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/
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:
- `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)
**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:
```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)
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)
if err := http.ListenAndServe(addr, api.Routes()); err != nil {
if err := http.ListenAndServe(addr, h); err != nil {
log.Fatalf("listen: %v", err)
}
}
+5
View File
@@ -2,12 +2,15 @@ services:
db:
image: postgis/postgis:17-3.5
container_name: momswap-backend-db
ports:
- "7721:5432"
environment:
POSTGRES_DB: "${POSTGRES_DB:-momswap}"
POSTGRES_USER: "${POSTGRES_USER:-momswap}"
POSTGRES_PASSWORD: "${POSTGRES_PASSWORD:-momswap}"
volumes:
- ./var/posrgres:/var/lib/postgresql/data
- ./etc/pg-init-remote.sh:/docker-entrypoint-initdb.d/99-remote.sh:ro
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-momswap} -d ${POSTGRES_DB:-momswap}"]
interval: 10s
@@ -31,6 +34,7 @@ services:
DATABASE_URL: "postgres://${POSTGRES_USER:-momswap}:${POSTGRES_PASSWORD:-momswap}@db:5432/${POSTGRES_DB:-momswap}?sslmode=disable"
volumes:
- ./etc:/app/etc:ro
- ./var/logs:/app/var/logs
depends_on:
db:
condition: service_healthy
@@ -54,6 +58,7 @@ services:
DATABASE_URL: "postgres://${POSTGRES_USER:-momswap}:${POSTGRES_PASSWORD:-momswap}@db:5432/${POSTGRES_DB:-momswap}?sslmode=disable"
volumes:
- ./etc:/src/etc:ro
- ./var/logs:/src/var/logs
depends_on:
db:
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
}
func (s *Service) CreateChallenge(publicKey string) (string, error) {
func (s *Service) CreateChallenge(publicKey, clientIP string) (string, error) {
if publicKey == "" {
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{
Nonce: nonce,
PublicKey: publicKey,
IP: clientIP,
ExpiresAt: time.Now().UTC().Add(s.config.ChallengeTTL),
Used: false,
})
@@ -102,7 +103,7 @@ func (s *Service) CreateChallenge(publicKey string) (string, error) {
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)
if err != nil {
return "", fmt.Errorf("%w: challenge not found", ErrUnauthorized)
@@ -132,6 +133,11 @@ func (s *Service) Login(publicKey, nonce, signature string) (string, error) {
PublicKey: publicKey,
ExpiresAt: time.Now().UTC().Add(s.config.SessionTTL),
})
s.store.SaveUserLogin(store.UserLogin{
PublicKey: publicKey,
IP: clientIP,
CreatedAt: time.Now().UTC(),
})
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)
return
}
nonce, err := a.service.CreateChallenge(req.PublicKey)
nonce, err := a.service.CreateChallenge(req.PublicKey, clientIP(r))
if err != nil {
writeErr(w, err)
return
@@ -163,7 +163,7 @@ func (a *API) login(w http.ResponseWriter, r *http.Request) {
writeErr(w, app.ErrBadRequest)
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 {
writeErr(w, err)
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
GetChallenge(nonce string) (Challenge, error)
MarkChallengeUsed(nonce string) error
SaveUserLogin(ul UserLogin)
SaveSession(session Session)
GetSession(token string) (Session, 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)
}
+13 -3
View File
@@ -3,7 +3,9 @@ package store
import (
"database/sql"
"embed"
"io/fs"
"log"
"sort"
_ "github.com/jackc/pgx/v5/stdlib"
)
@@ -17,12 +19,20 @@ func Migrate(databaseURL string) error {
return err
}
defer db.Close()
sql, err := migrationsFS.ReadFile("migrations/0001_init.sql")
entries, err := fs.Glob(migrationsFS, "migrations/*.sql")
if err != nil {
return err
}
if _, err := db.Exec(string(sql)); err != nil {
return err
sort.Strings(entries)
for _, name := range entries {
sql, err := migrationsFS.ReadFile(name)
if err != nil {
return err
}
if _, err := db.Exec(string(sql)); err != nil {
return err
}
}
log.Printf("migrations applied")
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 {
_, err := s.db.Exec(
`INSERT INTO challenges (nonce, public_key, expires_at, used) VALUES ($1, $2, $3, $4)`,
ch.Nonce, ch.PublicKey, ch.ExpiresAt, ch.Used,
`INSERT INTO challenges (nonce, public_key, ip, expires_at, used) VALUES ($1, $2, $3, $4, $5)`,
ch.Nonce, ch.PublicKey, nullStr(ch.IP), ch.ExpiresAt, ch.Used,
)
if err != nil {
if isUniqueViolation(err) {
@@ -69,9 +69,9 @@ func (s *PostgresStore) CreateChallenge(ch Challenge) error {
func (s *PostgresStore) GetChallenge(nonce string) (Challenge, error) {
var ch Challenge
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,
).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) {
return Challenge{}, ErrNotFound
}
@@ -282,6 +282,13 @@ func (s *PostgresStore) DeleteFeature(featureID string) error {
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) {
_, _ = s.db.Exec(`DELETE FROM challenges 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 {
Nonce string
PublicKey string
IP string
ExpiresAt time.Time
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 {
Token string
PublicKey string