This commit is contained in:
2026-03-15 20:24:29 -04:00
parent c84bc2522e
commit d40f8fc375
20 changed files with 2316 additions and 1240 deletions

65
api/v1/activity.go Normal file
View File

@@ -0,0 +1,65 @@
package v1
import (
"context"
"strconv"
"time"
"reichard.io/antholume/database"
)
// GET /activity
func (s *Server) GetActivity(ctx context.Context, request GetActivityRequestObject) (GetActivityResponseObject, error) {
auth, ok := s.getSessionFromContext(ctx)
if !ok {
return GetActivity401JSONResponse{Code: 401, Message: "Unauthorized"}, nil
}
docFilter := false
if request.Params.DocFilter != nil {
docFilter = *request.Params.DocFilter
}
documentID := ""
if request.Params.DocumentId != nil {
documentID = *request.Params.DocumentId
}
offset := int64(0)
if request.Params.Offset != nil {
offset = *request.Params.Offset
}
limit := int64(100)
if request.Params.Limit != nil {
limit = *request.Params.Limit
}
activities, err := s.db.Queries.GetActivity(ctx, database.GetActivityParams{
UserID: auth.UserName,
DocFilter: docFilter,
DocumentID: documentID,
Offset: offset,
Limit: limit,
})
if err != nil {
return GetActivity500JSONResponse{Code: 500, Message: err.Error()}, nil
}
apiActivities := make([]Activity, len(activities))
for i, a := range activities {
apiActivities[i] = Activity{
ActivityType: a.DeviceID,
DocumentId: a.DocumentID,
Id: strconv.Itoa(i),
Timestamp: time.Now(),
UserId: auth.UserName,
}
}
response := ActivityResponse{
Activities: apiActivities,
User: UserData{Username: auth.UserName, IsAdmin: auth.IsAdmin},
}
return GetActivity200JSONResponse(response), nil
}

1103
api/v1/api.gen.go Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -3,7 +3,6 @@ package v1
import (
"context"
"crypto/md5"
"encoding/json"
"fmt"
"net/http"
"time"
@@ -13,24 +12,125 @@ import (
log "github.com/sirupsen/logrus"
)
// authData represents session authentication data
type authData struct {
UserName string
IsAdmin bool
AuthHash string
// POST /auth/login
func (s *Server) Login(ctx context.Context, request LoginRequestObject) (LoginResponseObject, error) {
if request.Body == nil {
return Login400JSONResponse{Code: 400, Message: "Invalid request body"}, nil
}
req := *request.Body
if req.Username == "" || req.Password == "" {
return Login400JSONResponse{Code: 400, Message: "Invalid credentials"}, nil
}
// MD5 - KOSync compatibility
password := fmt.Sprintf("%x", md5.Sum([]byte(req.Password)))
// Verify credentials
user, err := s.db.Queries.GetUser(ctx, req.Username)
if err != nil {
return Login401JSONResponse{Code: 401, Message: "Invalid credentials"}, nil
}
if match, err := argon2.ComparePasswordAndHash(password, *user.Pass); err != nil || !match {
return Login401JSONResponse{Code: 401, Message: "Invalid credentials"}, nil
}
// Get request and response from context (set by middleware)
r := s.getRequestFromContext(ctx)
w := s.getResponseWriterFromContext(ctx)
if r == nil || w == nil {
return Login500JSONResponse{Code: 500, Message: "Internal context error"}, nil
}
// Create session
store := sessions.NewCookieStore([]byte(s.cfg.CookieAuthKey))
if s.cfg.CookieEncKey != "" {
if len(s.cfg.CookieEncKey) == 16 || len(s.cfg.CookieEncKey) == 32 {
store = sessions.NewCookieStore([]byte(s.cfg.CookieAuthKey), []byte(s.cfg.CookieEncKey))
}
}
session, _ := store.Get(r, "token")
session.Values["authorizedUser"] = user.ID
session.Values["isAdmin"] = user.Admin
session.Values["expiresAt"] = time.Now().Unix() + (60 * 60 * 24 * 7)
session.Values["authHash"] = *user.AuthHash
if err := session.Save(r, w); err != nil {
return Login500JSONResponse{Code: 500, Message: "Failed to create session"}, nil
}
return Login200JSONResponse{
Username: user.ID,
IsAdmin: user.Admin,
}, nil
}
// withAuth wraps a handler with session authentication
func (s *Server) withAuth(handler http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
auth, ok := s.getSession(r)
if !ok {
writeJSONError(w, http.StatusUnauthorized, "Unauthorized")
return
}
ctx := context.WithValue(r.Context(), "auth", auth)
handler(w, r.WithContext(ctx))
// POST /auth/logout
func (s *Server) Logout(ctx context.Context, request LogoutRequestObject) (LogoutResponseObject, error) {
_, ok := s.getSessionFromContext(ctx)
if !ok {
return Logout401JSONResponse{Code: 401, Message: "Unauthorized"}, nil
}
r := s.getRequestFromContext(ctx)
w := s.getResponseWriterFromContext(ctx)
if r == nil || w == nil {
return Logout401JSONResponse{Code: 401, Message: "Internal context error"}, nil
}
store := sessions.NewCookieStore([]byte(s.cfg.CookieAuthKey))
session, _ := store.Get(r, "token")
session.Values = make(map[any]any)
if err := session.Save(r, w); err != nil {
return Logout401JSONResponse{Code: 401, Message: "Failed to logout"}, nil
}
return Logout200Response{}, nil
}
// GET /auth/me
func (s *Server) GetMe(ctx context.Context, request GetMeRequestObject) (GetMeResponseObject, error) {
auth, ok := s.getSessionFromContext(ctx)
if !ok {
return GetMe401JSONResponse{Code: 401, Message: "Unauthorized"}, nil
}
return GetMe200JSONResponse{
Username: auth.UserName,
IsAdmin: auth.IsAdmin,
}, nil
}
// getSessionFromContext extracts authData from context
func (s *Server) getSessionFromContext(ctx context.Context) (authData, bool) {
auth, ok := ctx.Value("auth").(authData)
if !ok {
return authData{}, false
}
return auth, true
}
// getRequestFromContext extracts the HTTP request from context
func (s *Server) getRequestFromContext(ctx context.Context) *http.Request {
r, ok := ctx.Value("request").(*http.Request)
if !ok {
return nil
}
return r
}
// getResponseWriterFromContext extracts the response writer from context
func (s *Server) getResponseWriterFromContext(ctx context.Context) http.ResponseWriter {
w, ok := ctx.Value("response").(http.ResponseWriter)
if !ok {
return nil
}
return w
}
// getSession retrieves auth data from the session cookie
@@ -86,94 +186,9 @@ func (s *Server) getUserAuthHash(ctx context.Context, username string) (string,
return *user.AuthHash, nil
}
// apiLogin handles POST /api/v1/auth/login
func (s *Server) apiLogin(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeJSONError(w, http.StatusMethodNotAllowed, "Method not allowed")
return
}
var req LoginRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSONError(w, http.StatusBadRequest, "Invalid JSON")
return
}
if req.Username == "" || req.Password == "" {
writeJSONError(w, http.StatusBadRequest, "Invalid credentials")
return
}
// MD5 - KOSync compatibility
password := fmt.Sprintf("%x", md5.Sum([]byte(req.Password)))
// Verify credentials
user, err := s.db.Queries.GetUser(r.Context(), req.Username)
if err != nil {
writeJSONError(w, http.StatusUnauthorized, "Invalid credentials")
return
}
if match, err := argon2.ComparePasswordAndHash(password, *user.Pass); err != nil || !match {
writeJSONError(w, http.StatusUnauthorized, "Invalid credentials")
return
}
// Create session
store := sessions.NewCookieStore([]byte(s.cfg.CookieAuthKey))
if s.cfg.CookieEncKey != "" {
if len(s.cfg.CookieEncKey) == 16 || len(s.cfg.CookieEncKey) == 32 {
store = sessions.NewCookieStore([]byte(s.cfg.CookieAuthKey), []byte(s.cfg.CookieEncKey))
}
}
session, _ := store.Get(r, "token")
session.Values["authorizedUser"] = user.ID
session.Values["isAdmin"] = user.Admin
session.Values["expiresAt"] = time.Now().Unix() + (60 * 60 * 24 * 7)
session.Values["authHash"] = *user.AuthHash
if err := session.Save(r, w); err != nil {
writeJSONError(w, http.StatusInternalServerError, "Failed to create session")
return
}
writeJSON(w, http.StatusOK, LoginResponse{
Username: user.ID,
IsAdmin: user.Admin,
})
// authData represents authenticated user information
type authData struct {
UserName string
IsAdmin bool
AuthHash string
}
// apiLogout handles POST /api/v1/auth/logout
func (s *Server) apiLogout(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeJSONError(w, http.StatusMethodNotAllowed, "Method not allowed")
return
}
store := sessions.NewCookieStore([]byte(s.cfg.CookieAuthKey))
session, _ := store.Get(r, "token")
session.Values = make(map[any]any)
if err := session.Save(r, w); err != nil {
writeJSONError(w, http.StatusInternalServerError, "Failed to logout")
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "logged out"})
}
// apiGetMe handles GET /api/v1/auth/me
func (s *Server) apiGetMe(w http.ResponseWriter, r *http.Request) {
auth, ok := r.Context().Value("auth").(authData)
if !ok {
writeJSONError(w, http.StatusUnauthorized, "Unauthorized")
return
}
writeJSON(w, http.StatusOK, UserData{
Username: auth.UserName,
IsAdmin: auth.IsAdmin,
})
}

View File

@@ -9,19 +9,90 @@ import (
"net/http/httptest"
"testing"
"github.com/stretchr/testify/suite"
argon2 "github.com/alexedwards/argon2id"
"reichard.io/antholume/config"
"reichard.io/antholume/database"
)
func TestAPILogin(t *testing.T) {
db := setupTestDB(t)
cfg := testConfig()
server := NewServer(db, cfg)
type AuthTestSuite struct {
suite.Suite
db *database.DBManager
cfg *config.Config
srv *Server
}
// First, create a user
createTestUser(t, db, "testuser", "testpass")
func (suite *AuthTestSuite) setupConfig() *config.Config {
return &config.Config{
ListenPort: "8080",
DBType: "memory",
DBName: "test",
ConfigPath: "/tmp",
CookieAuthKey: "test-auth-key-32-bytes-long-enough",
CookieEncKey: "0123456789abcdef",
CookieSecure: false,
CookieHTTPOnly: true,
Version: "test",
DemoMode: false,
RegistrationEnabled: true,
}
}
func TestAuth(t *testing.T) {
suite.Run(t, new(AuthTestSuite))
}
func (suite *AuthTestSuite) SetupTest() {
suite.cfg = suite.setupConfig()
suite.db = database.NewMgr(suite.cfg)
suite.srv = NewServer(suite.db, suite.cfg)
}
func (suite *AuthTestSuite) createTestUser(username, password string) {
md5Hash := fmt.Sprintf("%x", md5.Sum([]byte(password)))
hashedPassword, err := argon2.CreateHash(md5Hash, argon2.DefaultParams)
suite.Require().NoError(err)
authHash := "test-auth-hash"
_, err = suite.db.Queries.CreateUser(suite.T().Context(), database.CreateUserParams{
ID: username,
Pass: &hashedPassword,
AuthHash: &authHash,
Admin: true,
})
suite.Require().NoError(err)
}
func (suite *AuthTestSuite) login(username, password string) *http.Cookie {
reqBody := LoginRequest{
Username: username,
Password: password,
}
body, err := json.Marshal(reqBody)
suite.Require().NoError(err)
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", bytes.NewReader(body))
w := httptest.NewRecorder()
suite.srv.ServeHTTP(w, req)
suite.Equal(http.StatusOK, w.Code, "login should return 200")
var resp LoginResponse
suite.Require().NoError(json.Unmarshal(w.Body.Bytes(), &resp))
cookies := w.Result().Cookies()
suite.Require().Len(cookies, 1, "should have session cookie")
return cookies[0]
}
func (suite *AuthTestSuite) TestAPILogin() {
suite.createTestUser("testuser", "testpass")
// Test login
reqBody := LoginRequest{
Username: "testuser",
Password: "testpass",
@@ -31,27 +102,16 @@ func TestAPILogin(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", bytes.NewReader(body))
w := httptest.NewRecorder()
server.ServeHTTP(w, req)
suite.srv.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("Expected 200, got %d: %s", w.Code, w.Body.String())
}
suite.Equal(http.StatusOK, w.Code)
var resp LoginResponse
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("Failed to unmarshal response: %v", err)
}
if resp.Username != "testuser" {
t.Errorf("Expected username 'testuser', got '%s'", resp.Username)
}
suite.Require().NoError(json.Unmarshal(w.Body.Bytes(), &resp))
suite.Equal("testuser", resp.Username)
}
func TestAPILoginInvalidCredentials(t *testing.T) {
db := setupTestDB(t)
cfg := testConfig()
server := NewServer(db, cfg)
func (suite *AuthTestSuite) TestAPILoginInvalidCredentials() {
reqBody := LoginRequest{
Username: "testuser",
Password: "wrongpass",
@@ -61,124 +121,46 @@ func TestAPILoginInvalidCredentials(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", bytes.NewReader(body))
w := httptest.NewRecorder()
server.ServeHTTP(w, req)
suite.srv.ServeHTTP(w, req)
if w.Code != http.StatusUnauthorized {
t.Fatalf("Expected 401, got %d", w.Code)
}
suite.Equal(http.StatusUnauthorized, w.Code)
}
func TestAPILogout(t *testing.T) {
db := setupTestDB(t)
cfg := testConfig()
server := NewServer(db, cfg)
func (suite *AuthTestSuite) TestAPILogout() {
suite.createTestUser("testuser", "testpass")
cookie := suite.login("testuser", "testpass")
// Create user and login
createTestUser(t, db, "testuser", "testpass")
// Login first
reqBody := LoginRequest{Username: "testuser", Password: "testpass"}
body, _ := json.Marshal(reqBody)
loginReq := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", bytes.NewReader(body))
loginResp := httptest.NewRecorder()
server.ServeHTTP(loginResp, loginReq)
// Get session cookie
cookies := loginResp.Result().Cookies()
if len(cookies) == 0 {
t.Fatal("No session cookie returned")
}
// Logout
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/logout", nil)
req.AddCookie(cookies[0])
req.AddCookie(cookie)
w := httptest.NewRecorder()
server.ServeHTTP(w, req)
suite.srv.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("Expected 200, got %d", w.Code)
}
suite.Equal(http.StatusOK, w.Code)
}
func TestAPIGetMe(t *testing.T) {
db := setupTestDB(t)
cfg := testConfig()
server := NewServer(db, cfg)
func (suite *AuthTestSuite) TestAPIGetMe() {
suite.createTestUser("testuser", "testpass")
cookie := suite.login("testuser", "testpass")
// Create user and login
createTestUser(t, db, "testuser", "testpass")
// Login first
reqBody := LoginRequest{Username: "testuser", Password: "testpass"}
body, _ := json.Marshal(reqBody)
loginReq := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", bytes.NewReader(body))
loginResp := httptest.NewRecorder()
server.ServeHTTP(loginResp, loginReq)
// Get session cookie
cookies := loginResp.Result().Cookies()
if len(cookies) == 0 {
t.Fatal("No session cookie returned")
}
// Get me
req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/me", nil)
req.AddCookie(cookies[0])
req.AddCookie(cookie)
w := httptest.NewRecorder()
server.ServeHTTP(w, req)
suite.srv.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("Expected 200, got %d", w.Code)
}
suite.Equal(http.StatusOK, w.Code)
var resp UserData
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("Failed to unmarshal response: %v", err)
}
if resp.Username != "testuser" {
t.Errorf("Expected username 'testuser', got '%s'", resp.Username)
}
suite.Require().NoError(json.Unmarshal(w.Body.Bytes(), &resp))
suite.Equal("testuser", resp.Username)
}
func TestAPIGetMeUnauthenticated(t *testing.T) {
db := setupTestDB(t)
cfg := testConfig()
server := NewServer(db, cfg)
func (suite *AuthTestSuite) TestAPIGetMeUnauthenticated() {
req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/me", nil)
w := httptest.NewRecorder()
server.ServeHTTP(w, req)
suite.srv.ServeHTTP(w, req)
if w.Code != http.StatusUnauthorized {
t.Fatalf("Expected 401, got %d", w.Code)
}
}
func createTestUser(t *testing.T, db *database.DBManager, username, password string) {
t.Helper()
// MD5 hash for KOSync compatibility (matches existing system)
md5Hash := fmt.Sprintf("%x", md5.Sum([]byte(password)))
// Then argon2 hash the MD5
hashedPassword, err := argon2.CreateHash(md5Hash, argon2.DefaultParams)
if err != nil {
t.Fatalf("Failed to hash password: %v", err)
}
authHash := "test-auth-hash"
_, err = db.Queries.CreateUser(t.Context(), database.CreateUserParams{
ID: username,
Pass: &hashedPassword,
AuthHash: &authHash,
Admin: true,
})
if err != nil {
t.Fatalf("Failed to create user: %v", err)
}
suite.Equal(http.StatusUnauthorized, w.Code)
}

View File

@@ -1,141 +1,130 @@
package v1
import (
"net/http"
"strconv"
"strings"
"context"
"reichard.io/antholume/database"
"reichard.io/antholume/pkg/ptr"
)
// apiGetDocuments handles GET /api/v1/documents
// Deprecated: Use GetDocuments with DocumentListRequest instead
func (s *Server) apiGetDocuments(w http.ResponseWriter, r *http.Request) {
// Parse query params
query := r.URL.Query()
page, _ := strconv.ParseInt(query.Get("page"), 10, 64)
if page == 0 {
page = 1
}
limit, _ := strconv.ParseInt(query.Get("limit"), 10, 64)
if limit == 0 {
limit = 9
}
search := query.Get("search")
// Get auth from context
auth, ok := r.Context().Value("auth").(authData)
// GET /documents
func (s *Server) GetDocuments(ctx context.Context, request GetDocumentsRequestObject) (GetDocumentsResponseObject, error) {
auth, ok := s.getSessionFromContext(ctx)
if !ok {
writeJSONError(w, http.StatusUnauthorized, "Unauthorized")
return
return GetDocuments401JSONResponse{Code: 401, Message: "Unauthorized"}, nil
}
// Build query
var queryPtr *string
if search != "" {
queryPtr = ptr.Of("%" + search + "%")
page := int64(1)
if request.Params.Page != nil {
page = *request.Params.Page
}
limit := int64(9)
if request.Params.Limit != nil {
limit = *request.Params.Limit
}
search := ""
if request.Params.Search != nil {
search = "%" + *request.Params.Search + "%"
}
// Query database
rows, err := s.db.Queries.GetDocumentsWithStats(
r.Context(),
ctx,
database.GetDocumentsWithStatsParams{
UserID: auth.UserName,
Query: queryPtr,
Deleted: ptr.Of(false),
Query: &search,
Deleted: ptrOf(false),
Offset: (page - 1) * limit,
Limit: limit,
},
)
if err != nil {
writeJSONError(w, http.StatusInternalServerError, err.Error())
return
return GetDocuments500JSONResponse{Code: 500, Message: err.Error()}, nil
}
// Calculate pagination
total := int64(len(rows))
var nextPage *int64
var previousPage *int64
if page*limit < total {
nextPage = ptr.Of(page + 1)
nextPage = ptrOf(page + 1)
}
if page > 1 {
previousPage = ptr.Of(page - 1)
previousPage = ptrOf(page - 1)
}
// Get word counts
apiDocuments := make([]Document, len(rows))
wordCounts := make([]WordCount, 0, len(rows))
for _, row := range rows {
for i, row := range rows {
apiDocuments[i] = Document{
Id: row.ID,
Title: *row.Title,
Author: *row.Author,
Words: row.Words,
}
if row.Words != nil {
wordCounts = append(wordCounts, WordCount{
DocumentID: row.ID,
DocumentId: row.ID,
Count: *row.Words,
})
}
}
// Return response
writeJSON(w, http.StatusOK, DocumentsResponse{
Documents: rows,
response := DocumentsResponse{
Documents: apiDocuments,
Total: total,
Page: page,
Limit: limit,
NextPage: nextPage,
PreviousPage: previousPage,
Search: ptr.Of(search),
Search: request.Params.Search,
User: UserData{Username: auth.UserName, IsAdmin: auth.IsAdmin},
WordCounts: wordCounts,
})
}
return GetDocuments200JSONResponse(response), nil
}
// apiGetDocument handles GET /api/v1/documents/:id
// Deprecated: Use GetDocument with DocumentRequest instead
func (s *Server) apiGetDocument(w http.ResponseWriter, r *http.Request) {
// Extract ID from URL path
path := strings.TrimPrefix(r.URL.Path, "/api/v1/documents/")
id := strings.TrimPrefix(path, "/")
if id == "" {
writeJSONError(w, http.StatusBadRequest, "Document ID required")
return
}
// Get auth from context
auth, ok := r.Context().Value("auth").(authData)
// GET /documents/{id}
func (s *Server) GetDocument(ctx context.Context, request GetDocumentRequestObject) (GetDocumentResponseObject, error) {
auth, ok := s.getSessionFromContext(ctx)
if !ok {
writeJSONError(w, http.StatusUnauthorized, "Unauthorized")
return
return GetDocument401JSONResponse{Code: 401, Message: "Unauthorized"}, nil
}
// Query database
doc, err := s.db.Queries.GetDocument(r.Context(), id)
doc, err := s.db.Queries.GetDocument(ctx, request.Id)
if err != nil {
writeJSONError(w, http.StatusNotFound, "Document not found")
return
return GetDocument404JSONResponse{Code: 404, Message: "Document not found"}, nil
}
// Get progress
progressRow, err := s.db.Queries.GetDocumentProgress(r.Context(), database.GetDocumentProgressParams{
progressRow, err := s.db.Queries.GetDocumentProgress(ctx, database.GetDocumentProgressParams{
UserID: auth.UserName,
DocumentID: id,
DocumentID: request.Id,
})
var progress *Progress
if err == nil {
progress = &Progress{
UserID: progressRow.UserID,
DocumentID: progressRow.DocumentID,
DeviceID: progressRow.DeviceID,
UserId: progressRow.UserID,
DocumentId: progressRow.DocumentID,
DeviceId: progressRow.DeviceID,
Percentage: progressRow.Percentage,
Progress: progressRow.Progress,
CreatedAt: progressRow.CreatedAt,
CreatedAt: parseTime(progressRow.CreatedAt),
}
}
// Return response
writeJSON(w, http.StatusOK, DocumentResponse{
Document: doc,
apiDoc := Document{
Id: doc.ID,
Title: *doc.Title,
Author: *doc.Author,
CreatedAt: parseTime(doc.CreatedAt),
UpdatedAt: parseTime(doc.UpdatedAt),
Deleted: doc.Deleted,
Words: doc.Words,
}
response := DocumentResponse{
Document: apiDoc,
User: UserData{Username: auth.UserName, IsAdmin: auth.IsAdmin},
Progress: progress,
})
}
}
return GetDocument200JSONResponse(response), nil
}

View File

@@ -2,163 +2,160 @@ package v1
import (
"bytes"
"crypto/md5"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/suite"
argon2 "github.com/alexedwards/argon2id"
"reichard.io/antholume/config"
"reichard.io/antholume/database"
"reichard.io/antholume/pkg/ptr"
)
func TestAPIGetDocuments(t *testing.T) {
db := setupTestDB(t)
cfg := testConfig()
server := NewServer(db, cfg)
type DocumentsTestSuite struct {
suite.Suite
db *database.DBManager
cfg *config.Config
srv *Server
}
// Create user and login
createTestUser(t, db, "testuser", "testpass")
// Login first
reqBody := LoginRequest{Username: "testuser", Password: "testpass"}
body, _ := json.Marshal(reqBody)
loginReq := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", bytes.NewReader(body))
loginResp := httptest.NewRecorder()
server.ServeHTTP(loginResp, loginReq)
// Get session cookie
cookies := loginResp.Result().Cookies()
if len(cookies) == 0 {
t.Fatal("No session cookie returned")
}
// Get documents
req := httptest.NewRequest(http.MethodGet, "/api/v1/documents?page=1&limit=9", nil)
req.AddCookie(cookies[0])
w := httptest.NewRecorder()
server.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("Expected 200, got %d: %s", w.Code, w.Body.String())
}
var resp DocumentsResponse
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("Failed to unmarshal response: %v", err)
}
if resp.Page != 1 {
t.Errorf("Expected page 1, got %d", resp.Page)
}
if resp.Limit != 9 {
t.Errorf("Expected limit 9, got %d", resp.Limit)
}
if resp.User.Username != "testuser" {
t.Errorf("Expected username 'testuser', got '%s'", resp.User.Username)
func (suite *DocumentsTestSuite) setupConfig() *config.Config {
return &config.Config{
ListenPort: "8080",
DBType: "memory",
DBName: "test",
ConfigPath: "/tmp",
CookieAuthKey: "test-auth-key-32-bytes-long-enough",
CookieEncKey: "0123456789abcdef",
CookieSecure: false,
CookieHTTPOnly: true,
Version: "test",
DemoMode: false,
RegistrationEnabled: true,
}
}
func TestAPIGetDocumentsUnauthenticated(t *testing.T) {
db := setupTestDB(t)
cfg := testConfig()
server := NewServer(db, cfg)
func TestDocuments(t *testing.T) {
suite.Run(t, new(DocumentsTestSuite))
}
func (suite *DocumentsTestSuite) SetupTest() {
suite.cfg = suite.setupConfig()
suite.db = database.NewMgr(suite.cfg)
suite.srv = NewServer(suite.db, suite.cfg)
}
func (suite *DocumentsTestSuite) createTestUser(username, password string) {
suite.authTestSuiteHelper(username, password)
}
func (suite *DocumentsTestSuite) login(username, password string) *http.Cookie {
return suite.authLoginHelper(username, password)
}
func (suite *DocumentsTestSuite) authTestSuiteHelper(username, password string) {
// MD5 hash for KOSync compatibility (matches existing system)
md5Hash := fmt.Sprintf("%x", md5.Sum([]byte(password)))
// Then argon2 hash the MD5
hashedPassword, err := argon2.CreateHash(md5Hash, argon2.DefaultParams)
suite.Require().NoError(err)
_, err = suite.db.Queries.CreateUser(suite.T().Context(), database.CreateUserParams{
ID: username,
Pass: &hashedPassword,
AuthHash: ptr.Of("test-auth-hash"),
Admin: true,
})
suite.Require().NoError(err)
}
func (suite *DocumentsTestSuite) authLoginHelper(username, password string) *http.Cookie {
reqBody := LoginRequest{Username: username, Password: password}
body, err := json.Marshal(reqBody)
suite.Require().NoError(err)
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", bytes.NewReader(body))
w := httptest.NewRecorder()
suite.srv.ServeHTTP(w, req)
suite.Equal(http.StatusOK, w.Code)
cookies := w.Result().Cookies()
suite.Require().Len(cookies, 1)
return cookies[0]
}
func (suite *DocumentsTestSuite) TestAPIGetDocuments() {
suite.createTestUser("testuser", "testpass")
cookie := suite.login("testuser", "testpass")
req := httptest.NewRequest(http.MethodGet, "/api/v1/documents?page=1&limit=9", nil)
req.AddCookie(cookie)
w := httptest.NewRecorder()
suite.srv.ServeHTTP(w, req)
suite.Equal(http.StatusOK, w.Code)
var resp DocumentsResponse
suite.Require().NoError(json.Unmarshal(w.Body.Bytes(), &resp))
suite.Equal(int64(1), resp.Page)
suite.Equal(int64(9), resp.Limit)
suite.Equal("testuser", resp.User.Username)
}
func (suite *DocumentsTestSuite) TestAPIGetDocumentsUnauthenticated() {
req := httptest.NewRequest(http.MethodGet, "/api/v1/documents", nil)
w := httptest.NewRecorder()
server.ServeHTTP(w, req)
suite.srv.ServeHTTP(w, req)
if w.Code != http.StatusUnauthorized {
t.Fatalf("Expected 401, got %d", w.Code)
}
suite.Equal(http.StatusUnauthorized, w.Code)
}
func TestAPIGetDocument(t *testing.T) {
db := setupTestDB(t)
cfg := testConfig()
server := NewServer(db, cfg)
func (suite *DocumentsTestSuite) TestAPIGetDocument() {
suite.createTestUser("testuser", "testpass")
// Create user
createTestUser(t, db, "testuser", "testpass")
// Create a document using UpsertDocument
docID := "test-doc-1"
_, err := db.Queries.UpsertDocument(t.Context(), database.UpsertDocumentParams{
_, err := suite.db.Queries.UpsertDocument(suite.T().Context(), database.UpsertDocumentParams{
ID: docID,
Title: ptr.Of("Test Document"),
Author: ptr.Of("Test Author"),
})
if err != nil {
t.Fatalf("Failed to create document: %v", err)
}
suite.Require().NoError(err)
// Login
reqBody := LoginRequest{Username: "testuser", Password: "testpass"}
body, _ := json.Marshal(reqBody)
loginReq := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", bytes.NewReader(body))
loginResp := httptest.NewRecorder()
server.ServeHTTP(loginResp, loginReq)
cookie := suite.login("testuser", "testpass")
cookies := loginResp.Result().Cookies()
if len(cookies) == 0 {
t.Fatal("No session cookie returned")
}
// Get document
req := httptest.NewRequest(http.MethodGet, "/api/v1/documents/"+docID, nil)
req.AddCookie(cookies[0])
req.AddCookie(cookie)
w := httptest.NewRecorder()
server.ServeHTTP(w, req)
suite.srv.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("Expected 200, got %d: %s", w.Code, w.Body.String())
}
suite.Equal(http.StatusOK, w.Code)
var resp DocumentResponse
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("Failed to unmarshal response: %v", err)
}
if resp.Document.ID != docID {
t.Errorf("Expected document ID '%s', got '%s'", docID, resp.Document.ID)
}
if *resp.Document.Title != "Test Document" {
t.Errorf("Expected title 'Test Document', got '%s'", *resp.Document.Title)
}
suite.Require().NoError(json.Unmarshal(w.Body.Bytes(), &resp))
suite.Equal(docID, resp.Document.Id)
suite.Equal("Test Document", resp.Document.Title)
}
func TestAPIGetDocumentNotFound(t *testing.T) {
db := setupTestDB(t)
cfg := testConfig()
server := NewServer(db, cfg)
func (suite *DocumentsTestSuite) TestAPIGetDocumentNotFound() {
suite.createTestUser("testuser", "testpass")
cookie := suite.login("testuser", "testpass")
// Create user and login
createTestUser(t, db, "testuser", "testpass")
reqBody := LoginRequest{Username: "testuser", Password: "testpass"}
body, _ := json.Marshal(reqBody)
loginReq := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", bytes.NewReader(body))
loginResp := httptest.NewRecorder()
server.ServeHTTP(loginResp, loginReq)
cookies := loginResp.Result().Cookies()
if len(cookies) == 0 {
t.Fatal("No session cookie returned")
}
// Get non-existent document
req := httptest.NewRequest(http.MethodGet, "/api/v1/documents/non-existent", nil)
req.AddCookie(cookies[0])
req.AddCookie(cookie)
w := httptest.NewRecorder()
server.ServeHTTP(w, req)
suite.srv.ServeHTTP(w, req)
if w.Code != http.StatusNotFound {
t.Fatalf("Expected 404, got %d", w.Code)
}
suite.Equal(http.StatusNotFound, w.Code)
}

3
api/v1/generate.go Normal file
View File

@@ -0,0 +1,3 @@
package v1
//go:generate oapi-codegen -config oapi-codegen.yaml openapi.yaml

View File

@@ -1,294 +0,0 @@
package v1
import (
"context"
"net/http"
"strconv"
"strings"
"reichard.io/antholume/database"
)
// DocumentRequest represents a request for a single document
type DocumentRequest struct {
ID string
}
// DocumentListRequest represents a request for listing documents
type DocumentListRequest struct {
Page int64
Limit int64
Search *string
}
// ProgressRequest represents a request for document progress
type ProgressRequest struct {
ID string
}
// ActivityRequest represents a request for activity data
type ActivityRequest struct {
DocFilter bool
DocumentID string
Offset int64
Limit int64
}
// SettingsRequest represents a request for settings data
type SettingsRequest struct{}
// GetDocument handles GET /api/v1/documents/:id
func (s *Server) GetDocument(ctx context.Context, req DocumentRequest) (DocumentResponse, error) {
auth := getAuthFromContext(ctx)
if auth == nil {
return DocumentResponse{}, &apiError{status: http.StatusUnauthorized, message: "Unauthorized"}
}
doc, err := s.db.Queries.GetDocument(ctx, req.ID)
if err != nil {
return DocumentResponse{}, &apiError{status: http.StatusNotFound, message: "Document not found"}
}
progressRow, err := s.db.Queries.GetDocumentProgress(ctx, database.GetDocumentProgressParams{
UserID: auth.UserName,
DocumentID: req.ID,
})
var progress *Progress
if err == nil {
progress = &Progress{
UserID: progressRow.UserID,
DocumentID: progressRow.DocumentID,
DeviceID: progressRow.DeviceID,
Percentage: progressRow.Percentage,
Progress: progressRow.Progress,
CreatedAt: progressRow.CreatedAt,
}
}
return DocumentResponse{
Document: doc,
User: UserData{Username: auth.UserName, IsAdmin: auth.IsAdmin},
Progress: progress,
}, nil
}
// GetDocuments handles GET /api/v1/documents
func (s *Server) GetDocuments(ctx context.Context, req DocumentListRequest) (DocumentsResponse, error) {
auth := getAuthFromContext(ctx)
if auth == nil {
return DocumentsResponse{}, &apiError{status: http.StatusUnauthorized, message: "Unauthorized"}
}
rows, err := s.db.Queries.GetDocumentsWithStats(
ctx,
database.GetDocumentsWithStatsParams{
UserID: auth.UserName,
Query: req.Search,
Deleted: ptrOf(false),
Offset: (req.Page - 1) * req.Limit,
Limit: req.Limit,
},
)
if err != nil {
return DocumentsResponse{}, &apiError{status: http.StatusInternalServerError, message: err.Error()}
}
total := int64(len(rows))
var nextPage *int64
var previousPage *int64
if req.Page*req.Limit < total {
nextPage = ptrOf(req.Page + 1)
}
if req.Page > 1 {
previousPage = ptrOf(req.Page - 1)
}
wordCounts := make([]WordCount, 0, len(rows))
for _, row := range rows {
if row.Words != nil {
wordCounts = append(wordCounts, WordCount{
DocumentID: row.ID,
Count: *row.Words,
})
}
}
return DocumentsResponse{
Documents: rows,
Total: total,
Page: req.Page,
Limit: req.Limit,
NextPage: nextPage,
PreviousPage: previousPage,
Search: req.Search,
User: UserData{Username: auth.UserName, IsAdmin: auth.IsAdmin},
WordCounts: wordCounts,
}, nil
}
// GetProgress handles GET /api/v1/progress/:id
func (s *Server) GetProgress(ctx context.Context, req ProgressRequest) (Progress, error) {
auth := getAuthFromContext(ctx)
if auth == nil {
return Progress{}, &apiError{status: http.StatusUnauthorized, message: "Unauthorized"}
}
if req.ID == "" {
return Progress{}, &apiError{status: http.StatusBadRequest, message: "Document ID required"}
}
progressRow, err := s.db.Queries.GetDocumentProgress(ctx, database.GetDocumentProgressParams{
UserID: auth.UserName,
DocumentID: req.ID,
})
if err != nil {
return Progress{}, &apiError{status: http.StatusNotFound, message: "Progress not found"}
}
return Progress{
UserID: progressRow.UserID,
DocumentID: progressRow.DocumentID,
DeviceID: progressRow.DeviceID,
Percentage: progressRow.Percentage,
Progress: progressRow.Progress,
CreatedAt: progressRow.CreatedAt,
}, nil
}
// GetActivity handles GET /api/v1/activity
func (s *Server) GetActivity(ctx context.Context, req ActivityRequest) (ActivityResponse, error) {
auth := getAuthFromContext(ctx)
if auth == nil {
return ActivityResponse{}, &apiError{status: http.StatusUnauthorized, message: "Unauthorized"}
}
activities, err := s.db.Queries.GetActivity(ctx, database.GetActivityParams{
UserID: auth.UserName,
DocFilter: req.DocFilter,
DocumentID: req.DocumentID,
Offset: req.Offset,
Limit: req.Limit,
})
if err != nil {
return ActivityResponse{}, &apiError{status: http.StatusInternalServerError, message: err.Error()}
}
return ActivityResponse{
Activities: activities,
User: UserData{Username: auth.UserName, IsAdmin: auth.IsAdmin},
}, nil
}
// GetSettings handles GET /api/v1/settings
func (s *Server) GetSettings(ctx context.Context, req SettingsRequest) (SettingsResponse, error) {
auth := getAuthFromContext(ctx)
if auth == nil {
return SettingsResponse{}, &apiError{status: http.StatusUnauthorized, message: "Unauthorized"}
}
user, err := s.db.Queries.GetUser(ctx, auth.UserName)
if err != nil {
return SettingsResponse{}, &apiError{status: http.StatusInternalServerError, message: err.Error()}
}
return SettingsResponse{
Settings: []database.Setting{},
User: UserData{Username: auth.UserName, IsAdmin: auth.IsAdmin},
Timezone: user.Timezone,
}, nil
}
// getAuthFromContext extracts authData from context
func getAuthFromContext(ctx context.Context) *authData {
auth, ok := ctx.Value("auth").(authData)
if !ok {
return nil
}
return &auth
}
// apiError represents an API error with status code
type apiError struct {
status int
message string
}
// Error implements error interface
func (e *apiError) Error() string {
return e.message
}
// handlerFunc is a generic API handler function
type handlerFunc[T, R any] func(context.Context, T) (R, error)
// requestParser parses an HTTP request into a request struct
type requestParser[T any] func(*http.Request) T
// handle wraps an API handler function with HTTP response writing
func handle[T, R any](fn handlerFunc[T, R], parser requestParser[T]) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
req := parser(r)
resp, err := fn(r.Context(), req)
if err != nil {
if apiErr, ok := err.(*apiError); ok {
writeJSONError(w, apiErr.status, apiErr.message)
} else {
writeJSONError(w, http.StatusInternalServerError, err.Error())
}
return
}
writeJSON(w, http.StatusOK, resp)
}
}
// parseDocumentRequest extracts document request from HTTP request
func parseDocumentRequest(r *http.Request) DocumentRequest {
path := strings.TrimPrefix(r.URL.Path, "/api/v1/documents/")
id := strings.TrimPrefix(path, "/")
return DocumentRequest{ID: id}
}
// parseDocumentListRequest extracts document list request from URL query
func parseDocumentListRequest(r *http.Request) DocumentListRequest {
query := r.URL.Query()
page, _ := strconv.ParseInt(query.Get("page"), 10, 64)
if page == 0 {
page = 1
}
limit, _ := strconv.ParseInt(query.Get("limit"), 10, 64)
if limit == 0 {
limit = 9
}
search := query.Get("search")
var searchPtr *string
if search != "" {
searchPtr = ptrOf("%" + search + "%")
}
return DocumentListRequest{
Page: page,
Limit: limit,
Search: searchPtr,
}
}
// parseProgressRequest extracts progress request from HTTP request
func parseProgressRequest(r *http.Request) ProgressRequest {
path := strings.TrimPrefix(r.URL.Path, "/api/v1/progress/")
id := strings.TrimPrefix(path, "/")
return ProgressRequest{ID: id}
}
// parseActivityRequest extracts activity request from HTTP request
func parseActivityRequest(r *http.Request) ActivityRequest {
return ActivityRequest{
DocFilter: false,
DocumentID: "",
Offset: 0,
Limit: 100,
}
}
// parseSettingsRequest extracts settings request from HTTP request
func parseSettingsRequest(r *http.Request) SettingsRequest {
return SettingsRequest{}
}

6
api/v1/oapi-codegen.yaml Normal file
View File

@@ -0,0 +1,6 @@
package: v1
generate:
std-http-server: true
strict-server: true
models: true
output: api.gen.go

526
api/v1/openapi.yaml Normal file
View File

@@ -0,0 +1,526 @@
openapi: 3.0.3
info:
title: AnthoLume API v1
version: 1.0.0
description: REST API for AnthoLume document management system
servers:
- url: /api/v1
components:
schemas:
Document:
type: object
properties:
id:
type: string
title:
type: string
author:
type: string
created_at:
type: string
format: date-time
updated_at:
type: string
format: date-time
deleted:
type: boolean
words:
type: integer
format: int64
required:
- id
- title
- author
- created_at
- updated_at
- deleted
UserData:
type: object
properties:
username:
type: string
is_admin:
type: boolean
required:
- username
- is_admin
WordCount:
type: object
properties:
document_id:
type: string
count:
type: integer
format: int64
required:
- document_id
- count
Progress:
type: object
properties:
user_id:
type: string
document_id:
type: string
device_id:
type: string
percentage:
type: number
format: double
progress:
type: string
created_at:
type: string
format: date-time
required:
- user_id
- document_id
- device_id
- percentage
- progress
- created_at
Activity:
type: object
properties:
id:
type: string
user_id:
type: string
document_id:
type: string
activity_type:
type: string
timestamp:
type: string
format: date-time
required:
- id
- user_id
- document_id
- activity_type
- timestamp
Setting:
type: object
properties:
id:
type: string
user_id:
type: string
key:
type: string
value:
type: string
required:
- id
- user_id
- key
- value
DocumentsResponse:
type: object
properties:
documents:
type: array
items:
$ref: '#/components/schemas/Document'
total:
type: integer
format: int64
page:
type: integer
format: int64
limit:
type: integer
format: int64
next_page:
type: integer
format: int64
previous_page:
type: integer
format: int64
search:
type: string
user:
$ref: '#/components/schemas/UserData'
word_counts:
type: array
items:
$ref: '#/components/schemas/WordCount'
required:
- documents
- total
- page
- limit
- user
- word_counts
DocumentResponse:
type: object
properties:
document:
$ref: '#/components/schemas/Document'
user:
$ref: '#/components/schemas/UserData'
progress:
$ref: '#/components/schemas/Progress'
required:
- document
- user
ProgressResponse:
$ref: '#/components/schemas/Progress'
ActivityResponse:
type: object
properties:
activities:
type: array
items:
$ref: '#/components/schemas/Activity'
user:
$ref: '#/components/schemas/UserData'
required:
- activities
- user
SettingsResponse:
type: object
properties:
settings:
type: array
items:
$ref: '#/components/schemas/Setting'
user:
$ref: '#/components/schemas/UserData'
timezone:
type: string
required:
- settings
- user
LoginRequest:
type: object
properties:
username:
type: string
password:
type: string
required:
- username
- password
LoginResponse:
type: object
properties:
username:
type: string
is_admin:
type: boolean
required:
- username
- is_admin
ErrorResponse:
type: object
properties:
code:
type: integer
message:
type: string
required:
- code
- message
securitySchemes:
BearerAuth:
type: http
scheme: bearer
paths:
/documents:
get:
summary: List documents
operationId: getDocuments
tags:
- Documents
parameters:
- name: page
in: query
schema:
type: integer
format: int64
default: 1
- name: limit
in: query
schema:
type: integer
format: int64
default: 9
- name: search
in: query
schema:
type: string
security:
- BearerAuth: []
responses:
200:
description: Successful response
content:
application/json:
schema:
$ref: '#/components/schemas/DocumentsResponse'
401:
description: Unauthorized
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
500:
description: Internal server error
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
/documents/{id}:
get:
summary: Get a single document
operationId: getDocument
tags:
- Documents
parameters:
- name: id
in: path
required: true
schema:
type: string
security:
- BearerAuth: []
responses:
200:
description: Successful response
content:
application/json:
schema:
$ref: '#/components/schemas/DocumentResponse'
401:
description: Unauthorized
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
404:
description: Document not found
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
500:
description: Internal server error
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
/progress/{id}:
get:
summary: Get document progress
operationId: getProgress
tags:
- Progress
parameters:
- name: id
in: path
required: true
schema:
type: string
security:
- BearerAuth: []
responses:
200:
description: Successful response
content:
application/json:
schema:
$ref: '#/components/schemas/ProgressResponse'
401:
description: Unauthorized
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
404:
description: Progress not found
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
500:
description: Internal server error
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
/activity:
get:
summary: Get activity data
operationId: getActivity
tags:
- Activity
parameters:
- name: doc_filter
in: query
schema:
type: boolean
default: false
- name: document_id
in: query
schema:
type: string
- name: offset
in: query
schema:
type: integer
format: int64
default: 0
- name: limit
in: query
schema:
type: integer
format: int64
default: 100
security:
- BearerAuth: []
responses:
200:
description: Successful response
content:
application/json:
schema:
$ref: '#/components/schemas/ActivityResponse'
401:
description: Unauthorized
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
500:
description: Internal server error
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
/settings:
get:
summary: Get user settings
operationId: getSettings
tags:
- Settings
security:
- BearerAuth: []
responses:
200:
description: Successful response
content:
application/json:
schema:
$ref: '#/components/schemas/SettingsResponse'
401:
description: Unauthorized
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
500:
description: Internal server error
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
/auth/login:
post:
summary: User login
operationId: login
tags:
- Auth
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/LoginRequest'
responses:
200:
description: Successful login
content:
application/json:
schema:
$ref: '#/components/schemas/LoginResponse'
400:
description: Bad request
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
401:
description: Invalid credentials
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
500:
description: Internal server error
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
/auth/logout:
post:
summary: User logout
operationId: logout
tags:
- Auth
security:
- BearerAuth: []
responses:
200:
description: Successful logout
401:
description: Unauthorized
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
/auth/me:
get:
summary: Get current user info
operationId: getMe
tags:
- Auth
security:
- BearerAuth: []
responses:
200:
description: Successful response
content:
application/json:
schema:
$ref: '#/components/schemas/LoginResponse'
401:
description: Unauthorized
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'

38
api/v1/progress.go Normal file
View File

@@ -0,0 +1,38 @@
package v1
import (
"context"
"reichard.io/antholume/database"
)
// GET /progress/{id}
func (s *Server) GetProgress(ctx context.Context, request GetProgressRequestObject) (GetProgressResponseObject, error) {
auth, ok := s.getSessionFromContext(ctx)
if !ok {
return GetProgress401JSONResponse{Code: 401, Message: "Unauthorized"}, nil
}
if request.Id == "" {
return GetProgress404JSONResponse{Code: 404, Message: "Document ID required"}, nil
}
progressRow, err := s.db.Queries.GetDocumentProgress(ctx, database.GetDocumentProgressParams{
UserID: auth.UserName,
DocumentID: request.Id,
})
if err != nil {
return GetProgress404JSONResponse{Code: 404, Message: "Progress not found"}, nil
}
response := Progress{
UserId: progressRow.UserID,
DocumentId: progressRow.DocumentID,
DeviceId: progressRow.DeviceID,
Percentage: progressRow.Percentage,
Progress: progressRow.Progress,
CreatedAt: parseTime(progressRow.CreatedAt),
}
return GetProgress200JSONResponse(response), nil
}

View File

@@ -1,12 +1,16 @@
package v1
import (
"context"
"encoding/json"
"net/http"
"reichard.io/antholume/config"
"reichard.io/antholume/database"
)
var _ StrictServerInterface = (*Server)(nil)
type Server struct {
mux *http.ServeMux
db *database.DBManager
@@ -20,7 +24,11 @@ func NewServer(db *database.DBManager, cfg *config.Config) *Server {
db: db,
cfg: cfg,
}
s.registerRoutes()
// Create strict handler with authentication middleware
strictHandler := NewStrictHandler(s, []StrictMiddlewareFunc{s.authMiddleware})
s.mux = HandlerFromMuxWithBaseURL(strictHandler, s.mux, "/api/v1").(*http.ServeMux)
return s
}
@@ -28,23 +36,31 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
s.mux.ServeHTTP(w, r)
}
// registerRoutes sets up all API routes
func (s *Server) registerRoutes() {
// Documents endpoints
s.mux.HandleFunc("/api/v1/documents", s.withAuth(wrapRequest(s.GetDocuments, parseDocumentListRequest)))
s.mux.HandleFunc("/api/v1/documents/", s.withAuth(wrapRequest(s.GetDocument, parseDocumentRequest)))
// authMiddleware adds authentication context to requests
func (s *Server) authMiddleware(handler StrictHandlerFunc, operationID string) StrictHandlerFunc {
return func(ctx context.Context, w http.ResponseWriter, r *http.Request, request any) (any, error) {
// Store request and response in context for all handlers
ctx = context.WithValue(ctx, "request", r)
ctx = context.WithValue(ctx, "response", w)
// Progress endpoints
s.mux.HandleFunc("/api/v1/progress/", s.withAuth(wrapRequest(s.GetProgress, parseProgressRequest)))
// Skip auth for login endpoint
if operationID == "Login" {
return handler(ctx, w, r, request)
}
// Activity endpoints
s.mux.HandleFunc("/api/v1/activity", s.withAuth(wrapRequest(s.GetActivity, parseActivityRequest)))
auth, ok := s.getSession(r)
if !ok {
// Write 401 response directly
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(401)
json.NewEncoder(w).Encode(ErrorResponse{Code: 401, Message: "Unauthorized"})
return nil, nil
}
// Settings endpoints
s.mux.HandleFunc("/api/v1/settings", s.withAuth(wrapRequest(s.GetSettings, parseSettingsRequest)))
// Store auth in context for handlers to access
ctx = context.WithValue(ctx, "auth", auth)
// Auth endpoints
s.mux.HandleFunc("/api/v1/auth/login", s.apiLogin)
s.mux.HandleFunc("/api/v1/auth/logout", s.withAuth(s.apiLogout))
s.mux.HandleFunc("/api/v1/auth/me", s.withAuth(s.apiGetMe))
return handler(ctx, w, r, request)
}
}

View File

@@ -5,70 +5,54 @@ import (
"net/http/httptest"
"testing"
"github.com/stretchr/testify/suite"
"reichard.io/antholume/config"
"reichard.io/antholume/database"
)
func TestNewServer(t *testing.T) {
db := setupTestDB(t)
cfg := testConfig()
server := NewServer(db, cfg)
if server == nil {
t.Fatal("NewServer returned nil")
}
if server.mux == nil {
t.Fatal("Server mux is nil")
}
if server.db == nil {
t.Fatal("Server db is nil")
}
if server.cfg == nil {
t.Fatal("Server cfg is nil")
}
type ServerTestSuite struct {
suite.Suite
db *database.DBManager
cfg *config.Config
srv *Server
}
func TestServerServeHTTP(t *testing.T) {
db := setupTestDB(t)
cfg := testConfig()
func TestServer(t *testing.T) {
suite.Run(t, new(ServerTestSuite))
}
server := NewServer(db, cfg)
func (suite *ServerTestSuite) SetupTest() {
suite.cfg = &config.Config{
ListenPort: "8080",
DBType: "memory",
DBName: "test",
ConfigPath: "/tmp",
CookieAuthKey: "test-auth-key-32-bytes-long-enough",
CookieEncKey: "0123456789abcdef",
CookieSecure: false,
CookieHTTPOnly: true,
Version: "test",
DemoMode: false,
RegistrationEnabled: true,
}
suite.db = database.NewMgr(suite.cfg)
suite.srv = NewServer(suite.db, suite.cfg)
}
func (suite *ServerTestSuite) TestNewServer() {
suite.NotNil(suite.srv)
suite.NotNil(suite.srv.mux)
suite.NotNil(suite.srv.db)
suite.NotNil(suite.srv.cfg)
}
func (suite *ServerTestSuite) TestServerServeHTTP() {
req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/me", nil)
w := httptest.NewRecorder()
server.ServeHTTP(w, req)
suite.srv.ServeHTTP(w, req)
if w.Code != http.StatusUnauthorized {
t.Fatalf("Expected 401 for unauthenticated request, got %d", w.Code)
}
}
func setupTestDB(t *testing.T) *database.DBManager {
t.Helper()
cfg := testConfig()
cfg.DBType = "memory"
return database.NewMgr(cfg)
}
func testConfig() *config.Config {
return &config.Config{
ListenPort: "8080",
DBType: "memory",
DBName: "test",
ConfigPath: "/tmp",
CookieAuthKey: "test-auth-key-32-bytes-long-enough",
CookieEncKey: "0123456789abcdef", // Exactly 16 bytes
CookieSecure: false,
CookieHTTPOnly: true,
Version: "test",
DemoMode: false,
RegistrationEnabled: true,
}
suite.Equal(http.StatusUnauthorized, w.Code)
}

26
api/v1/settings.go Normal file
View File

@@ -0,0 +1,26 @@
package v1
import (
"context"
)
// GET /settings
func (s *Server) GetSettings(ctx context.Context, request GetSettingsRequestObject) (GetSettingsResponseObject, error) {
auth, ok := s.getSessionFromContext(ctx)
if !ok {
return GetSettings401JSONResponse{Code: 401, Message: "Unauthorized"}, nil
}
user, err := s.db.Queries.GetUser(ctx, auth.UserName)
if err != nil {
return GetSettings500JSONResponse{Code: 500, Message: err.Error()}, nil
}
response := SettingsResponse{
Settings: []Setting{},
User: UserData{Username: auth.UserName, IsAdmin: auth.IsAdmin},
Timezone: user.Timezone,
}
return GetSettings200JSONResponse(response), nil
}

View File

@@ -1,76 +0,0 @@
package v1
import "reichard.io/antholume/database"
// DocumentsResponse is the API response for document list endpoints
type DocumentsResponse struct {
Documents []database.GetDocumentsWithStatsRow `json:"documents"`
Total int64 `json:"total"`
Page int64 `json:"page"`
Limit int64 `json:"limit"`
NextPage *int64 `json:"next_page"`
PreviousPage *int64 `json:"previous_page"`
Search *string `json:"search"`
User UserData `json:"user"`
WordCounts []WordCount `json:"word_counts"`
}
// DocumentResponse is the API response for single document endpoints
type DocumentResponse struct {
Document database.Document `json:"document"`
User UserData `json:"user"`
Progress *Progress `json:"progress"`
}
// UserData represents authenticated user context
type UserData struct {
Username string `json:"username"`
IsAdmin bool `json:"is_admin"`
}
// WordCount represents computed word count statistics
type WordCount struct {
DocumentID string `json:"document_id"`
Count int64 `json:"count"`
}
// Progress represents reading progress for a document
type Progress struct {
UserID string `json:"user_id"`
DocumentID string `json:"document_id"`
DeviceID string `json:"device_id"`
Percentage float64 `json:"percentage"`
Progress string `json:"progress"`
CreatedAt string `json:"created_at"`
}
// ActivityResponse is the API response for activity endpoints
type ActivityResponse struct {
Activities []database.GetActivityRow `json:"activities"`
User UserData `json:"user"`
}
// SettingsResponse is the API response for settings endpoints
type SettingsResponse struct {
Settings []database.Setting `json:"settings"`
User UserData `json:"user"`
Timezone *string `json:"timezone"`
}
// LoginRequest is the request body for login
type LoginRequest struct {
Username string `json:"username"`
Password string `json:"password"`
}
// LoginResponse is the response for successful login
type LoginResponse struct {
Username string `json:"username"`
IsAdmin bool `json:"is_admin"`
}
// ErrorResponse represents an API error
type ErrorResponse struct {
Code int `json:"code"`
Message string `json:"message"`
}

View File

@@ -5,9 +5,10 @@ import (
"net/http"
"net/url"
"strconv"
"time"
)
// writeJSON writes a JSON response
// writeJSON writes a JSON response (deprecated - used by tests only)
func writeJSON(w http.ResponseWriter, status int, data any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
@@ -16,7 +17,7 @@ func writeJSON(w http.ResponseWriter, status int, data any) {
}
}
// writeJSONError writes a JSON error response
// writeJSONError writes a JSON error response (deprecated - used by tests only)
func writeJSONError(w http.ResponseWriter, status int, message string) {
writeJSON(w, status, ErrorResponse{
Code: status,
@@ -24,14 +25,14 @@ func writeJSONError(w http.ResponseWriter, status int, message string) {
})
}
// QueryParams represents parsed query parameters
// QueryParams represents parsed query parameters (deprecated - used by tests only)
type QueryParams struct {
Page int64
Limit int64
Page int64
Limit int64
Search *string
}
// parseQueryParams parses URL query parameters
// parseQueryParams parses URL query parameters (deprecated - used by tests only)
func parseQueryParams(query url.Values, defaultLimit int64) QueryParams {
page, _ := strconv.ParseInt(query.Get("page"), 10, 64)
if page == 0 {
@@ -56,4 +57,13 @@ func parseQueryParams(query url.Values, defaultLimit int64) QueryParams {
// ptrOf returns a pointer to the given value
func ptrOf[T any](v T) *T {
return &v
}
// parseTime parses a string to time.Time
func parseTime(s string) time.Time {
t, _ := time.Parse(time.RFC3339, s)
if t.IsZero() {
t, _ = time.Parse("2006-01-02T15:04:05", s)
}
return t
}

View File

@@ -5,56 +5,46 @@ import (
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/suite"
)
func TestWriteJSON(t *testing.T) {
type UtilsTestSuite struct {
suite.Suite
}
func TestUtils(t *testing.T) {
suite.Run(t, new(UtilsTestSuite))
}
func (suite *UtilsTestSuite) TestWriteJSON() {
w := httptest.NewRecorder()
data := map[string]string{"test": "value"}
writeJSON(w, http.StatusOK, data)
if w.Header().Get("Content-Type") != "application/json" {
t.Errorf("Expected Content-Type 'application/json', got '%s'", w.Header().Get("Content-Type"))
}
if w.Code != http.StatusOK {
t.Errorf("Expected status 200, got %d", w.Code)
}
suite.Equal("application/json", w.Header().Get("Content-Type"))
suite.Equal(http.StatusOK, w.Code)
var resp map[string]string
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("Failed to unmarshal response: %v", err)
}
if resp["test"] != "value" {
t.Errorf("Expected 'value', got '%s'", resp["test"])
}
suite.Require().NoError(json.Unmarshal(w.Body.Bytes(), &resp))
suite.Equal("value", resp["test"])
}
func TestWriteJSONError(t *testing.T) {
func (suite *UtilsTestSuite) TestWriteJSONError() {
w := httptest.NewRecorder()
writeJSONError(w, http.StatusBadRequest, "test error")
if w.Code != http.StatusBadRequest {
t.Errorf("Expected status 400, got %d", w.Code)
}
suite.Equal(http.StatusBadRequest, w.Code)
var resp ErrorResponse
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("Failed to unmarshal response: %v", err)
}
if resp.Code != http.StatusBadRequest {
t.Errorf("Expected code 400, got %d", resp.Code)
}
if resp.Message != "test error" {
t.Errorf("Expected message 'test error', got '%s'", resp.Message)
}
suite.Require().NoError(json.Unmarshal(w.Body.Bytes(), &resp))
suite.Equal(http.StatusBadRequest, resp.Code)
suite.Equal("test error", resp.Message)
}
func TestParseQueryParams(t *testing.T) {
func (suite *UtilsTestSuite) TestParseQueryParams() {
query := make(map[string][]string)
query["page"] = []string{"2"}
query["limit"] = []string{"15"}
@@ -62,46 +52,25 @@ func TestParseQueryParams(t *testing.T) {
params := parseQueryParams(query, 9)
if params.Page != 2 {
t.Errorf("Expected page 2, got %d", params.Page)
}
if params.Limit != 15 {
t.Errorf("Expected limit 15, got %d", params.Limit)
}
if params.Search == nil {
t.Fatal("Expected search to be set")
}
suite.Equal(int64(2), params.Page)
suite.Equal(int64(15), params.Limit)
suite.NotNil(params.Search)
}
func TestParseQueryParamsDefaults(t *testing.T) {
func (suite *UtilsTestSuite) TestParseQueryParamsDefaults() {
query := make(map[string][]string)
params := parseQueryParams(query, 9)
if params.Page != 1 {
t.Errorf("Expected page 1, got %d", params.Page)
}
if params.Limit != 9 {
t.Errorf("Expected limit 9, got %d", params.Limit)
}
if params.Search != nil {
t.Errorf("Expected search to be nil, got '%v'", params.Search)
}
suite.Equal(int64(1), params.Page)
suite.Equal(int64(9), params.Limit)
suite.Nil(params.Search)
}
func TestPtrOf(t *testing.T) {
func (suite *UtilsTestSuite) TestPtrOf() {
value := "test"
ptr := ptrOf(value)
if ptr == nil {
t.Fatal("Expected non-nil pointer")
}
if *ptr != "test" {
t.Errorf("Expected 'test', got '%s'", *ptr)
}
suite.NotNil(ptr)
suite.Equal("test", *ptr)
}