internal/client (new): a typed Go HTTP client for the API - login, register, logout, me, and full CRUD for games/platforms - shared by the TUI and the Fyne GUI so request-building, auth and error handling (an APIError with the HTTP status) live in one tested place instead of being duplicated. 8 tests against an httptest server covering success, 401, 403 and that the token is stored/cleared at the right times. cmd/tui (rewrite): drops the old unauthenticated read-only viewer. Now a login screen (bubbles/textinput) -> a scrollable games table (bubbles/table) with 'r' to refresh, built as an explicit state machine (screenLogin/screenLoading/screenGames) so Update's transitions are testable without a real terminal. 9 tests driving Update() directly. cmd/gui (rewrite): drops raylib for Fyne. Same shape as the TUI - a login form, then a games table, with networking on a goroutine and fyne.Do for the UI updates. The table-cell and login-validation logic are plain functions with no Fyne/display dependency, so they're tested directly (7 tests) rather than needing Fyne's headless test driver. internal/api: tagged every operation (Auth/Platforms/Games/Misc) so the generated OpenAPI spec groups into named services instead of one bucket; api/openapi.json regenerated. Added CORS middleware (wildcard origin, no credentials needed since clients use a bearer token, not the cookie) - needed for the new web client's browser requests to the API's a different origin. web/ (new): an Angular 22 + Material app, API client generated from api/openapi.json via ng-openapi-gen (services: true for per-resource classes - AuthService/GamesService/PlatformsService - instead of one generic Api facade). A Session service holds the token/user as signals, persists the token to localStorage, and is the only thing that calls the generated AuthService; an HttpInterceptorFn attaches the bearer token to every request; a CanActivateFn guard protects /games. Login and Games are standalone, lazy-loaded route components. 21 tests (Vitest, Angular's new default - no browser/Karma needed) covering the service, interceptor, guard and both components, run via `npm test` exactly as CI will call it. Justfile: fixed run-tui/run-gui, which used `go run .../main.go` - broken now that both packages span multiple files, since that only compiles the one named file. Added web-install/web-dev/web-build/web-test. .gitea/workflows/ci.yml (moved from .github, which Gitea doesn't read): on push to main/develop - gofmt, go vet, go build, go test, go mod tidy diff (with the Fyne build's system deps installed), sqlc diff, and the web app's build+test. .gitea/workflows/release.yml (new): on a vX.Y.Z tag, cross-compiles api+tui for linux/darwin/windows (amd64+arm64, pure Go, no cgo), builds the Fyne GUI for linux/amd64 only (cgo + its OpenGL/X11 link means it can't cross-compile from one Linux runner the way api/tui do, and this repo has no registered macOS/Windows runner to build it on natively - documented in the workflow's comments), builds the web app, and creates a Gitea release with all of it attached via the Gitea API. Written to Gitea's documented GitHub-Actions-compatible syntax but not executable from here - there's no Gitea server in this environment to run it against, unlike everything else in this commit, which was. Verified: every Go package builds/vets/tests clean; sqlc diff and go mod tidy -diff are clean; the Angular app builds for production and its test suite passes via `npm test`/`npm run build` (the exact commands CI runs), including after a clean `npm ci` from the committed lockfile; the new CORS config was checked with real preflight and GET requests against a running server, not just read. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
165 lines
4.7 KiB
Go
165 lines
4.7 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
"time"
|
|
|
|
"github.com/danielgtaylor/huma/v2"
|
|
"github.com/google/uuid"
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
|
|
"completed/internal/auth"
|
|
"completed/internal/db"
|
|
"completed/internal/model"
|
|
)
|
|
|
|
type authOutput struct {
|
|
SetCookie string `header:"Set-Cookie"`
|
|
Body model.AuthResponse
|
|
}
|
|
|
|
// sessionCookie builds the Set-Cookie value for a successful login. Secure
|
|
// is left unset because the app is also meant to run over plain HTTP in
|
|
// local/docker-compose use; put a TLS-terminating proxy in front for
|
|
// anything reachable over the network and set Secure there instead.
|
|
func sessionCookie(token string) string {
|
|
c := http.Cookie{
|
|
Name: sessionCookieName,
|
|
Value: token,
|
|
Path: "/",
|
|
HttpOnly: true,
|
|
SameSite: http.SameSiteLaxMode,
|
|
MaxAge: auth.SessionDuration,
|
|
}
|
|
return c.String()
|
|
}
|
|
|
|
func clearedSessionCookie() string {
|
|
c := http.Cookie{
|
|
Name: sessionCookieName,
|
|
Value: "",
|
|
Path: "/",
|
|
HttpOnly: true,
|
|
SameSite: http.SameSiteLaxMode,
|
|
MaxAge: -1,
|
|
}
|
|
return c.String()
|
|
}
|
|
|
|
// newSession creates and stores a session for userID, returning the token
|
|
// to hand back to the client (only its hash is ever persisted).
|
|
func newSession(ctx context.Context, queries db.Querier, userID uuid.UUID) (string, error) {
|
|
token, tokenHash, err := auth.NewSessionToken()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
err = queries.CreateSession(ctx, db.CreateSessionParams{
|
|
TokenHash: tokenHash,
|
|
UserID: userID,
|
|
ExpiresAt: pgtype.Timestamptz{Time: time.Now().Add(auth.SessionDuration * time.Second), Valid: true},
|
|
})
|
|
return token, err
|
|
}
|
|
|
|
func registerAuthRoutes(api huma.API, queries db.Querier) {
|
|
huma.Register(api, huma.Operation{
|
|
OperationID: "register",
|
|
Tags: []string{"Auth"},
|
|
Method: http.MethodPost,
|
|
Path: "/api/auth/register",
|
|
Summary: "Register a new account",
|
|
}, func(ctx context.Context, input *struct{ Body model.RegisterInput }) (*authOutput, error) {
|
|
hash, err := auth.HashPassword(input.Body.Password)
|
|
if err != nil {
|
|
return nil, huma.Error500InternalServerError("could not process password")
|
|
}
|
|
|
|
u, err := queries.CreateUser(ctx, db.CreateUserParams{
|
|
Email: input.Body.Email,
|
|
PasswordHash: hash,
|
|
Name: input.Body.Name,
|
|
Bio: pgtype.Text{},
|
|
})
|
|
if err != nil {
|
|
return nil, dbError(err)
|
|
}
|
|
|
|
token, err := newSession(ctx, queries, u.ID)
|
|
if err != nil {
|
|
return nil, huma.Error500InternalServerError("could not create session")
|
|
}
|
|
|
|
return &authOutput{
|
|
SetCookie: sessionCookie(token),
|
|
Body: model.AuthResponse{
|
|
Token: token,
|
|
User: model.User{ID: u.ID.String(), Email: u.Email, Name: u.Name, IsAdmin: u.IsAdmin},
|
|
},
|
|
}, nil
|
|
})
|
|
|
|
huma.Register(api, huma.Operation{
|
|
OperationID: "login",
|
|
Tags: []string{"Auth"},
|
|
Method: http.MethodPost,
|
|
Path: "/api/auth/login",
|
|
Summary: "Log in to an existing account",
|
|
}, func(ctx context.Context, input *struct{ Body model.LoginInput }) (*authOutput, error) {
|
|
u, err := queries.GetUserByEmail(ctx, input.Body.Email)
|
|
if err != nil {
|
|
// Same message whether the email is unknown or the password is
|
|
// wrong, so a client can't use this endpoint to enumerate
|
|
// registered emails.
|
|
return nil, huma.Error401Unauthorized("invalid email or password")
|
|
}
|
|
|
|
ok, err := auth.VerifyPassword(input.Body.Password, u.PasswordHash)
|
|
if err != nil || !ok {
|
|
return nil, huma.Error401Unauthorized("invalid email or password")
|
|
}
|
|
|
|
token, err := newSession(ctx, queries, u.ID)
|
|
if err != nil {
|
|
return nil, huma.Error500InternalServerError("could not create session")
|
|
}
|
|
|
|
return &authOutput{
|
|
SetCookie: sessionCookie(token),
|
|
Body: model.AuthResponse{
|
|
Token: token,
|
|
User: model.User{ID: u.ID.String(), Email: u.Email, Name: u.Name, IsAdmin: u.IsAdmin},
|
|
},
|
|
}, nil
|
|
})
|
|
|
|
logoutOp := huma.Operation{
|
|
OperationID: "logout",
|
|
Tags: []string{"Auth"},
|
|
Method: http.MethodPost,
|
|
Path: "/api/auth/logout",
|
|
Summary: "Log out of the current session",
|
|
}
|
|
addAuth(&logoutOp, api, queries)
|
|
huma.Register(api, logoutOp, func(ctx context.Context, input *struct{}) (*struct {
|
|
SetCookie string `header:"Set-Cookie"`
|
|
}, error) {
|
|
_ = queries.DeleteSession(ctx, auth.HashSessionToken(tokenFromContext(ctx)))
|
|
return &struct {
|
|
SetCookie string `header:"Set-Cookie"`
|
|
}{SetCookie: clearedSessionCookie()}, nil
|
|
})
|
|
|
|
meOp := huma.Operation{
|
|
OperationID: "get-me",
|
|
Tags: []string{"Auth"},
|
|
Method: http.MethodGet,
|
|
Path: "/api/auth/me",
|
|
Summary: "Get the current user",
|
|
}
|
|
addAuth(&meOp, api, queries)
|
|
huma.Register(api, meOp, func(ctx context.Context, input *struct{}) (*struct{ Body model.User }, error) {
|
|
return &struct{ Body model.User }{Body: userFromContext(ctx).toModel()}, nil
|
|
})
|
|
}
|