Update
CI / test (push) Successful in 4s

This commit is contained in:
2026-03-02 21:21:21 +00:00
parent 184c5cb59f
commit 6c26135cad
15 changed files with 206 additions and 13 deletions
Vendored
+1
View File
@@ -1,2 +1,3 @@
libs/geo-api-client/node_modules/ libs/geo-api-client/node_modules/
api api
var/logs/
+3 -1
View File
@@ -51,9 +51,11 @@ Or use `./bin/up.sh` which runs the key generation if needed.
This starts: This starts:
- `db` (`postgis/postgis`) on `5432` - `db` (`postgis/postgis`) on `5432` inside the container, exposed as **`7721`** on the host for remote access
- `api` on `8122` — uses PostgreSQL via `DATABASE_URL` (migrations run on startup) - `api` on `8122` — uses PostgreSQL via `DATABASE_URL` (migrations run on startup)
**Remote DB access** (e.g. `postgres://momswap:momswap@HOST_IP:7721/momswap?sslmode=disable`): The init script `etc/pg-init-remote.sh` configures `pg_hba.conf` for remote connections on fresh installs. If the DB was initialized before that was added, run once: `./bin/fix-pg-remote.sh`
Stop the service: Stop the service:
```bash ```bash
+20
View File
@@ -0,0 +1,20 @@
#!/bin/bash
# Add pg_hba entries for remote connections to an already-initialized DB.
# Run this once if you started the DB before adding etc/pg-init-remote.sh.
set -e
CONTAINER="${1:-momswap-backend-db}"
if ! docker ps --format '{{.Names}}' | grep -q "^${CONTAINER}$"; then
echo "Container ${CONTAINER} is not running. Start it with: docker compose up -d db"
exit 1
fi
docker exec "$CONTAINER" bash -c '
PGDATA=/var/lib/postgresql/data
if grep -q "0.0.0.0/0" "$PGDATA/pg_hba.conf" 2>/dev/null; then
echo "Remote access already configured."
exit 0
fi
echo "host all all 0.0.0.0/0 scram-sha-256" >> "$PGDATA/pg_hba.conf"
echo "host all all ::/0 scram-sha-256" >> "$PGDATA/pg_hba.conf"
psql -U "${POSTGRES_USER:-momswap}" -d "${POSTGRES_DB:-momswap}" -c "SELECT pg_reload_conf();"
echo "Remote access configured. Reloaded PostgreSQL."
'
+9 -1
View File
@@ -49,8 +49,16 @@ func main() {
service.BootstrapAdmin(adminPublicKey) service.BootstrapAdmin(adminPublicKey)
api := httpapi.NewAPI(service) api := httpapi.NewAPI(service)
h := api.Routes()
if logDir := getEnv("LOG_DIR", "var/logs"); logDir != "" {
if wrapped, err := httpapi.WithRequestLogging(logDir, h); err != nil {
log.Printf("request logging disabled: %v", err)
} else {
h = wrapped
}
}
log.Printf("listening on %s", addr) log.Printf("listening on %s", addr)
if err := http.ListenAndServe(addr, api.Routes()); err != nil { if err := http.ListenAndServe(addr, h); err != nil {
log.Fatalf("listen: %v", err) log.Fatalf("listen: %v", err)
} }
} }
+5
View File
@@ -2,12 +2,15 @@ services:
db: db:
image: postgis/postgis:17-3.5 image: postgis/postgis:17-3.5
container_name: momswap-backend-db container_name: momswap-backend-db
ports:
- "7721:5432"
environment: environment:
POSTGRES_DB: "${POSTGRES_DB:-momswap}" POSTGRES_DB: "${POSTGRES_DB:-momswap}"
POSTGRES_USER: "${POSTGRES_USER:-momswap}" POSTGRES_USER: "${POSTGRES_USER:-momswap}"
POSTGRES_PASSWORD: "${POSTGRES_PASSWORD:-momswap}" POSTGRES_PASSWORD: "${POSTGRES_PASSWORD:-momswap}"
volumes: volumes:
- ./var/posrgres:/var/lib/postgresql/data - ./var/posrgres:/var/lib/postgresql/data
- ./etc/pg-init-remote.sh:/docker-entrypoint-initdb.d/99-remote.sh:ro
healthcheck: healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-momswap} -d ${POSTGRES_DB:-momswap}"] test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-momswap} -d ${POSTGRES_DB:-momswap}"]
interval: 10s interval: 10s
@@ -31,6 +34,7 @@ services:
DATABASE_URL: "postgres://${POSTGRES_USER:-momswap}:${POSTGRES_PASSWORD:-momswap}@db:5432/${POSTGRES_DB:-momswap}?sslmode=disable" DATABASE_URL: "postgres://${POSTGRES_USER:-momswap}:${POSTGRES_PASSWORD:-momswap}@db:5432/${POSTGRES_DB:-momswap}?sslmode=disable"
volumes: volumes:
- ./etc:/app/etc:ro - ./etc:/app/etc:ro
- ./var/logs:/app/var/logs
depends_on: depends_on:
db: db:
condition: service_healthy condition: service_healthy
@@ -54,6 +58,7 @@ services:
DATABASE_URL: "postgres://${POSTGRES_USER:-momswap}:${POSTGRES_PASSWORD:-momswap}@db:5432/${POSTGRES_DB:-momswap}?sslmode=disable" DATABASE_URL: "postgres://${POSTGRES_USER:-momswap}:${POSTGRES_PASSWORD:-momswap}@db:5432/${POSTGRES_DB:-momswap}?sslmode=disable"
volumes: volumes:
- ./etc:/src/etc:ro - ./etc:/src/etc:ro
- ./var/logs:/src/var/logs
depends_on: depends_on:
db: db:
condition: service_healthy condition: service_healthy
+5
View File
@@ -0,0 +1,5 @@
#!/bin/bash
# Allow remote connections to PostgreSQL (for clients connecting via host IP:7721)
set -e
echo "host all all 0.0.0.0/0 scram-sha-256" >> "$PGDATA/pg_hba.conf"
echo "host all all ::/0 scram-sha-256" >> "$PGDATA/pg_hba.conf"
+8 -2
View File
@@ -81,7 +81,7 @@ func (s *Service) RegisterBySignature(publicKey, signature string) error {
return nil return nil
} }
func (s *Service) CreateChallenge(publicKey string) (string, error) { func (s *Service) CreateChallenge(publicKey, clientIP string) (string, error) {
if publicKey == "" { if publicKey == "" {
return "", fmt.Errorf("%w: missing public key", ErrBadRequest) return "", fmt.Errorf("%w: missing public key", ErrBadRequest)
} }
@@ -93,6 +93,7 @@ func (s *Service) CreateChallenge(publicKey string) (string, error) {
err = s.store.CreateChallenge(store.Challenge{ err = s.store.CreateChallenge(store.Challenge{
Nonce: nonce, Nonce: nonce,
PublicKey: publicKey, PublicKey: publicKey,
IP: clientIP,
ExpiresAt: time.Now().UTC().Add(s.config.ChallengeTTL), ExpiresAt: time.Now().UTC().Add(s.config.ChallengeTTL),
Used: false, Used: false,
}) })
@@ -102,7 +103,7 @@ func (s *Service) CreateChallenge(publicKey string) (string, error) {
return nonce, nil return nonce, nil
} }
func (s *Service) Login(publicKey, nonce, signature string) (string, error) { func (s *Service) Login(publicKey, nonce, signature, clientIP string) (string, error) {
ch, err := s.store.GetChallenge(nonce) ch, err := s.store.GetChallenge(nonce)
if err != nil { if err != nil {
return "", fmt.Errorf("%w: challenge not found", ErrUnauthorized) return "", fmt.Errorf("%w: challenge not found", ErrUnauthorized)
@@ -132,6 +133,11 @@ func (s *Service) Login(publicKey, nonce, signature string) (string, error) {
PublicKey: publicKey, PublicKey: publicKey,
ExpiresAt: time.Now().UTC().Add(s.config.SessionTTL), ExpiresAt: time.Now().UTC().Add(s.config.SessionTTL),
}) })
s.store.SaveUserLogin(store.UserLogin{
PublicKey: publicKey,
IP: clientIP,
CreatedAt: time.Now().UTC(),
})
return token, nil return token, nil
} }
+2 -2
View File
@@ -145,7 +145,7 @@ func (a *API) createChallenge(w http.ResponseWriter, r *http.Request) {
writeErr(w, app.ErrBadRequest) writeErr(w, app.ErrBadRequest)
return return
} }
nonce, err := a.service.CreateChallenge(req.PublicKey) nonce, err := a.service.CreateChallenge(req.PublicKey, clientIP(r))
if err != nil { if err != nil {
writeErr(w, err) writeErr(w, err)
return return
@@ -163,7 +163,7 @@ func (a *API) login(w http.ResponseWriter, r *http.Request) {
writeErr(w, app.ErrBadRequest) writeErr(w, app.ErrBadRequest)
return return
} }
token, err := a.service.Login(req.PublicKey, req.Nonce, req.Signature) token, err := a.service.Login(req.PublicKey, req.Nonce, req.Signature, clientIP(r))
if err != nil { if err != nil {
writeErr(w, err) writeErr(w, err)
return return
+95
View File
@@ -0,0 +1,95 @@
package httpapi
import (
"fmt"
"net/http"
"os"
"path/filepath"
"strings"
"sync"
"time"
)
// WithRequestLogging wraps h with a handler that logs each request to dir/access.log.
// Uses dir "var/logs" by default. Returns h unchanged if dir is empty.
func WithRequestLogging(dir string, h http.Handler) (http.Handler, error) {
if dir == "" {
return h, nil
}
if err := os.MkdirAll(dir, 0755); err != nil {
return nil, err
}
f, err := os.OpenFile(filepath.Join(dir, "access.log"), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
if err != nil {
return nil, err
}
return &requestLogger{handler: h, file: f}, nil
}
type requestLogger struct {
handler http.Handler
file *os.File
mu sync.Mutex
}
func (l *requestLogger) ServeHTTP(w http.ResponseWriter, r *http.Request) {
start := time.Now()
ip := clientIP(r)
method := r.Method
path := r.URL.Path
userAgent := r.Header.Get("User-Agent")
if userAgent == "" {
userAgent = "-"
}
wrapped := &responseRecorder{ResponseWriter: w, status: http.StatusOK}
l.handler.ServeHTTP(wrapped, r)
elapsed := time.Since(start).Milliseconds()
line := formatLogLine(ip, method, path, wrapped.status, elapsed, strings.ReplaceAll(userAgent, "\"", "'"))
l.mu.Lock()
_, _ = l.file.WriteString(line)
l.mu.Unlock()
}
type responseRecorder struct {
http.ResponseWriter
status int
}
func (r *responseRecorder) WriteHeader(code int) {
r.status = code
r.ResponseWriter.WriteHeader(code)
}
func formatLogLine(ip, method, path string, status int, elapsedMs int64, userAgent string) string {
// Common log format: ip - - [timestamp] "method path protocol" status size "referer" "user-agent" elapsed_ms
t := time.Now().UTC().Format("02/Jan/2006:15:04:05 -0700")
return fmt.Sprintf("%s - - [%s] \"%s %s\" %d %d \"-\" \"%s\" %dms\n",
ip, t, method, path, status, 0, userAgent, elapsedMs)
}
func clientIP(r *http.Request) string {
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
// First IP in the list is the original client
for i := 0; i < len(xff); i++ {
if xff[i] == ',' {
return xff[:i]
}
}
return xff
}
if xri := r.Header.Get("X-Real-IP"); xri != "" {
return xri
}
host, _ := splitHostPort(r.RemoteAddr)
return host
}
func splitHostPort(addr string) (host, port string) {
if idx := strings.LastIndex(addr, ":"); idx >= 0 {
return addr[:idx], addr[idx+1:]
}
return addr, ""
}
+1
View File
@@ -8,6 +8,7 @@ type Store interface {
CreateChallenge(ch Challenge) error CreateChallenge(ch Challenge) error
GetChallenge(nonce string) (Challenge, error) GetChallenge(nonce string) (Challenge, error)
MarkChallengeUsed(nonce string) error MarkChallengeUsed(nonce string) error
SaveUserLogin(ul UserLogin)
SaveSession(session Session) SaveSession(session Session)
GetSession(token string) (Session, error) GetSession(token string) (Session, error)
SaveInvitation(inv Invitation) error SaveInvitation(inv Invitation) error
+4
View File
@@ -230,3 +230,7 @@ func (s *MemoryStore) PruneExpired(now time.Time) {
} }
} }
} }
func (s *MemoryStore) SaveUserLogin(ul UserLogin) {
// In-memory store: no-op for login history (persistence only in Postgres)
}
+11 -1
View File
@@ -3,7 +3,9 @@ package store
import ( import (
"database/sql" "database/sql"
"embed" "embed"
"io/fs"
"log" "log"
"sort"
_ "github.com/jackc/pgx/v5/stdlib" _ "github.com/jackc/pgx/v5/stdlib"
) )
@@ -17,13 +19,21 @@ func Migrate(databaseURL string) error {
return err return err
} }
defer db.Close() defer db.Close()
sql, err := migrationsFS.ReadFile("migrations/0001_init.sql")
entries, err := fs.Glob(migrationsFS, "migrations/*.sql")
if err != nil {
return err
}
sort.Strings(entries)
for _, name := range entries {
sql, err := migrationsFS.ReadFile(name)
if err != nil { if err != nil {
return err return err
} }
if _, err := db.Exec(string(sql)); err != nil { if _, err := db.Exec(string(sql)); err != nil {
return err return err
} }
}
log.Printf("migrations applied") log.Printf("migrations applied")
return nil return nil
} }
@@ -0,0 +1,21 @@
-- Add ip column to challenges (idempotent)
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'public' AND table_name = 'challenges' AND column_name = 'ip'
) THEN
ALTER TABLE challenges ADD COLUMN ip TEXT;
END IF;
END $$;
-- User login history (ip, created_at per user)
CREATE TABLE IF NOT EXISTS user_logins (
id SERIAL PRIMARY KEY,
public_key TEXT NOT NULL REFERENCES users(public_key) ON DELETE CASCADE,
ip TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_user_logins_public_key ON user_logins(public_key);
CREATE INDEX IF NOT EXISTS idx_user_logins_created_at ON user_logins(created_at);
+11 -4
View File
@@ -54,8 +54,8 @@ func (s *PostgresStore) GetUser(publicKey string) (User, error) {
func (s *PostgresStore) CreateChallenge(ch Challenge) error { func (s *PostgresStore) CreateChallenge(ch Challenge) error {
_, err := s.db.Exec( _, err := s.db.Exec(
`INSERT INTO challenges (nonce, public_key, expires_at, used) VALUES ($1, $2, $3, $4)`, `INSERT INTO challenges (nonce, public_key, ip, expires_at, used) VALUES ($1, $2, $3, $4, $5)`,
ch.Nonce, ch.PublicKey, ch.ExpiresAt, ch.Used, ch.Nonce, ch.PublicKey, nullStr(ch.IP), ch.ExpiresAt, ch.Used,
) )
if err != nil { if err != nil {
if isUniqueViolation(err) { if isUniqueViolation(err) {
@@ -69,9 +69,9 @@ func (s *PostgresStore) CreateChallenge(ch Challenge) error {
func (s *PostgresStore) GetChallenge(nonce string) (Challenge, error) { func (s *PostgresStore) GetChallenge(nonce string) (Challenge, error) {
var ch Challenge var ch Challenge
err := s.db.QueryRow( err := s.db.QueryRow(
`SELECT nonce, public_key, expires_at, used FROM challenges WHERE nonce = $1`, `SELECT nonce, public_key, COALESCE(ip,''), expires_at, used FROM challenges WHERE nonce = $1`,
nonce, nonce,
).Scan(&ch.Nonce, &ch.PublicKey, &ch.ExpiresAt, &ch.Used) ).Scan(&ch.Nonce, &ch.PublicKey, &ch.IP, &ch.ExpiresAt, &ch.Used)
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
return Challenge{}, ErrNotFound return Challenge{}, ErrNotFound
} }
@@ -282,6 +282,13 @@ func (s *PostgresStore) DeleteFeature(featureID string) error {
return nil return nil
} }
func (s *PostgresStore) SaveUserLogin(ul UserLogin) {
_, _ = s.db.Exec(
`INSERT INTO user_logins (public_key, ip, created_at) VALUES ($1, $2, $3)`,
ul.PublicKey, nullStr(ul.IP), ul.CreatedAt,
)
}
func (s *PostgresStore) PruneExpired(now time.Time) { func (s *PostgresStore) PruneExpired(now time.Time) {
_, _ = s.db.Exec(`DELETE FROM challenges WHERE expires_at < $1`, now) _, _ = s.db.Exec(`DELETE FROM challenges WHERE expires_at < $1`, now)
_, _ = s.db.Exec(`DELETE FROM sessions WHERE expires_at < $1`, now) _, _ = s.db.Exec(`DELETE FROM sessions WHERE expires_at < $1`, now)
+8
View File
@@ -11,10 +11,18 @@ type User struct {
type Challenge struct { type Challenge struct {
Nonce string Nonce string
PublicKey string PublicKey string
IP string
ExpiresAt time.Time ExpiresAt time.Time
Used bool Used bool
} }
// UserLogin records a successful login for a user (ip, created_at)
type UserLogin struct {
PublicKey string
IP string
CreatedAt time.Time
}
type Session struct { type Session struct {
Token string Token string
PublicKey string PublicKey string