This commit is contained in:
2026-09-15 09:15:53 -05:00
parent 97c930196a
commit ab9573b875
21 changed files with 1664 additions and 0 deletions
+100
View File
@@ -0,0 +1,100 @@
package api
import (
"fmt"
"net/http"
"github.com/gin-contrib/sessions"
"github.com/gin-gonic/gin"
"golang.org/x/crypto/bcrypt"
"jp.wtf/go-fishing/internal/models"
)
type CreateAnglerRequest struct {
Username string `json:"username" binding:"required"`
Password string `json:"password" binding:"required"`
}
type Response struct {
Data any `json:"$data,omitempty"`
Meta *APIMetadata `json:"$meta,omitempty"`
Error *APIError `json:"$error,omitempty"`
}
type APIMetadata struct {
Version string `json:"version,omitempty"`
}
type APIErrorCode string
const (
APIErrorCodeUnknown APIErrorCode = "API_ERR_UNKNOWN"
APIErrorCodeUnauthorized APIErrorCode = "API_ERR_UNAUTHORIZED"
APIErrorCodeNotFound APIErrorCode = "API_ERR_NOT_FOUND"
APIErrorCodeInvalidBody APIErrorCode = "API_ERR_INVALID_BODY"
APIErrorCodeInvalidRequest APIErrorCode = "API_ERR_INVALID_REQUEST"
)
type APIError struct {
Code APIErrorCode `json:"code"`
Message string `json:"message"`
}
func SendJSONResponse(c *gin.Context, status int, response Response) {
c.JSON(status, response)
}
func OK(c *gin.Context, data any) {
SendJSONResponse(c, http.StatusOK, Response{
Data: data,
Meta: &APIMetadata{
Version: c.GetString("APIVersion"),
},
})
}
func Fail(c *gin.Context, status int, code APIErrorCode, message string) {
SendJSONResponse(c, status, Response{
Error: &APIError{
Code: code,
Message: message,
},
})
}
func FailAndAbort(c *gin.Context, status int, code APIErrorCode, message string) {
Fail(c, status, code, message)
c.Abort()
}
func CheckIfLoggedIn(c *gin.Context) bool {
s := sessions.Default(c)
val := s.Get("angler_id")
return val != nil && val != ""
}
func GetAnglerFromSession(c *gin.Context) (models.Angler, error) {
val, exists := c.Get("angler")
if !exists {
return models.Angler{}, fmt.Errorf("No angler")
}
angler, ok := val.(models.Angler)
if !ok {
return models.Angler{}, fmt.Errorf("Malformed or invalid angler")
}
return angler, nil
}
func HashPassword(password string) (string, error) {
bytes, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
return string(bytes), err
}
func CheckPassword(password, hash string) bool {
err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
return err == nil
}
+114
View File
@@ -0,0 +1,114 @@
package db
import (
"fmt"
"time"
"charm.land/ssh"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
"golang.org/x/crypto/bcrypt"
gossh "golang.org/x/crypto/ssh"
"jp.wtf/go-fishing/internal/models"
)
type Database struct {
Conn *sqlx.DB
}
func (db *Database) GetAnglerByID(angler_id uuid.UUID) (models.Angler, error) {
var angler models.Angler
err := db.Conn.Get(&angler, "SELECT angler_id, angler_username, angler_email FROM anglers WHERE angler_id = $1", angler_id)
return angler, err
}
func (db *Database) GetAnglerByKey(ssh_key ssh.PublicKey) (models.Angler, error) {
var angler models.Angler
err := db.Conn.Get(&angler, "SELECT angler_id, angler_username, angler_email FROM ssh_keys INNER JOIN anglers USING (angler_id) WHERE key_data = $1", ssh_key.Marshal())
return angler, err
}
func (db *Database) GetRawAnglerByUsername(username string) (models.AnglerDB, error) {
var angler models.AnglerDB
err := db.Conn.Get(&angler, "SELECT angler_id, angler_username, angler_email, angler_password FROM anglers WHERE angler_username = $1", username)
return angler, err
}
func (db *Database) CreateNewAngler(username string, hashed_password string) (models.Angler, error) {
var new_angler models.Angler
err := db.Conn.Get(&new_angler, `
INSERT INTO public.anglers (angler_username, angler_password)
VALUES ($1, $2)
RETURNING angler_id, angler_username, angler_email
`, username, hashed_password)
return new_angler, err
}
func (db *Database) CreateNewAnglerFromKey(ssh_key ssh.PublicKey) (models.Angler, error) {
bytes, err := bcrypt.GenerateFromPassword([]byte(uuid.New().String()), bcrypt.DefaultCost)
if err != nil {
return models.Angler{}, err
}
fingerprint := gossh.FingerprintSHA256(ssh_key)
angler, err := db.CreateNewAngler(fingerprint, string(bytes))
if err != nil {
return models.Angler{}, err
}
if _, err := db.AttachKeyToAngler(angler.ID, ssh_key); err != nil {
return models.Angler{}, err
}
return angler, err
}
func (db *Database) AttachKeyToAngler(angler_id uuid.UUID, ssh_key ssh.PublicKey) (models.SSHKey, error) {
var new_key models.SSHKey
err := db.Conn.Get(&new_key, `
INSERT INTO public.ssh_keys (key_type, key_data, key_fingerprint, angler_id)
VALUES ($1, $2, $3, $4)
RETURNING key_id, key_type, key_data, key_fingerprint
`, ssh_key.Type(), ssh_key.Marshal(), gossh.FingerprintSHA256(ssh_key), angler_id)
return new_key, err
}
func (db *Database) IsCasting(angler_id uuid.UUID) (bool, error) {
var exists bool
err := db.Conn.Get(&exists, "SELECT EXISTS( SELECT 1 FROM public.casts WHERE angler_id = $1 AND cast_ended_at IS NULL )", angler_id)
if err != nil {
fmt.Print(err.Error())
return false, err
}
return exists, err
}
func (db *Database) GetMostRecentCast(angler_id uuid.UUID) (models.Cast, error) {
var cast models.Cast
err := db.Conn.Get(&cast, "SELECT cast_id, cast_started_at, cast_ended_at, catch_id FROM public.casts WHERE angler_id =! ORDER cast_started_at DESC LIMIT 1", angler_id)
return cast, err
}
func (db *Database) CreateNewCast(angler_id uuid.UUID) (models.Cast, error) {
var cast models.Cast
err := db.Conn.Get(&cast, "INSERT INTO public.casts (angler_id) VALUES ($1) RETURNING *", angler_id)
return cast, err
}
func (db *Database) EndCastByID(cast_id uuid.UUID) (models.Cast, error) {
var cast models.Cast
err := db.Conn.Get(&cast, "UPDATE public.casts SET cast_ended_at = $1 WHERE cast_id = $2 RETURNING *", time.Now(), cast_id)
return cast, err
}
+16
View File
@@ -0,0 +1,16 @@
package handlers
import (
"github.com/gin-gonic/gin"
"jp.wtf/go-fishing/internal/db"
v1 "jp.wtf/go-fishing/internal/handlers/v1"
)
type Env struct {
DB *db.Database
}
func (env *Env) RegisterRoutes(rg *gin.RouterGroup) {
v1Router := v1.V1Router{DB: env.DB}
v1Router.RegisterRoutes(rg)
}
+122
View File
@@ -0,0 +1,122 @@
package v1
import (
"database/sql"
"errors"
"net/http"
"github.com/gin-contrib/sessions"
"github.com/gin-gonic/gin"
"jp.wtf/go-fishing/internal/api"
"jp.wtf/go-fishing/internal/models"
)
func (h *V1Router) Login(c *gin.Context) {
if api.CheckIfLoggedIn(c) {
api.Fail(c, http.StatusBadRequest, api.APIErrorCodeInvalidRequest, "You are already logged in!")
return
}
var json struct {
Username string `json:"username"`
Password string `json:"password"`
}
if err := c.ShouldBind(&json); err != nil {
api.Fail(c, http.StatusBadRequest, api.APIErrorCodeInvalidBody, err.Error())
return
}
angler, err := h.DB.GetRawAnglerByUsername(json.Username)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
api.Fail(c, http.StatusUnauthorized, api.APIErrorCodeUnauthorized, "Invalid username or password")
return
}
api.Fail(c, http.StatusInternalServerError, api.APIErrorCodeUnknown, err.Error())
return
}
if valid := api.CheckPassword(json.Password, angler.PasswordHash); valid {
s := sessions.Default(c)
s.Set("angler_id", angler.ID)
err := s.Save()
if err != nil {
api.Fail(c, http.StatusInternalServerError, api.APIErrorCodeUnknown, "Failed to create new session")
return
}
api.OK(c, models.Angler{
ID: angler.ID,
Username: angler.Username,
})
return
}
api.Fail(c, http.StatusUnauthorized, api.APIErrorCodeUnauthorized, "Invalid username or password")
}
func (h *V1Router) Signup(c *gin.Context) {
if api.CheckIfLoggedIn(c) {
api.Fail(c, http.StatusBadRequest, api.APIErrorCodeInvalidRequest, "You are already logged in!")
return
}
var json api.CreateAnglerRequest
if err := c.ShouldBind(&json); err != nil {
api.Fail(c, http.StatusBadRequest, api.APIErrorCodeInvalidBody, err.Error())
return
}
hashed_password, hash_err := api.HashPassword(json.Password)
if hash_err != nil {
api.Fail(c, http.StatusInternalServerError, api.APIErrorCodeUnknown, hash_err.Error())
return
}
new_angler, create_err := h.DB.CreateNewAngler(json.Username, hashed_password)
if create_err != nil {
api.Fail(c, http.StatusInternalServerError, api.APIErrorCodeUnknown, create_err.Error())
return
}
s := sessions.Default(c)
s.Set("angler_id", new_angler.ID)
if err := s.Save(); err != nil {
api.Fail(c, http.StatusInternalServerError, api.APIErrorCodeUnknown, "Failed to create new session")
return
}
api.OK(c, models.Angler{
ID: new_angler.ID,
Username: new_angler.Username,
})
}
func (h *V1Router) Logout(c *gin.Context) {
if !api.CheckIfLoggedIn(c) {
api.Fail(c, http.StatusBadRequest, api.APIErrorCodeInvalidRequest, "You are not logged in...thus you cannot logout!")
return
}
s := sessions.Default(c)
s.Clear()
s.Save()
api.OK(c, nil)
}
func (h *V1Router) GetMe(c *gin.Context) {
angler, err := api.GetAnglerFromSession(c)
if err != nil {
api.Fail(c, http.StatusInternalServerError, api.APIErrorCodeUnknown, err.Error())
return
}
api.OK(c, angler)
}
+108
View File
@@ -0,0 +1,108 @@
package v1
import (
"math/rand/v2"
"net/http"
"time"
"github.com/gin-gonic/gin"
"jp.wtf/go-fishing/internal/api"
)
var sayings = []string{
// Near Misses
"Close but no cigar",
"Almost only counts in horseshoes and hand grenades",
"A day late and a dollar short",
"A miss is as good as a mile",
"Always a bridesmaid, never a bride",
"Snatched defeat from the jaws of victory",
"Fell at the last hurdle",
"Missed the boat",
"Missed it by a whisker",
"So near, and yet so far",
"A swing and a miss",
// Futile Efforts
"Beating a dead horse",
"Tilting at windmills",
"Squeezing blood from a stone",
"Nailing jelly to a wall",
"Herding cats",
"Rearranging deck chairs on the Titanic",
"Pushing a rope",
"Spitting in the wind",
"Putting toothpaste back in the tube",
"Shoveling snow in a blizzard",
"Spinning your wheels",
"Emptying the ocean with a teaspoon",
"Barking up the wrong tree",
"A wild goose chase",
"Catching the wind in a net",
}
func (h *V1Router) Cast(c *gin.Context) {
angler, err := api.GetAnglerFromSession(c)
if err != nil {
api.Fail(c, http.StatusInternalServerError, api.APIErrorCodeUnknown, err.Error())
return
}
is_casting, check_err := h.DB.IsCasting(angler.ID)
if check_err != nil {
api.Fail(c, http.StatusInternalServerError, api.APIErrorCodeUnknown, "Unable to check for existing cast")
return
}
if is_casting {
api.Fail(c, http.StatusBadRequest, api.APIErrorCodeInvalidRequest, "You are already casting! >:|")
return
}
cast, cast_create_err := h.DB.CreateNewCast(angler.ID)
if cast_create_err != nil {
api.Fail(c, http.StatusInternalServerError, api.APIErrorCodeUnknown, "Unable to start your cast")
return
}
// Fishing can take anywhere from 2-5 seconds
minTime, maxTime := 2, 5
timeToWait := rand.IntN(maxTime-minTime+1) + minTime
select {
case <-time.After(time.Duration(timeToWait) * time.Second):
// One we complete fishing we must decide if we caught anything
chance := 75
gen := rand.IntN(100)
caught := gen < chance
_, end_cast_err := h.DB.EndCastByID(cast.ID)
if end_cast_err != nil {
api.Fail(c, http.StatusInternalServerError, api.APIErrorCodeUnknown, "Unable to update your cast — may have left you in a bad state :(")
return
}
if caught {
// If so then we must select a fish.
// Then we must determine a size between the min and max of said fish
// Finally record the catch and return it
api.OK(c, "Got one! Nice!")
} else {
// Otherwise return. You are left empty handed
api.Fail(c, http.StatusNotFound, api.APIErrorCodeNotFound, sayings[rand.IntN(len(sayings))])
}
case <-c.Done():
_, end_cast_err := h.DB.EndCastByID(cast.ID)
if end_cast_err != nil {
api.Fail(c, http.StatusInternalServerError, api.APIErrorCodeUnknown, "Unable to update your cast — may have left you in a bad state :(")
return
}
api.Fail(c, http.StatusRequestTimeout, api.APIErrorCodeUnknown, "Cast terminiated for unknown reason")
}
}
+59
View File
@@ -0,0 +1,59 @@
package v1
import (
"log"
"net/http"
"github.com/gin-contrib/sessions"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"jp.wtf/go-fishing/internal/api"
"jp.wtf/go-fishing/internal/db"
)
type V1Router struct {
DB *db.Database
}
func (h *V1Router) AnglerAuthMiddleware(c *gin.Context) {
s := sessions.Default(c)
val := s.Get("angler_id")
if val == nil || val == "" {
api.FailAndAbort(c, http.StatusUnauthorized, api.APIErrorCodeUnauthorized, "You are not logged in!")
return
}
valStr, ok := val.(string)
angler_id, uuid_err := uuid.Parse(valStr)
if uuid_err != nil || !ok {
s.Clear()
s.Save()
api.FailAndAbort(c, http.StatusInternalServerError, api.APIErrorCodeUnknown, "Session data malformed")
return
}
angler, err := h.DB.GetAnglerByID(angler_id)
if err != nil {
s.Clear()
s.Save()
log.Print("Failed to find angler: ", err.Error())
api.FailAndAbort(c, http.StatusUnauthorized, api.APIErrorCodeUnauthorized, "Bad or expired session")
return
}
c.Set("angler", angler)
s.Save()
}
func (h *V1Router) RegisterRoutes(rg *gin.RouterGroup) {
auth := rg.Group("/auth")
auth.POST("/login", h.Login)
auth.POST("/signup", h.Signup)
auth.POST("/logout", h.Logout)
auth.GET("/me", h.GetMe)
rg.GET("/cast", h.AnglerAuthMiddleware, h.Cast)
}
@@ -0,0 +1,14 @@
-- +goose Up
-- +goose StatementBegin
CREATE TABLE public.anglers (
angler_id UUID NOT NULL DEFAULT gen_random_uuid(),
angler_username TEXT NOT NULL UNIQUE,
angler_password TEXT NOT NULL,
angler_email TEXT
);
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
DROP TABLE public.anglers;
-- +goose StatementEnd
@@ -0,0 +1,37 @@
-- +goose Up
-- +goose StatementBegin
ALTER TABLE public.anglers ADD PRIMARY KEY (angler_id);
CREATE TABLE public.fish (
fish_id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY,
fish_name TEXT NOT NULL,
fish_desc TEXT NOT NULL,
fish_size_oz_max NUMERIC NOT NULL,
fish_size_oz_min NUMERIC NOT NULL
);
CREATE TABLE public.catches (
catch_id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY,
catch_caught_at TIMESTAMPTZ NOT NULL,
catch_size_oz NUMERIC NOT NULL,
angler_id UUID NOT NULL REFERENCES public.anglers(angler_id),
fish_id UUID NOT NULL REFERENCES public.fish(fish_id)
);
CREATE TABLE public.casts (
cast_id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY,
cast_started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
cast_ended_at TIMESTAMPTZ,
angler_id UUID NOT NULL REFERENCES public.anglers(angler_id),
catch_id UUID REFERENCES public.catches(catch_id)
);
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
ALTER TABLE public.anglers DROP CONSTRAINT anglers_pkey
DROP TABLE public.fish;
DROP TABLE public.catches;
DROP TABLE public.casts;
-- +goose StatementEnd
@@ -0,0 +1,15 @@
-- +goose Up
-- +goose StatementBegin
CREATE TABLE public.ssh_keys (
key_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
angler_id UUID NOT NULL REFERENCES public.anglers(angler_id),
key_type TEXT NOT NULL,
key_data BYTEA NOT NULL UNIQUE,
key_fingerprint TEXT
);
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
DROP TABLE public.ssh_keys;
-- +goose StatementEnd
+6
View File
@@ -0,0 +1,6 @@
package migrations
import "embed"
//go:embed *.sql
var Files embed.FS
+52
View File
@@ -0,0 +1,52 @@
package models
import (
"database/sql"
"time"
"github.com/google/uuid"
_ "github.com/jackc/pgx/v5/stdlib"
)
type Angler struct {
ID uuid.UUID `db:"angler_id" json:"id"`
Username string `db:"angler_username" json:"name"`
Email *string `db:"angler_email" json:"email"`
}
type SSHKey struct {
ID uuid.UUID `db:"key_id" json:"id"`
SSH_Type string `db:"key_type" json:"ssh_type"`
Date []byte `db:"key_data" json:"data"`
Fingerprint string `db:"key_fingerprint" json:"fingerprint"`
}
type AnglerDB struct {
Angler
PasswordHash string `db:"angler_password"`
}
type Fish struct {
ID uuid.UUID `db:"fish_id" json:"id"`
Name string `db:"fish_name" json:"name"`
Description string `db:"fish_desc" json:"desc"`
SizeOZMax uint16 `db:"fish_size_oz_max" json:"size_oz_max"`
SizeOZMin uint16 `db:"fish_size_oz_maxin" json:"size_oz_min"`
}
type Catch struct {
ID uuid.UUID `db:"catch_id" json:"id"`
At time.Time `db:"catch_caught_at" json:"caught_at"`
AnglerID uuid.UUID `db:"angler_id" json:"angler_id"`
FishID uuid.UUID `db:"fish_id" json:"fish_id"`
SizeOZ uint16 `db:"catch_size_oz" json:"size_oz"`
}
type Cast struct {
ID uuid.UUID `db:"cast_id" json:"id"`
Start time.Time `db:"cast_started_at" json:"start"`
End sql.NullTime `db:"cast_ended_at" json:"end"`
AnglerID uuid.UUID `db:"angler_id" json:"angler_id"`
CatchID uuid.NullUUID `db:"catch_id" json:"catch_id"`
}