Add the runnable Gitea, PostgreSQL, and Hop control-plane foundation
Hop-State: A_06FN3QYA1N56NQYGZ81DWGR Hop-Proposal: R_06FN3QXJRYPPEQZQA6S69QG Hop-Task: T_06FN3MVGY3MT82ESQ89BND0 Hop-Attempt: AT_06FN3MVGY092BFA5MR9C7EG
This commit is contained in:
@@ -0,0 +1,9 @@
|
|||||||
|
# Local development only. Replace every non-empty value outside localhost.
|
||||||
|
POSTGRES_PASSWORD=hopweb-local-postgres
|
||||||
|
HOP_ADMIN_TOKEN=hopweb-local-admin-token-change-me
|
||||||
|
HOP_GITEA_WEBHOOK_SECRET=hopweb-local-webhook-secret-change-me
|
||||||
|
|
||||||
|
# Create a Gitea access token after first login, then set it here to enable
|
||||||
|
# POST /api/v1/repositories/link.
|
||||||
|
GITEA_API_TOKEN=
|
||||||
|
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
.env
|
||||||
|
.DS_Store
|
||||||
|
bin/
|
||||||
|
coverage.out
|
||||||
|
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
.PHONY: build config down logs test up
|
||||||
|
|
||||||
|
build:
|
||||||
|
cd services/control-plane && go build -buildvcs=false ./...
|
||||||
|
|
||||||
|
config:
|
||||||
|
docker compose --env-file .env.example config --quiet
|
||||||
|
|
||||||
|
down:
|
||||||
|
docker compose down
|
||||||
|
|
||||||
|
logs:
|
||||||
|
docker compose logs -f
|
||||||
|
|
||||||
|
test:
|
||||||
|
cd services/control-plane && go test -buildvcs=false ./...
|
||||||
|
|
||||||
|
up:
|
||||||
|
test -f .env || cp .env.example .env
|
||||||
|
docker compose up --build -d
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
name: hopweb
|
||||||
|
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:17.6-alpine
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: gitea
|
||||||
|
POSTGRES_USER: hopweb
|
||||||
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?copy .env.example to .env first}
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U hopweb -d gitea"]
|
||||||
|
interval: 3s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 20
|
||||||
|
volumes:
|
||||||
|
- postgres-data:/var/lib/postgresql/data
|
||||||
|
- ./deploy/postgres/init:/docker-entrypoint-initdb.d:ro
|
||||||
|
networks: [hopweb]
|
||||||
|
|
||||||
|
gitea:
|
||||||
|
image: docker.gitea.com/gitea:1.26.4
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
environment:
|
||||||
|
USER_UID: "1000"
|
||||||
|
USER_GID: "1000"
|
||||||
|
GITEA__database__DB_TYPE: postgres
|
||||||
|
GITEA__database__HOST: postgres:5432
|
||||||
|
GITEA__database__NAME: gitea
|
||||||
|
GITEA__database__USER: hopweb
|
||||||
|
GITEA__database__PASSWD: ${POSTGRES_PASSWORD}
|
||||||
|
GITEA__server__DOMAIN: localhost
|
||||||
|
GITEA__server__ROOT_URL: http://localhost:3000/
|
||||||
|
GITEA__server__SSH_DOMAIN: localhost
|
||||||
|
GITEA__server__SSH_PORT: "2222"
|
||||||
|
GITEA__service__DISABLE_REGISTRATION: "false"
|
||||||
|
GITEA__security__INSTALL_LOCK: "true"
|
||||||
|
GITEA__webhook__ALLOWED_HOST_LIST: control-plane
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
- "2222:22"
|
||||||
|
volumes:
|
||||||
|
- gitea-data:/data
|
||||||
|
networks: [hopweb]
|
||||||
|
|
||||||
|
control-plane:
|
||||||
|
build:
|
||||||
|
context: ./services/control-plane
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
gitea:
|
||||||
|
condition: service_started
|
||||||
|
environment:
|
||||||
|
HOP_HTTP_ADDR: :8080
|
||||||
|
HOP_DATABASE_URL: postgres://hopweb:${POSTGRES_PASSWORD}@postgres:5432/hop?sslmode=disable
|
||||||
|
HOP_ADMIN_TOKEN: ${HOP_ADMIN_TOKEN:?set HOP_ADMIN_TOKEN in .env}
|
||||||
|
HOP_GITEA_WEBHOOK_SECRET: ${HOP_GITEA_WEBHOOK_SECRET:?set HOP_GITEA_WEBHOOK_SECRET in .env}
|
||||||
|
GITEA_BASE_URL: http://gitea:3000
|
||||||
|
GITEA_API_TOKEN: ${GITEA_API_TOKEN:-}
|
||||||
|
ports:
|
||||||
|
- "8080:8080"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "wget", "-qO-", "http://localhost:8080/readyz"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 20
|
||||||
|
networks: [hopweb]
|
||||||
|
|
||||||
|
networks:
|
||||||
|
hopweb:
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
gitea-data:
|
||||||
|
postgres-data:
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
CREATE DATABASE hop OWNER hopweb;
|
||||||
|
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
# Local development
|
||||||
|
|
||||||
|
## Start the foundation
|
||||||
|
|
||||||
|
Requirements: Docker with Compose v2, Go 1.26 or newer for host-side tests, and
|
||||||
|
ports 3000, 8080, and 2222 available.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cp .env.example .env
|
||||||
|
make up
|
||||||
|
```
|
||||||
|
|
||||||
|
Open Gitea at <http://localhost:3000>. The Hop control plane exposes health at
|
||||||
|
<http://localhost:8080/healthz> and readiness at
|
||||||
|
<http://localhost:8080/readyz>.
|
||||||
|
|
||||||
|
Gitea self-registration is enabled for this local stack. After creating the
|
||||||
|
first account, create an access token in Gitea and set `GITEA_API_TOKEN` in
|
||||||
|
`.env`, then restart the control plane:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker compose up -d --force-recreate control-plane
|
||||||
|
```
|
||||||
|
|
||||||
|
## Link a repository
|
||||||
|
|
||||||
|
Create a repository in Gitea, then ask Hop to verify it through the Gitea API
|
||||||
|
and record the link:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
set -a
|
||||||
|
. ./.env
|
||||||
|
set +a
|
||||||
|
|
||||||
|
curl --fail-with-body \
|
||||||
|
-H "Authorization: Bearer $HOP_ADMIN_TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"owner":"YOUR_USER","name":"YOUR_REPOSITORY"}' \
|
||||||
|
http://localhost:8080/api/v1/repositories/link
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configure a webhook
|
||||||
|
|
||||||
|
In the Gitea repository, create a Gitea webhook with:
|
||||||
|
|
||||||
|
- target URL: `http://control-plane:8080/api/v1/gitea/webhooks`
|
||||||
|
- content type: `application/json`
|
||||||
|
- secret: the value of `HOP_GITEA_WEBHOOK_SECRET` in `.env`
|
||||||
|
- events: all events
|
||||||
|
|
||||||
|
The control plane verifies `X-Gitea-Signature` against the raw request body,
|
||||||
|
records each delivery exactly once, and refreshes repository metadata from the
|
||||||
|
signed payload.
|
||||||
|
|
||||||
|
## Useful commands
|
||||||
|
|
||||||
|
```sh
|
||||||
|
make test
|
||||||
|
make config
|
||||||
|
make logs
|
||||||
|
make down
|
||||||
|
```
|
||||||
|
|
||||||
|
To destroy local data as well as containers, run `docker compose down -v`.
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
FROM golang:1.26-alpine AS build
|
||||||
|
WORKDIR /src
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
RUN go mod download
|
||||||
|
COPY . .
|
||||||
|
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/hop-control-plane ./cmd/hop-control-plane
|
||||||
|
|
||||||
|
FROM alpine:3.23
|
||||||
|
RUN apk add --no-cache ca-certificates && addgroup -S hop && adduser -S -G hop hop
|
||||||
|
COPY --from=build /out/hop-control-plane /usr/local/bin/hop-control-plane
|
||||||
|
USER hop
|
||||||
|
EXPOSE 8080
|
||||||
|
ENTRYPOINT ["hop-control-plane"]
|
||||||
|
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"hopweb/internal/config"
|
||||||
|
"hopweb/internal/gitea"
|
||||||
|
"hopweb/internal/httpapi"
|
||||||
|
"hopweb/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
|
||||||
|
cfg, err := config.Load()
|
||||||
|
if err != nil {
|
||||||
|
logger.Error("invalid configuration", "error", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
db, err := store.Open(ctx, cfg.DatabaseURL)
|
||||||
|
if err != nil {
|
||||||
|
logger.Error("open database", "error", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
if err := db.Migrate(ctx); err != nil {
|
||||||
|
logger.Error("migrate database", "error", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
giteaClient, err := gitea.NewClient(cfg.GiteaBaseURL, cfg.GiteaAPIToken)
|
||||||
|
if err != nil {
|
||||||
|
logger.Error("configure gitea client", "error", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
server := &http.Server{
|
||||||
|
Addr: cfg.HTTPAddr,
|
||||||
|
Handler: httpapi.New(db, giteaClient, cfg.AdminToken, cfg.GiteaWebhookSecret, logger),
|
||||||
|
ReadHeaderTimeout: 5 * time.Second,
|
||||||
|
ReadTimeout: 15 * time.Second,
|
||||||
|
WriteTimeout: 15 * time.Second,
|
||||||
|
IdleTimeout: 60 * time.Second,
|
||||||
|
}
|
||||||
|
|
||||||
|
errCh := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
logger.Info("control plane listening", "addr", cfg.HTTPAddr)
|
||||||
|
errCh <- server.ListenAndServe()
|
||||||
|
}()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case err := <-errCh:
|
||||||
|
if !errors.Is(err, http.ErrServerClosed) {
|
||||||
|
logger.Error("serve", "error", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
case <-ctx.Done():
|
||||||
|
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
defer shutdownCancel()
|
||||||
|
if err := server.Shutdown(shutdownCtx); err != nil {
|
||||||
|
logger.Error("shutdown", "error", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
module hopweb
|
||||||
|
|
||||||
|
go 1.26.0
|
||||||
|
|
||||||
|
require github.com/jackc/pgx/v5 v5.10.0
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||||
|
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||||
|
golang.org/x/sync v0.17.0 // indirect
|
||||||
|
golang.org/x/text v0.29.0 // indirect
|
||||||
|
)
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||||
|
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||||
|
github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0=
|
||||||
|
github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
|
||||||
|
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||||
|
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
|
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
|
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
||||||
|
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||||
|
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
|
||||||
|
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
HTTPAddr string
|
||||||
|
DatabaseURL string
|
||||||
|
AdminToken string
|
||||||
|
GiteaWebhookSecret string
|
||||||
|
GiteaBaseURL string
|
||||||
|
GiteaAPIToken string
|
||||||
|
}
|
||||||
|
|
||||||
|
func Load() (Config, error) {
|
||||||
|
cfg := Config{
|
||||||
|
HTTPAddr: value("HOP_HTTP_ADDR", ":8080"),
|
||||||
|
DatabaseURL: os.Getenv("HOP_DATABASE_URL"),
|
||||||
|
AdminToken: os.Getenv("HOP_ADMIN_TOKEN"),
|
||||||
|
GiteaWebhookSecret: os.Getenv("HOP_GITEA_WEBHOOK_SECRET"),
|
||||||
|
GiteaBaseURL: value("GITEA_BASE_URL", "http://localhost:3000"),
|
||||||
|
GiteaAPIToken: os.Getenv("GITEA_API_TOKEN"),
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.DatabaseURL == "" {
|
||||||
|
return Config{}, errors.New("HOP_DATABASE_URL is required")
|
||||||
|
}
|
||||||
|
if cfg.AdminToken == "" {
|
||||||
|
return Config{}, errors.New("HOP_ADMIN_TOKEN is required")
|
||||||
|
}
|
||||||
|
if len(cfg.AdminToken) < 16 {
|
||||||
|
return Config{}, errors.New("HOP_ADMIN_TOKEN must be at least 16 characters")
|
||||||
|
}
|
||||||
|
if cfg.GiteaWebhookSecret == "" {
|
||||||
|
return Config{}, errors.New("HOP_GITEA_WEBHOOK_SECRET is required")
|
||||||
|
}
|
||||||
|
if len(cfg.GiteaWebhookSecret) < 16 {
|
||||||
|
return Config{}, errors.New("HOP_GITEA_WEBHOOK_SECRET must be at least 16 characters")
|
||||||
|
}
|
||||||
|
return cfg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func value(name, fallback string) string {
|
||||||
|
if v := os.Getenv(name); v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestLoadRequiresSecretsAndDatabase(t *testing.T) {
|
||||||
|
t.Setenv("HOP_DATABASE_URL", "")
|
||||||
|
t.Setenv("HOP_ADMIN_TOKEN", "")
|
||||||
|
t.Setenv("HOP_GITEA_WEBHOOK_SECRET", "")
|
||||||
|
if _, err := Load(); err == nil {
|
||||||
|
t.Fatal("expected missing configuration error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadDefaults(t *testing.T) {
|
||||||
|
t.Setenv("HOP_DATABASE_URL", "postgres://example")
|
||||||
|
t.Setenv("HOP_ADMIN_TOKEN", "admin-token-long-enough")
|
||||||
|
t.Setenv("HOP_GITEA_WEBHOOK_SECRET", "webhook-secret-long-enough")
|
||||||
|
t.Setenv("HOP_HTTP_ADDR", "")
|
||||||
|
t.Setenv("GITEA_BASE_URL", "")
|
||||||
|
cfg, err := Load()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if cfg.HTTPAddr != ":8080" || cfg.GiteaBaseURL != "http://localhost:3000" {
|
||||||
|
t.Fatalf("unexpected defaults: %#v", cfg)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
package gitea
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
var ErrTokenNotConfigured = errors.New("gitea API token is not configured")
|
||||||
|
|
||||||
|
type Repository struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Owner Owner `json:"owner"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
FullName string `json:"full_name"`
|
||||||
|
CloneURL string `json:"clone_url"`
|
||||||
|
DefaultBranch string `json:"default_branch"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Owner struct {
|
||||||
|
Login string `json:"login"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r Repository) OwnerName() string {
|
||||||
|
if r.Owner.Login != "" {
|
||||||
|
return r.Owner.Login
|
||||||
|
}
|
||||||
|
return r.Owner.Username
|
||||||
|
}
|
||||||
|
|
||||||
|
type Client struct {
|
||||||
|
baseURL *url.URL
|
||||||
|
token string
|
||||||
|
http *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewClient(baseURL, token string) (*Client, error) {
|
||||||
|
u, err := url.Parse(strings.TrimRight(baseURL, "/"))
|
||||||
|
if err != nil || u.Scheme == "" || u.Host == "" {
|
||||||
|
return nil, fmt.Errorf("invalid GITEA_BASE_URL %q", baseURL)
|
||||||
|
}
|
||||||
|
return &Client{baseURL: u, token: token, http: &http.Client{Timeout: 10 * time.Second}}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) Repository(ctx context.Context, owner, name string) (Repository, error) {
|
||||||
|
if c.token == "" {
|
||||||
|
return Repository{}, ErrTokenNotConfigured
|
||||||
|
}
|
||||||
|
u := c.baseURL.JoinPath("api", "v1", "repos", owner, name)
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
|
||||||
|
if err != nil {
|
||||||
|
return Repository{}, err
|
||||||
|
}
|
||||||
|
req.Header.Set("Authorization", "token "+c.token)
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
|
||||||
|
resp, err := c.http.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return Repository{}, fmt.Errorf("request repository: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return Repository{}, fmt.Errorf("gitea repository lookup returned %s", resp.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
var repo Repository
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&repo); err != nil {
|
||||||
|
return Repository{}, fmt.Errorf("decode repository: %w", err)
|
||||||
|
}
|
||||||
|
if repo.ID <= 0 || repo.OwnerName() == "" || repo.Name == "" {
|
||||||
|
return Repository{}, errors.New("gitea returned an incomplete repository")
|
||||||
|
}
|
||||||
|
return repo, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
package gitea
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRepositoryUsesTokenAndDecodesResponse(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path != "/gitea/api/v1/repos/hop/demo" {
|
||||||
|
t.Fatalf("path = %q", r.URL.Path)
|
||||||
|
}
|
||||||
|
if got := r.Header.Get("Authorization"); got != "token gitea-token" {
|
||||||
|
t.Fatalf("authorization = %q", got)
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{"id":42,"owner":{"login":"hop"},"name":"demo","full_name":"hop/demo","clone_url":"http://gitea/hop/demo.git","default_branch":"main"}`))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
client, err := NewClient(server.URL+"/gitea", "gitea-token")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
repository, err := client.Repository(context.Background(), "hop", "demo")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if repository.ID != 42 || repository.OwnerName() != "hop" || repository.DefaultBranch != "main" {
|
||||||
|
t.Fatalf("unexpected repository: %#v", repository)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRepositoryRequiresToken(t *testing.T) {
|
||||||
|
client, err := NewClient("http://gitea.test", "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := client.Repository(context.Background(), "hop", "demo"); err != ErrTokenNotConfigured {
|
||||||
|
t.Fatalf("error = %v, want %v", err, ErrTokenNotConfigured)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package gitea
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/hmac"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
)
|
||||||
|
|
||||||
|
func VerifySignature(body []byte, signature, secret string) bool {
|
||||||
|
provided, err := hex.DecodeString(signature)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
mac := hmac.New(sha256.New, []byte(secret))
|
||||||
|
_, _ = mac.Write(body)
|
||||||
|
return hmac.Equal(provided, mac.Sum(nil))
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package gitea
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/hmac"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestVerifySignature(t *testing.T) {
|
||||||
|
body := []byte(`{"hello":"hop"}`)
|
||||||
|
mac := hmac.New(sha256.New, []byte("secret"))
|
||||||
|
_, _ = mac.Write(body)
|
||||||
|
signature := hex.EncodeToString(mac.Sum(nil))
|
||||||
|
|
||||||
|
if !VerifySignature(body, signature, "secret") {
|
||||||
|
t.Fatal("expected valid signature")
|
||||||
|
}
|
||||||
|
if VerifySignature(body, signature, "wrong") {
|
||||||
|
t.Fatal("accepted signature with wrong secret")
|
||||||
|
}
|
||||||
|
if VerifySignature(body, "not-hex", "secret") {
|
||||||
|
t.Fatal("accepted malformed signature")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
package httpapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"crypto/subtle"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"hopweb/internal/gitea"
|
||||||
|
"hopweb/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
const maxWebhookBody = 2 << 20
|
||||||
|
|
||||||
|
type giteaClient interface {
|
||||||
|
Repository(context.Context, string, string) (gitea.Repository, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type server struct {
|
||||||
|
store store.Store
|
||||||
|
gitea giteaClient
|
||||||
|
adminToken string
|
||||||
|
webhookSecret string
|
||||||
|
logger *slog.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(data store.Store, client giteaClient, adminToken, webhookSecret string, logger *slog.Logger) http.Handler {
|
||||||
|
s := &server{store: data, gitea: client, adminToken: adminToken, webhookSecret: webhookSecret, logger: logger}
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("GET /healthz", s.health)
|
||||||
|
mux.HandleFunc("GET /readyz", s.ready)
|
||||||
|
mux.Handle("GET /api/v1/repositories", s.requireAdmin(http.HandlerFunc(s.listRepositories)))
|
||||||
|
mux.Handle("POST /api/v1/repositories/link", s.requireAdmin(http.HandlerFunc(s.linkRepository)))
|
||||||
|
mux.HandleFunc("POST /api/v1/gitea/webhooks", s.giteaWebhook)
|
||||||
|
return requestLogger(logger, mux)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *server) health(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *server) ready(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
if err := s.store.Ping(ctx); err != nil {
|
||||||
|
writeError(w, http.StatusServiceUnavailable, "database unavailable")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]string{"status": "ready"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *server) listRepositories(w http.ResponseWriter, r *http.Request) {
|
||||||
|
repositories, err := s.store.ListRepositories(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error("list repositories", "error", err)
|
||||||
|
writeError(w, http.StatusInternalServerError, "could not list repositories")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"repositories": repositories})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *server) linkRepository(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var input struct {
|
||||||
|
Owner string `json:"owner"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
if err := decodeJSON(w, r, &input); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !validSlug(input.Owner) || !validSlug(input.Name) {
|
||||||
|
writeError(w, http.StatusBadRequest, "owner and name are required and cannot contain slashes")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
repository, err := s.gitea.Repository(r.Context(), input.Owner, input.Name)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, gitea.ErrTokenNotConfigured) {
|
||||||
|
writeError(w, http.StatusServiceUnavailable, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.logger.Warn("gitea repository lookup failed", "owner", input.Owner, "name", input.Name, "error", err)
|
||||||
|
writeError(w, http.StatusBadGateway, "could not load repository from Gitea")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
saved, err := s.store.UpsertRepository(r.Context(), repository)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error("link repository", "error", err)
|
||||||
|
writeError(w, http.StatusInternalServerError, "could not link repository")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusCreated, saved)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *server) giteaWebhook(w http.ResponseWriter, r *http.Request) {
|
||||||
|
body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, maxWebhookBody))
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusRequestEntityTooLarge, "webhook payload too large")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !gitea.VerifySignature(body, r.Header.Get("X-Gitea-Signature"), s.webhookSecret) {
|
||||||
|
writeError(w, http.StatusUnauthorized, "invalid webhook signature")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
deliveryID := strings.TrimSpace(r.Header.Get("X-Gitea-Delivery"))
|
||||||
|
event := strings.TrimSpace(r.Header.Get("X-Gitea-Event"))
|
||||||
|
if deliveryID == "" || event == "" {
|
||||||
|
writeError(w, http.StatusBadRequest, "missing Gitea delivery or event header")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var payload struct {
|
||||||
|
Repository *gitea.Repository `json:"repository"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, &payload); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid JSON payload")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
digest := sha256.Sum256(body)
|
||||||
|
duplicate, err := s.store.RecordWebhook(r.Context(), store.WebhookDelivery{
|
||||||
|
DeliveryID: deliveryID, Event: event, EventType: r.Header.Get("X-Gitea-Event-Type"),
|
||||||
|
PayloadSHA256: hex.EncodeToString(digest[:]), Repository: payload.Repository,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error("record webhook", "delivery_id", deliveryID, "error", err)
|
||||||
|
writeError(w, http.StatusInternalServerError, "could not record webhook")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if duplicate {
|
||||||
|
w.Header().Set("X-Hop-Duplicate", "true")
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *server) requireAdmin(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
authorization := r.Header.Get("Authorization")
|
||||||
|
if !strings.HasPrefix(authorization, "Bearer ") {
|
||||||
|
writeError(w, http.StatusUnauthorized, "unauthorized")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
provided := strings.TrimPrefix(authorization, "Bearer ")
|
||||||
|
if len(provided) != len(s.adminToken) || subtle.ConstantTimeCompare([]byte(provided), []byte(s.adminToken)) != 1 {
|
||||||
|
writeError(w, http.StatusUnauthorized, "unauthorized")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func validSlug(value string) bool {
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
return value != "" && !strings.ContainsAny(value, "/\\")
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeJSON(w http.ResponseWriter, r *http.Request, target any) error {
|
||||||
|
r.Body = http.MaxBytesReader(w, r.Body, 64<<10)
|
||||||
|
decoder := json.NewDecoder(r.Body)
|
||||||
|
decoder.DisallowUnknownFields()
|
||||||
|
if err := decoder.Decode(target); err != nil {
|
||||||
|
return errors.New("invalid JSON body")
|
||||||
|
}
|
||||||
|
if err := decoder.Decode(&struct{}{}); err != io.EOF {
|
||||||
|
return errors.New("request body must contain one JSON object")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeError(w http.ResponseWriter, status int, message string) {
|
||||||
|
writeJSON(w, status, map[string]any{"error": map[string]string{"message": message}})
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeJSON(w http.ResponseWriter, status int, body any) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(status)
|
||||||
|
_ = json.NewEncoder(w).Encode(body)
|
||||||
|
}
|
||||||
|
|
||||||
|
func requestLogger(logger *slog.Logger, next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
started := time.Now()
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
logger.Info("request", "method", r.Method, "path", r.URL.Path, "duration_ms", time.Since(started).Milliseconds())
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
package httpapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"crypto/hmac"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"hopweb/internal/gitea"
|
||||||
|
"hopweb/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
type fakeStore struct {
|
||||||
|
repositories []store.Repository
|
||||||
|
delivery store.WebhookDelivery
|
||||||
|
duplicate bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) Ping(context.Context) error { return nil }
|
||||||
|
func (f *fakeStore) ListRepositories(context.Context) ([]store.Repository, error) {
|
||||||
|
return f.repositories, nil
|
||||||
|
}
|
||||||
|
func (f *fakeStore) UpsertRepository(_ context.Context, repository gitea.Repository) (store.Repository, error) {
|
||||||
|
saved := store.Repository{ID: "RP_TEST", GiteaID: repository.ID, Owner: repository.OwnerName(), Name: repository.Name, FullName: repository.FullName}
|
||||||
|
f.repositories = append(f.repositories, saved)
|
||||||
|
return saved, nil
|
||||||
|
}
|
||||||
|
func (f *fakeStore) RecordWebhook(_ context.Context, delivery store.WebhookDelivery) (bool, error) {
|
||||||
|
f.delivery = delivery
|
||||||
|
return f.duplicate, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type fakeGitea struct {
|
||||||
|
repository gitea.Repository
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f fakeGitea) Repository(context.Context, string, string) (gitea.Repository, error) {
|
||||||
|
return f.repository, f.err
|
||||||
|
}
|
||||||
|
|
||||||
|
func testServer(data store.Store, client giteaClient) http.Handler {
|
||||||
|
return New(data, client, "admin-secret", "webhook-secret", slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdminAuthentication(t *testing.T) {
|
||||||
|
handler := testServer(&fakeStore{}, fakeGitea{})
|
||||||
|
for _, header := range []string{"", "admin-secret", "Bearer wrong"} {
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/repositories", nil)
|
||||||
|
req.Header.Set("Authorization", header)
|
||||||
|
response := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(response, req)
|
||||||
|
if response.Code != http.StatusUnauthorized {
|
||||||
|
t.Fatalf("header %q: status = %d, want %d", header, response.Code, http.StatusUnauthorized)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLinkRepository(t *testing.T) {
|
||||||
|
data := &fakeStore{}
|
||||||
|
client := fakeGitea{repository: gitea.Repository{ID: 42, Owner: gitea.Owner{Login: "hop"}, Name: "demo", FullName: "hop/demo"}}
|
||||||
|
handler := testServer(data, client)
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/repositories/link", bytes.NewBufferString(`{"owner":"hop","name":"demo"}`))
|
||||||
|
req.Header.Set("Authorization", "Bearer admin-secret")
|
||||||
|
response := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(response, req)
|
||||||
|
if response.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("status = %d, want %d; body=%s", response.Code, http.StatusCreated, response.Body.String())
|
||||||
|
}
|
||||||
|
if len(data.repositories) != 1 || data.repositories[0].GiteaID != 42 {
|
||||||
|
t.Fatalf("repository not linked: %#v", data.repositories)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWebhookVerificationAndDeduplication(t *testing.T) {
|
||||||
|
data := &fakeStore{duplicate: true}
|
||||||
|
handler := testServer(data, fakeGitea{err: errors.New("unused")})
|
||||||
|
body := []byte(`{"repository":{"id":42,"owner":{"login":"hop"},"name":"demo","full_name":"hop/demo"}}`)
|
||||||
|
mac := hmac.New(sha256.New, []byte("webhook-secret"))
|
||||||
|
_, _ = mac.Write(body)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/gitea/webhooks", bytes.NewReader(body))
|
||||||
|
req.Header.Set("X-Gitea-Signature", hex.EncodeToString(mac.Sum(nil)))
|
||||||
|
req.Header.Set("X-Gitea-Delivery", "delivery-1")
|
||||||
|
req.Header.Set("X-Gitea-Event", "push")
|
||||||
|
response := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(response, req)
|
||||||
|
|
||||||
|
if response.Code != http.StatusNoContent {
|
||||||
|
t.Fatalf("status = %d, want %d; body=%s", response.Code, http.StatusNoContent, response.Body.String())
|
||||||
|
}
|
||||||
|
if response.Header().Get("X-Hop-Duplicate") != "true" {
|
||||||
|
t.Fatal("expected duplicate response header")
|
||||||
|
}
|
||||||
|
if data.delivery.DeliveryID != "delivery-1" || data.delivery.Repository == nil || data.delivery.Repository.ID != 42 {
|
||||||
|
t.Fatalf("delivery not recorded: %#v", data.delivery)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWebhookRejectsInvalidSignature(t *testing.T) {
|
||||||
|
handler := testServer(&fakeStore{}, fakeGitea{})
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/gitea/webhooks", bytes.NewBufferString(`{}`))
|
||||||
|
req.Header.Set("X-Gitea-Signature", "bad")
|
||||||
|
response := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(response, req)
|
||||||
|
if response.Code != http.StatusUnauthorized {
|
||||||
|
t.Fatalf("status = %d, want %d", response.Code, http.StatusUnauthorized)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package ids
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/base32"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
var encoding = base32.NewEncoding("0123456789ABCDEFGHJKMNPQRSTVWXYZ").WithPadding(base32.NoPadding)
|
||||||
|
|
||||||
|
func Repository() (string, error) {
|
||||||
|
value := make([]byte, 16)
|
||||||
|
milliseconds := uint64(time.Now().UnixMilli())
|
||||||
|
value[0] = byte(milliseconds >> 40)
|
||||||
|
value[1] = byte(milliseconds >> 32)
|
||||||
|
value[2] = byte(milliseconds >> 24)
|
||||||
|
value[3] = byte(milliseconds >> 16)
|
||||||
|
value[4] = byte(milliseconds >> 8)
|
||||||
|
value[5] = byte(milliseconds)
|
||||||
|
if _, err := rand.Read(value[6:]); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return "RP_" + encoding.EncodeToString(value), nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package ids
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRepository(t *testing.T) {
|
||||||
|
first, err := Repository()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
second, err := Repository()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(first, "RP_") || len(first) != 29 {
|
||||||
|
t.Fatalf("unexpected repository ID %q", first)
|
||||||
|
}
|
||||||
|
if first == second {
|
||||||
|
t.Fatal("generated duplicate IDs")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
CREATE TABLE repositories (
|
||||||
|
id text PRIMARY KEY,
|
||||||
|
gitea_id bigint NOT NULL UNIQUE CHECK (gitea_id > 0),
|
||||||
|
owner text NOT NULL,
|
||||||
|
name text NOT NULL,
|
||||||
|
full_name text NOT NULL,
|
||||||
|
clone_url text NOT NULL DEFAULT '',
|
||||||
|
default_branch text NOT NULL DEFAULT '',
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
UNIQUE (owner, name)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE webhook_deliveries (
|
||||||
|
delivery_id text PRIMARY KEY,
|
||||||
|
event text NOT NULL,
|
||||||
|
event_type text NOT NULL DEFAULT '',
|
||||||
|
payload_sha256 text NOT NULL,
|
||||||
|
received_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"embed"
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
||||||
|
"hopweb/internal/gitea"
|
||||||
|
"hopweb/internal/ids"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed migrations/*.sql
|
||||||
|
var migrations embed.FS
|
||||||
|
|
||||||
|
type Repository struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
GiteaID int64 `json:"gitea_id"`
|
||||||
|
Owner string `json:"owner"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
FullName string `json:"full_name"`
|
||||||
|
CloneURL string `json:"clone_url"`
|
||||||
|
DefaultBranch string `json:"default_branch"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type WebhookDelivery struct {
|
||||||
|
DeliveryID string
|
||||||
|
Event string
|
||||||
|
EventType string
|
||||||
|
PayloadSHA256 string
|
||||||
|
Repository *gitea.Repository
|
||||||
|
}
|
||||||
|
|
||||||
|
type Store interface {
|
||||||
|
Ping(context.Context) error
|
||||||
|
ListRepositories(context.Context) ([]Repository, error)
|
||||||
|
UpsertRepository(context.Context, gitea.Repository) (Repository, error)
|
||||||
|
RecordWebhook(context.Context, WebhookDelivery) (bool, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type Postgres struct {
|
||||||
|
pool *pgxpool.Pool
|
||||||
|
}
|
||||||
|
|
||||||
|
func Open(ctx context.Context, databaseURL string) (*Postgres, error) {
|
||||||
|
pool, err := pgxpool.New(ctx, databaseURL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := pool.Ping(ctx); err != nil {
|
||||||
|
pool.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &Postgres{pool: pool}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Postgres) Close() { p.pool.Close() }
|
||||||
|
|
||||||
|
func (p *Postgres) Ping(ctx context.Context) error { return p.pool.Ping(ctx) }
|
||||||
|
|
||||||
|
func (p *Postgres) Migrate(ctx context.Context) error {
|
||||||
|
if _, err := p.pool.Exec(ctx, `CREATE TABLE IF NOT EXISTS schema_migrations (name text PRIMARY KEY, applied_at timestamptz NOT NULL DEFAULT now())`); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
entries, err := migrations.ReadDir("migrations")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
sort.Slice(entries, func(i, j int) bool { return entries[i].Name() < entries[j].Name() })
|
||||||
|
for _, entry := range entries {
|
||||||
|
name := entry.Name()
|
||||||
|
var exists bool
|
||||||
|
if err := p.pool.QueryRow(ctx, `SELECT EXISTS (SELECT 1 FROM schema_migrations WHERE name=$1)`, name).Scan(&exists); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if exists {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
sql, err := migrations.ReadFile("migrations/" + name)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
tx, err := p.pool.Begin(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err = tx.Exec(ctx, string(sql)); err == nil {
|
||||||
|
_, err = tx.Exec(ctx, `INSERT INTO schema_migrations (name) VALUES ($1)`, name)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
_ = tx.Rollback(ctx)
|
||||||
|
return fmt.Errorf("apply migration %s: %w", name, err)
|
||||||
|
}
|
||||||
|
if err := tx.Commit(ctx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Postgres) ListRepositories(ctx context.Context) ([]Repository, error) {
|
||||||
|
rows, err := p.pool.Query(ctx, `SELECT id, gitea_id, owner, name, full_name, clone_url, default_branch, created_at, updated_at FROM repositories ORDER BY full_name`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
repositories := make([]Repository, 0)
|
||||||
|
for rows.Next() {
|
||||||
|
var repository Repository
|
||||||
|
if err := rows.Scan(&repository.ID, &repository.GiteaID, &repository.Owner, &repository.Name, &repository.FullName, &repository.CloneURL, &repository.DefaultBranch, &repository.CreatedAt, &repository.UpdatedAt); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
repositories = append(repositories, repository)
|
||||||
|
}
|
||||||
|
return repositories, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Postgres) UpsertRepository(ctx context.Context, repository gitea.Repository) (Repository, error) {
|
||||||
|
id, err := ids.Repository()
|
||||||
|
if err != nil {
|
||||||
|
return Repository{}, err
|
||||||
|
}
|
||||||
|
return upsertRepository(ctx, p.pool, id, repository)
|
||||||
|
}
|
||||||
|
|
||||||
|
type querier interface {
|
||||||
|
QueryRow(context.Context, string, ...any) pgx.Row
|
||||||
|
}
|
||||||
|
|
||||||
|
func upsertRepository(ctx context.Context, q querier, id string, repository gitea.Repository) (Repository, error) {
|
||||||
|
owner := repository.OwnerName()
|
||||||
|
fullName := repository.FullName
|
||||||
|
if fullName == "" {
|
||||||
|
fullName = owner + "/" + repository.Name
|
||||||
|
}
|
||||||
|
var saved Repository
|
||||||
|
err := q.QueryRow(ctx, `
|
||||||
|
INSERT INTO repositories (id, gitea_id, owner, name, full_name, clone_url, default_branch)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||||
|
ON CONFLICT (gitea_id) DO UPDATE SET
|
||||||
|
owner=EXCLUDED.owner, name=EXCLUDED.name, full_name=EXCLUDED.full_name,
|
||||||
|
clone_url=EXCLUDED.clone_url, default_branch=EXCLUDED.default_branch, updated_at=now()
|
||||||
|
RETURNING id, gitea_id, owner, name, full_name, clone_url, default_branch, created_at, updated_at`,
|
||||||
|
id, repository.ID, owner, repository.Name, fullName, repository.CloneURL, repository.DefaultBranch,
|
||||||
|
).Scan(&saved.ID, &saved.GiteaID, &saved.Owner, &saved.Name, &saved.FullName, &saved.CloneURL, &saved.DefaultBranch, &saved.CreatedAt, &saved.UpdatedAt)
|
||||||
|
return saved, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Postgres) RecordWebhook(ctx context.Context, delivery WebhookDelivery) (bool, error) {
|
||||||
|
tx, err := p.pool.Begin(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
defer func() { _ = tx.Rollback(ctx) }()
|
||||||
|
|
||||||
|
result, err := tx.Exec(ctx, `INSERT INTO webhook_deliveries (delivery_id, event, event_type, payload_sha256) VALUES ($1,$2,$3,$4) ON CONFLICT DO NOTHING`, delivery.DeliveryID, delivery.Event, delivery.EventType, delivery.PayloadSHA256)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
if result.RowsAffected() == 0 {
|
||||||
|
var existingEvent, existingEventType, existingDigest string
|
||||||
|
if err := tx.QueryRow(ctx, `SELECT event, event_type, payload_sha256 FROM webhook_deliveries WHERE delivery_id=$1`, delivery.DeliveryID).Scan(&existingEvent, &existingEventType, &existingDigest); err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
if existingEvent != delivery.Event || existingEventType != delivery.EventType || existingDigest != delivery.PayloadSHA256 {
|
||||||
|
return false, fmt.Errorf("webhook delivery ID %q was reused with different content", delivery.DeliveryID)
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
if delivery.Repository != nil && delivery.Repository.ID > 0 {
|
||||||
|
id, err := ids.Repository()
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
if _, err := upsertRepository(ctx, tx, id, *delivery.Repository); err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := tx.Commit(ctx); err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ Store = (*Postgres)(nil)
|
||||||
Reference in New Issue
Block a user