wip 2
This commit is contained in:
65
api/v1/activity.go
Normal file
65
api/v1/activity.go
Normal 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
1103
api/v1/api.gen.go
Normal file
File diff suppressed because it is too large
Load Diff
227
api/v1/auth.go
227
api/v1/auth.go
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
3
api/v1/generate.go
Normal file
@@ -0,0 +1,3 @@
|
||||
package v1
|
||||
|
||||
//go:generate oapi-codegen -config oapi-codegen.yaml openapi.yaml
|
||||
@@ -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
6
api/v1/oapi-codegen.yaml
Normal 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
526
api/v1/openapi.yaml
Normal 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
38
api/v1/progress.go
Normal 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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
26
api/v1/settings.go
Normal 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
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user