Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,12 @@ POSTGRES_DB=chessrepeat
FIREBASE_PROJECT_ID=
ALLOWED_ORIGINS=https://example.com

# opening explorer (internal-only service, same compose project). The
# backend reaches it by service name over the compose network. For local
# dev without compose, point at the published loopback port instead:
# EXPLORER_URL=http://localhost:8090
EXPLORER_URL=http://explorer:8080

# backups
DB_BACKUP_TAG=
TZ=UTC
Expand Down
16 changes: 16 additions & 0 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ on:
env:
REGISTRY: ghcr.io
BACKEND_IMAGE: ${{ github.repository }}-backend
EXPLORER_IMAGE: ${{ github.repository }}-explorer

jobs:
# run tests before deploy.
Expand All @@ -27,6 +28,11 @@ jobs:
- name: test
working-directory: backend
run: go test ./...
- name: explorer vet + test
working-directory: explorer
run: |
go vet ./...
go test ./...

build-and-push:
if: github.event_name == 'push'
Expand All @@ -53,6 +59,16 @@ jobs:
${{ env.REGISTRY }}/${{ env.BACKEND_IMAGE }}:${{ github.sha }}
cache-from: type=gha,scope=backend
cache-to: type=gha,scope=backend,mode=max
- name: build & push explorer
uses: docker/build-push-action@v6
with:
context: explorer
push: true
tags: |
${{ env.REGISTRY }}/${{ env.EXPLORER_IMAGE }}:latest
${{ env.REGISTRY }}/${{ env.EXPLORER_IMAGE }}:${{ github.sha }}
cache-from: type=gha,scope=explorer
cache-to: type=gha,scope=explorer,mode=max

deploy:
if: github.event_name == 'push'
Expand Down
3 changes: 3 additions & 0 deletions backend/cmd/server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@ func main() {
// Chapter creation is an HTTP POST (the tree exceeds the WS frame
// cap); on success the ws server nudges the owner's room to resync.
api.RegisterChapterRoute(httpMux, db, wsServer)
// Opening-explorer proxy: browser -> here (cookie auth) -> explorer
// service (bearer token). Disabled if EXPLORER_URL/token are unset.
api.RegisterExplorerRoute(httpMux, db, cfg.ExplorerURL)

// rootMux dispatches WS upgrades directly (long-lived, no timeout)
// and everything else through the timeout wrapper.
Expand Down
68 changes: 68 additions & 0 deletions backend/internal/api/explorer.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
package api

import (
"io"
"log"
"net/http"
"net/url"
"time"

"chessrepeat/internal/auth"
"chessrepeat/internal/store"
)

// explorerClient bounds the server-to-server call so a slow explorer
// can't tie up a request goroutine indefinitely.
var explorerClient = &http.Client{Timeout: 5 * time.Second}

// GetExplorer proxies opening-explorer stats for a position. The browser
// hits this cookie-authenticated endpoint (same session gate as every
// other API route); the backend forwards to the explorer, an internal-
// only service reached over the compose network. Response body is the
// explorer's JSON passed through unchanged.
func GetExplorer(db store.Repo, explorerURL string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if _, ok := auth.RequireSession(db, w, r); !ok {
return
}
if explorerURL == "" {
// Feature disabled (e.g. local dev without the explorer running).
// Return an empty result rather than an error so the UI just
// shows "no games" and the browser console stays clean.
w.Header().Set("Content-Type", "application/json")
w.Write([]byte("null"))
return
}

fen := r.URL.Query().Get("fen")
if fen == "" {
http.Error(w, "missing fen", http.StatusBadRequest)
return
}

upstream := explorerURL + "/query?fen=" + url.QueryEscape(fen)
req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, upstream, nil)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
return
}

resp, err := explorerClient.Do(req)
if err != nil {
log.Println("explorer query failed:", err)
http.Error(w, "explorer unavailable", http.StatusBadGateway)
return
}
defer resp.Body.Close()

w.Header().Set("Content-Type", "application/json")
w.WriteHeader(resp.StatusCode)
io.Copy(w, resp.Body)
}
}

// RegisterExplorerRoute wires the /explorer proxy. No-op-friendly: when
// the explorer isn't configured the handler returns an empty result.
func RegisterExplorerRoute(mux *http.ServeMux, db store.Repo, explorerURL string) {
mux.HandleFunc("GET /explorer", GetExplorer(db, explorerURL))
}
6 changes: 6 additions & 0 deletions backend/internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,11 @@ type Config struct {
// chessrepeat.com and the API at api.chessrepeat.com). Leave unset
// in local dev where SPA and API share the same host.
HintCookieDomain string
// ExplorerURL is the base URL of the opening-explorer service, an
// internal-only container the backend reaches over the compose
// network. The browser never touches it, so no token is involved.
// Empty disables the /explorer route.
ExplorerURL string
}

// Load reads .env (if present) and resolves every config value, applying
Expand All @@ -46,6 +51,7 @@ func Load() Config {
FirebaseProjectID: strings.TrimSpace(os.Getenv("FIREBASE_PROJECT_ID")),
CookieSecure: strings.EqualFold(os.Getenv("COOKIE_SECURE"), "true"),
HintCookieDomain: strings.TrimSpace(os.Getenv("HINT_COOKIE_DOMAIN")),
ExplorerURL: strings.TrimRight(strings.TrimSpace(os.Getenv("EXPLORER_URL")), "/"),
}

if cfg.FirebaseProjectID == "" {
Expand Down
20 changes: 19 additions & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,14 +26,31 @@ services:
ALLOWED_ORIGINS: ${ALLOWED_ORIGINS}
LISTEN_ADDR: ":8080"
COOKIE_SECURE: "true"
#chessrepeat.com, api.chessrepeat.com
#chessrepeat.com, api.chessrepeat.com
HINT_COOKIE_DOMAIN: ${HINT_COOKIE_DOMAIN}
# Opening-explorer service on the compose network (internal-only,
# no token — it isn't reachable from the internet).
EXPLORER_URL: ${EXPLORER_URL:-http://explorer:8080}
ports:
- "127.0.0.1:8080:8080"
depends_on:
postgres:
condition: service_healthy

# Opening explorer: pebble-backed position stats. Internal-only — no
# Caddy route. The host publish is loopback (for the import CLI on the
# box), not the public internet.
explorer:
image: ghcr.io/${GH_OWNER}/chessrepeat-explorer:${IMAGE_TAG:-latest}
restart: unless-stopped
environment:
DB_PATH: /data/chess
LISTEN_ADDR: ":8080"
ports:
- "127.0.0.1:8090:8080"
volumes:
- explorerdata:/data

# daily db backup sidecar to r2 or s3
db-backup:
image: tiredofit/db-backup:${DB_BACKUP_TAG:-latest}
Expand Down Expand Up @@ -76,3 +93,4 @@ services:

volumes:
pgdata:
explorerdata:
10 changes: 10 additions & 0 deletions explorer/.dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
.env
.env.*
*.log
*.pgn
chess/
explorer
README.md
DEPLOY.md
.github
query.http
40 changes: 40 additions & 0 deletions explorer/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# If you prefer the allow list template instead of the deny list, see community template:
# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore
#
# Binaries for programs and plugins
*.exe
*.exe~
*.dll
*.so
*.dylib

# Test binary, built with `go test -c`
*.test

# Code coverage profiles and other test artifacts
*.out
coverage.*
*.coverprofile
profile.cov

# Dependency directories (remove the comment below to include it)
# vendor/

# Go workspace file
go.work
go.work.sum

# env file
.env

# Editor/IDE
# .idea/
# .vscode/

*.pgn
chess/

# Local build outputs
/explorer
/chessrepeat-explorer
/import-pgn
29 changes: 29 additions & 0 deletions explorer/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# syntax=docker/dockerfile:1

# Build stage: compile a fully static binary so the runtime image can be
# distroless. CGO is off — pebble and its deps build pure-Go.
FROM golang:1.26-alpine AS build
WORKDIR /src

COPY go.mod go.sum ./
RUN go mod download

COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" \
-o /out/explorer ./
# Empty data dir to ship in the runtime image (distroless has no shell to
# mkdir there).
RUN mkdir -p /out/data

# Runtime stage: distroless static. No shell, no package manager, runs
# as nonroot. Pebble data lives on a mounted volume via DB_PATH.
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=build /out/explorer /explorer
# /data must exist and be owned by nonroot: a fresh named volume copies
# the image's ownership on first mount. Without this the volume is
# root-owned and pebble fails with "mkdir /data/chess: permission denied".
COPY --from=build --chown=nonroot:nonroot /out/data /data
ENV DB_PATH=/data/chess LISTEN_ADDR=:8080
EXPOSE 8080
USER nonroot:nonroot
ENTRYPOINT ["/explorer"]
124 changes: 124 additions & 0 deletions explorer/cmd/import-pgn/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
package main

import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"os"

"github.com/corentings/chess"
)

// Payload is the per-game body sent to the /import endpoint.
type Payload struct {
Winner chess.Color `json:"winner"`
Moves []string `json:"moves"`
}

// winner maps a game outcome to the winning color, or NoColor for a draw/no result.
func winner(o chess.Outcome) chess.Color {
switch o {
case chess.WhiteWon:
return chess.White
case chess.BlackWon:
return chess.Black
default:
return chess.NoColor
}
}

// batchSize is how many games go in each PUT. The server aggregates a
// batch in memory and commits it with a single fsync, so larger batches
// mean far fewer syncs; 1000 games is ~a few hundred KB of JSON.
const batchSize = 1000

// importResult mirrors the server's ImportResult.
type importResult struct {
Imported int `json:"imported"`
Skipped int `json:"skipped"`
}

func main() {
if len(os.Args) < 3 {
fmt.Println("Usage: import-pgn <pgn-file> <api-url>")
os.Exit(1)
}

pgnPath := os.Args[1]
apiPath := os.Args[2]

f, err := os.Open(pgnPath)
if err != nil {
panic(err)
}
defer f.Close()

client := &http.Client{}
notation := chess.AlgebraicNotation{}
var total importResult

send := func(batch []Payload) {
jsonData, err := json.Marshal(batch)
if err != nil {
fmt.Printf("Error marshaling payload: %v\n", err)
os.Exit(1)
}
req, err := http.NewRequest("PUT", apiPath, bytes.NewBuffer(jsonData))
if err != nil {
fmt.Printf("Error creating request: %v\n", err)
os.Exit(1)
}
req.Header.Set("Content-Type", "application/json")

resp, err := client.Do(req)
if err != nil {
fmt.Printf("Error sending request: %v\n", err)
os.Exit(1)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
fmt.Printf("Import failed: %s %s\n", resp.Status, body)
os.Exit(1)
}
var res importResult
if err := json.NewDecoder(resp.Body).Decode(&res); err != nil {
fmt.Printf("Error decoding response: %v\n", err)
os.Exit(1)
}
total.Imported += res.Imported
total.Skipped += res.Skipped
fmt.Printf("imported %d games (%d skipped)\n", total.Imported, total.Skipped)
}

batch := make([]Payload, 0, batchSize)
scanner := chess.NewScanner(f)
// for each game
for scanner.Scan() {
game := scanner.Next()
if game == nil {
continue
}

// Encode each move in the game's notation (SAN), matching what the
// server decodes with game.MoveStr.
moves := game.Moves()
positions := game.Positions()
notated := make([]string, len(moves))
for i, m := range moves {
notated[i] = notation.Encode(positions[i], m)
}

// can also add filtering step here...
batch = append(batch, Payload{Winner: winner(game.Outcome()), Moves: notated})
if len(batch) == batchSize {
send(batch)
batch = batch[:0]
}
}
if len(batch) > 0 {
send(batch)
}
}
Loading