phase 2 initial test
This commit is contained in:
+26
@@ -0,0 +1,26 @@
|
|||||||
|
# ── Stage 1: Build frontend ───────────────────────────────────────────────────
|
||||||
|
FROM node:22-alpine AS frontend-builder
|
||||||
|
WORKDIR /app/frontend
|
||||||
|
RUN npm install -g pnpm
|
||||||
|
COPY frontend/package.json frontend/pnpm-lock.yaml ./
|
||||||
|
RUN pnpm install --frozen-lockfile
|
||||||
|
COPY frontend/ ./
|
||||||
|
RUN pnpm build
|
||||||
|
|
||||||
|
# ── Stage 2: Build Go binary ──────────────────────────────────────────────────
|
||||||
|
FROM golang:1.24-alpine AS go-builder
|
||||||
|
RUN apk add --no-cache git
|
||||||
|
WORKDIR /app
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
RUN go mod download
|
||||||
|
COPY . .
|
||||||
|
COPY --from=frontend-builder /app/frontend/dist ./web/dist
|
||||||
|
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /forgebucket ./cmd/forgebucket
|
||||||
|
|
||||||
|
# ── Stage 3: Minimal runtime ──────────────────────────────────────────────────
|
||||||
|
FROM alpine:3.21
|
||||||
|
RUN apk add --no-cache git ca-certificates tzdata
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=go-builder /forgebucket ./forgebucket
|
||||||
|
EXPOSE 8080
|
||||||
|
ENTRYPOINT ["./forgebucket"]
|
||||||
+20
-7
@@ -16,29 +16,42 @@ import (
|
|||||||
|
|
||||||
"github.com/forgeo/forgebucket/internal/api"
|
"github.com/forgeo/forgebucket/internal/api"
|
||||||
"github.com/forgeo/forgebucket/internal/config"
|
"github.com/forgeo/forgebucket/internal/config"
|
||||||
|
"github.com/forgeo/forgebucket/internal/db"
|
||||||
|
gitdomain "github.com/forgeo/forgebucket/internal/domain/git"
|
||||||
|
"github.com/forgeo/forgebucket/internal/models/migrations"
|
||||||
"github.com/forgeo/forgebucket/web"
|
"github.com/forgeo/forgebucket/web"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
// Load .env if present (dev convenience; production uses real env vars)
|
|
||||||
_ = godotenv.Load()
|
_ = godotenv.Load()
|
||||||
|
|
||||||
cfg, err := config.Load()
|
cfg, err := config.Load()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("config error: %v", err)
|
log.Fatalf("config: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
engine, err := db.Open(cfg.DatabaseURL, cfg.Debug)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("database: %v", err)
|
||||||
|
}
|
||||||
|
defer engine.Close()
|
||||||
|
|
||||||
|
if err := migrations.Run(engine); err != nil {
|
||||||
|
log.Fatalf("migrations: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
gitdomain.SetRepoRoot(cfg.RepoRoot)
|
||||||
|
|
||||||
store := sessions.NewCookieStore([]byte(cfg.SessionSecret))
|
store := sessions.NewCookieStore([]byte(cfg.SessionSecret))
|
||||||
store.Options = &sessions.Options{
|
store.Options = &sessions.Options{
|
||||||
Path: "/",
|
Path: "/",
|
||||||
MaxAge: 86400 * 7, // 7 days
|
MaxAge: 86400 * 7,
|
||||||
HttpOnly: true,
|
HttpOnly: true,
|
||||||
Secure: !cfg.Debug,
|
Secure: !cfg.Debug,
|
||||||
SameSite: http.SameSiteLaxMode,
|
SameSite: http.SameSiteLaxMode,
|
||||||
}
|
}
|
||||||
|
|
||||||
staticFS := web.FS()
|
handler := api.New(cfg, engine, store, web.FS())
|
||||||
handler := api.New(cfg, store, staticFS)
|
|
||||||
|
|
||||||
srv := &http.Server{
|
srv := &http.Server{
|
||||||
Addr: fmt.Sprintf(":%s", cfg.Port),
|
Addr: fmt.Sprintf(":%s", cfg.Port),
|
||||||
@@ -51,7 +64,7 @@ func main() {
|
|||||||
go func() {
|
go func() {
|
||||||
log.Printf("ForgeBucket listening on http://localhost:%s", cfg.Port)
|
log.Printf("ForgeBucket listening on http://localhost:%s", cfg.Port)
|
||||||
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||||
log.Fatalf("server error: %v", err)
|
log.Fatalf("server: %v", err)
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
@@ -62,7 +75,7 @@ func main() {
|
|||||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
if err := srv.Shutdown(ctx); err != nil {
|
if err := srv.Shutdown(ctx); err != nil {
|
||||||
log.Printf("shutdown error: %v", err)
|
log.Printf("shutdown: %v", err)
|
||||||
}
|
}
|
||||||
log.Println("ForgeBucket stopped")
|
log.Println("ForgeBucket stopped")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
|
||||||
|
"github.com/joho/godotenv"
|
||||||
|
|
||||||
|
"github.com/forgeo/forgebucket/internal/config"
|
||||||
|
"github.com/forgeo/forgebucket/internal/db"
|
||||||
|
"github.com/forgeo/forgebucket/internal/models/migrations"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
_ = godotenv.Load()
|
||||||
|
|
||||||
|
cfg, err := config.Load()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
engine, err := db.Open(cfg.DatabaseURL, cfg.Debug)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("db: %v", err)
|
||||||
|
}
|
||||||
|
defer engine.Close()
|
||||||
|
|
||||||
|
log.Println("Running migrations...")
|
||||||
|
if err := migrations.Run(engine); err != nil {
|
||||||
|
log.Fatalf("migrate: %v", err)
|
||||||
|
}
|
||||||
|
log.Println("Migrations complete.")
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
version: "3.9"
|
||||||
|
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: forgebucket
|
||||||
|
POSTGRES_USER: forgebucket
|
||||||
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?required}
|
||||||
|
volumes:
|
||||||
|
- postgres_data:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U forgebucket"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 10
|
||||||
|
|
||||||
|
app:
|
||||||
|
build: .
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
env_file: .env
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: postgres://forgebucket:${POSTGRES_PASSWORD}@postgres:5432/forgebucket?sslmode=disable
|
||||||
|
ports:
|
||||||
|
- "8080:8080"
|
||||||
|
volumes:
|
||||||
|
- repo_data:/var/lib/forgebucket/repos
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres_data:
|
||||||
|
repo_data:
|
||||||
+3
-15
@@ -1,5 +1,8 @@
|
|||||||
version: "3.9"
|
version: "3.9"
|
||||||
|
|
||||||
|
# Dev: only PostgreSQL runs here. Run the Go server locally with `make dev`.
|
||||||
|
# Production: docker compose -f docker-compose.prod.yml up
|
||||||
|
|
||||||
services:
|
services:
|
||||||
postgres:
|
postgres:
|
||||||
image: postgres:16-alpine
|
image: postgres:16-alpine
|
||||||
@@ -18,20 +21,5 @@ services:
|
|||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 10
|
retries: 10
|
||||||
|
|
||||||
app:
|
|
||||||
build: .
|
|
||||||
restart: unless-stopped
|
|
||||||
depends_on:
|
|
||||||
postgres:
|
|
||||||
condition: service_healthy
|
|
||||||
env_file: .env
|
|
||||||
environment:
|
|
||||||
DATABASE_URL: postgres://forgebucket:password@postgres:5432/forgebucket?sslmode=disable
|
|
||||||
ports:
|
|
||||||
- "8080:8080"
|
|
||||||
volumes:
|
|
||||||
- repo_data:/var/lib/forgebucket/repos
|
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
postgres_data:
|
postgres_data:
|
||||||
repo_data:
|
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
func jsonError(w http.ResponseWriter, message string, status int) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(status)
|
||||||
|
json.NewEncoder(w).Encode(map[string]string{"error": message})
|
||||||
|
}
|
||||||
|
|
||||||
|
func jsonOK(w http.ResponseWriter, v any) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(v)
|
||||||
|
}
|
||||||
@@ -1,22 +1,53 @@
|
|||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"xorm.io/xorm"
|
||||||
|
|
||||||
|
"github.com/forgeo/forgebucket/internal/models"
|
||||||
)
|
)
|
||||||
|
|
||||||
type PipelineHandler struct{}
|
type PipelineHandler struct {
|
||||||
|
db *xorm.Engine
|
||||||
|
}
|
||||||
|
|
||||||
func NewPipelineHandler() *PipelineHandler {
|
func NewPipelineHandler(db *xorm.Engine) *PipelineHandler {
|
||||||
return &PipelineHandler{}
|
return &PipelineHandler{db: db}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *PipelineHandler) List(w http.ResponseWriter, r *http.Request) {
|
func (h *PipelineHandler) List(w http.ResponseWriter, r *http.Request) {
|
||||||
w.Header().Set("Content-Type", "application/json")
|
ownerName := chi.URLParam(r, "owner")
|
||||||
json.NewEncoder(w).Encode([]any{})
|
repoName := chi.URLParam(r, "repo")
|
||||||
|
|
||||||
|
repoID, ok := h.repoID(w, ownerName, repoName)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = repoID
|
||||||
|
// Pipeline records will be added in Phase 3 (CI integration).
|
||||||
|
// Return empty list so the client doesn't break.
|
||||||
|
jsonOK(w, []any{})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *PipelineHandler) Get(w http.ResponseWriter, r *http.Request) {
|
func (h *PipelineHandler) Get(w http.ResponseWriter, r *http.Request) {
|
||||||
w.Header().Set("Content-Type", "application/json")
|
jsonError(w, "not implemented", http.StatusNotImplemented)
|
||||||
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
|
}
|
||||||
|
|
||||||
|
func (h *PipelineHandler) repoID(w http.ResponseWriter, ownerName, repoName string) (int64, bool) {
|
||||||
|
var owner models.User
|
||||||
|
found, err := h.db.Where("username = ?", ownerName).Get(&owner)
|
||||||
|
if err != nil || !found {
|
||||||
|
jsonError(w, "repository not found", http.StatusNotFound)
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
|
||||||
|
var repo models.Repository
|
||||||
|
found, err = h.db.Where("owner_id = ? AND name = ?", owner.ID, repoName).Get(&repo)
|
||||||
|
if err != nil || !found {
|
||||||
|
jsonError(w, "repository not found", http.StatusNotFound)
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
return repo.ID, true
|
||||||
}
|
}
|
||||||
|
|||||||
+152
-10
@@ -3,31 +3,173 @@ package handlers
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"xorm.io/xorm"
|
||||||
|
|
||||||
|
"github.com/forgeo/forgebucket/internal/api/middleware"
|
||||||
|
"github.com/forgeo/forgebucket/internal/models"
|
||||||
)
|
)
|
||||||
|
|
||||||
type PRHandler struct{}
|
type PRHandler struct {
|
||||||
|
db *xorm.Engine
|
||||||
|
}
|
||||||
|
|
||||||
func NewPRHandler() *PRHandler {
|
func NewPRHandler(db *xorm.Engine) *PRHandler {
|
||||||
return &PRHandler{}
|
return &PRHandler{db: db}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *PRHandler) List(w http.ResponseWriter, r *http.Request) {
|
func (h *PRHandler) List(w http.ResponseWriter, r *http.Request) {
|
||||||
w.Header().Set("Content-Type", "application/json")
|
repoID, ok := h.repoIDFromURL(w, r)
|
||||||
json.NewEncoder(w).Encode([]any{})
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
status := r.URL.Query().Get("status")
|
||||||
|
|
||||||
|
sess := h.db.Where("repo_id = ?", repoID)
|
||||||
|
if status != "" {
|
||||||
|
sess = sess.And("status = ?", status)
|
||||||
|
}
|
||||||
|
|
||||||
|
var prs []models.PullRequest
|
||||||
|
if err := sess.Find(&prs); err != nil {
|
||||||
|
jsonError(w, "could not list pull requests", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if prs == nil {
|
||||||
|
prs = []models.PullRequest{}
|
||||||
|
}
|
||||||
|
jsonOK(w, prs)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *PRHandler) Create(w http.ResponseWriter, r *http.Request) {
|
func (h *PRHandler) Create(w http.ResponseWriter, r *http.Request) {
|
||||||
|
repoID, ok := h.repoIDFromURL(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
authorID, _ := middleware.UserIDFromContext(r.Context())
|
||||||
|
|
||||||
|
var body struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
Body string `json:"body"`
|
||||||
|
SourceBranch string `json:"sourceBranch"`
|
||||||
|
TargetBranch string `json:"targetBranch"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
|
jsonError(w, "invalid request body", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if body.Title == "" || body.SourceBranch == "" {
|
||||||
|
jsonError(w, "title and sourceBranch are required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if body.TargetBranch == "" {
|
||||||
|
body.TargetBranch = "main"
|
||||||
|
}
|
||||||
|
|
||||||
|
pr := &models.PullRequest{
|
||||||
|
RepoID: repoID,
|
||||||
|
AuthorID: authorID,
|
||||||
|
Title: body.Title,
|
||||||
|
Body: body.Body,
|
||||||
|
SourceBranch: body.SourceBranch,
|
||||||
|
TargetBranch: body.TargetBranch,
|
||||||
|
Status: models.PRStatusOpen,
|
||||||
|
}
|
||||||
|
if _, err := h.db.Insert(pr); err != nil {
|
||||||
|
jsonError(w, "could not create pull request", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
w.WriteHeader(http.StatusCreated)
|
w.WriteHeader(http.StatusCreated)
|
||||||
json.NewEncoder(w).Encode(map[string]string{"status": "created"})
|
json.NewEncoder(w).Encode(pr)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *PRHandler) Get(w http.ResponseWriter, r *http.Request) {
|
func (h *PRHandler) Get(w http.ResponseWriter, r *http.Request) {
|
||||||
w.Header().Set("Content-Type", "application/json")
|
pr, ok := h.lookupPR(w, r)
|
||||||
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
jsonOK(w, pr)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *PRHandler) Merge(w http.ResponseWriter, r *http.Request) {
|
func (h *PRHandler) Merge(w http.ResponseWriter, r *http.Request) {
|
||||||
w.Header().Set("Content-Type", "application/json")
|
pr, ok := h.lookupPR(w, r)
|
||||||
json.NewEncoder(w).Encode(map[string]string{"status": "merged"})
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if pr.Status != models.PRStatusOpen {
|
||||||
|
jsonError(w, "pull request is not open", http.StatusConflict)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
pr.Status = models.PRStatusMerged
|
||||||
|
if _, err := h.db.ID(pr.ID).Cols("status").Update(pr); err != nil {
|
||||||
|
jsonError(w, "could not merge pull request", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
jsonOK(w, pr)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *PRHandler) Close(w http.ResponseWriter, r *http.Request) {
|
||||||
|
pr, ok := h.lookupPR(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if pr.Status != models.PRStatusOpen {
|
||||||
|
jsonError(w, "pull request is not open", http.StatusConflict)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
pr.Status = models.PRStatusClosed
|
||||||
|
if _, err := h.db.ID(pr.ID).Cols("status").Update(pr); err != nil {
|
||||||
|
jsonError(w, "could not close pull request", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
jsonOK(w, pr)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *PRHandler) repoIDFromURL(w http.ResponseWriter, r *http.Request) (int64, bool) {
|
||||||
|
ownerName := chi.URLParam(r, "owner")
|
||||||
|
repoName := chi.URLParam(r, "repo")
|
||||||
|
|
||||||
|
var owner models.User
|
||||||
|
found, err := h.db.Where("username = ?", ownerName).Get(&owner)
|
||||||
|
if err != nil || !found {
|
||||||
|
jsonError(w, "repository not found", http.StatusNotFound)
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
|
||||||
|
var repo models.Repository
|
||||||
|
found, err = h.db.Where("owner_id = ? AND name = ?", owner.ID, repoName).Get(&repo)
|
||||||
|
if err != nil || !found {
|
||||||
|
jsonError(w, "repository not found", http.StatusNotFound)
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
return repo.ID, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *PRHandler) lookupPR(w http.ResponseWriter, r *http.Request) (*models.PullRequest, bool) {
|
||||||
|
repoID, ok := h.repoIDFromURL(w, r)
|
||||||
|
if !ok {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
prIDStr := chi.URLParam(r, "prID")
|
||||||
|
prID, err := strconv.ParseInt(prIDStr, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
jsonError(w, "invalid pull request ID", http.StatusBadRequest)
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
var pr models.PullRequest
|
||||||
|
found, err := h.db.Where("id = ? AND repo_id = ?", prID, repoID).Get(&pr)
|
||||||
|
if err != nil || !found {
|
||||||
|
jsonError(w, "pull request not found", http.StatusNotFound)
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
return &pr, true
|
||||||
}
|
}
|
||||||
|
|||||||
+175
-12
@@ -3,36 +3,199 @@ package handlers
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"xorm.io/xorm"
|
||||||
|
|
||||||
|
"github.com/forgeo/forgebucket/internal/api/middleware"
|
||||||
|
"github.com/forgeo/forgebucket/internal/config"
|
||||||
|
gitdomain "github.com/forgeo/forgebucket/internal/domain/git"
|
||||||
|
"github.com/forgeo/forgebucket/internal/models"
|
||||||
)
|
)
|
||||||
|
|
||||||
type RepoHandler struct{}
|
type RepoHandler struct {
|
||||||
|
db *xorm.Engine
|
||||||
|
cfg *config.Config
|
||||||
|
}
|
||||||
|
|
||||||
func NewRepoHandler() *RepoHandler {
|
func NewRepoHandler(db *xorm.Engine, cfg *config.Config) *RepoHandler {
|
||||||
return &RepoHandler{}
|
return &RepoHandler{db: db, cfg: cfg}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *RepoHandler) List(w http.ResponseWriter, r *http.Request) {
|
func (h *RepoHandler) List(w http.ResponseWriter, r *http.Request) {
|
||||||
w.Header().Set("Content-Type", "application/json")
|
userID, _ := middleware.UserIDFromContext(r.Context())
|
||||||
json.NewEncoder(w).Encode([]any{})
|
|
||||||
|
var repos []models.Repository
|
||||||
|
if err := h.db.Where("owner_id = ?", userID).Find(&repos); err != nil {
|
||||||
|
jsonError(w, "could not list repositories", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if repos == nil {
|
||||||
|
repos = []models.Repository{}
|
||||||
|
}
|
||||||
|
jsonOK(w, repos)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *RepoHandler) Create(w http.ResponseWriter, r *http.Request) {
|
func (h *RepoHandler) Create(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID, _ := middleware.UserIDFromContext(r.Context())
|
||||||
|
|
||||||
|
var body struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
IsPrivate bool `json:"isPrivate"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
|
jsonError(w, "invalid request body", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if body.Name == "" {
|
||||||
|
jsonError(w, "name is required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
diskPath := filepath.Join(h.cfg.RepoRoot, strconv.FormatInt(userID, 10), body.Name+".git")
|
||||||
|
|
||||||
|
repo := &models.Repository{
|
||||||
|
OwnerID: userID,
|
||||||
|
Name: body.Name,
|
||||||
|
Description: body.Description,
|
||||||
|
IsPrivate: body.IsPrivate,
|
||||||
|
DefaultBranch: "main",
|
||||||
|
DiskPath: diskPath,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialise bare repo on disk before inserting to DB
|
||||||
|
gitdomain.SetRepoRoot(h.cfg.RepoRoot)
|
||||||
|
if err := gitdomain.Init(diskPath); err != nil {
|
||||||
|
jsonError(w, "could not initialise git repository", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := h.db.Insert(repo); err != nil {
|
||||||
|
jsonError(w, "repository name already taken", http.StatusConflict)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
w.WriteHeader(http.StatusCreated)
|
w.WriteHeader(http.StatusCreated)
|
||||||
json.NewEncoder(w).Encode(map[string]string{"status": "created"})
|
json.NewEncoder(w).Encode(repo)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *RepoHandler) Get(w http.ResponseWriter, r *http.Request) {
|
func (h *RepoHandler) Get(w http.ResponseWriter, r *http.Request) {
|
||||||
w.Header().Set("Content-Type", "application/json")
|
repo, ok := h.lookupRepo(w, r)
|
||||||
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
jsonOK(w, repo)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *RepoHandler) Tree(w http.ResponseWriter, r *http.Request) {
|
func (h *RepoHandler) Tree(w http.ResponseWriter, r *http.Request) {
|
||||||
w.Header().Set("Content-Type", "application/json")
|
repo, ok := h.lookupRepo(w, r)
|
||||||
json.NewEncoder(w).Encode([]any{})
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ref := r.URL.Query().Get("ref")
|
||||||
|
if ref == "" {
|
||||||
|
ref = repo.DefaultBranch
|
||||||
|
}
|
||||||
|
subPath := r.URL.Query().Get("path")
|
||||||
|
|
||||||
|
gitdomain.SetRepoRoot(h.cfg.RepoRoot)
|
||||||
|
entries, err := gitdomain.TreeLS(repo.DiskPath, ref, subPath)
|
||||||
|
if err != nil {
|
||||||
|
jsonError(w, "could not read tree", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if entries == nil {
|
||||||
|
entries = []gitdomain.TreeEntry{}
|
||||||
|
}
|
||||||
|
jsonOK(w, entries)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *RepoHandler) Blob(w http.ResponseWriter, r *http.Request) {
|
func (h *RepoHandler) Blob(w http.ResponseWriter, r *http.Request) {
|
||||||
w.Header().Set("Content-Type", "application/json")
|
repo, ok := h.lookupRepo(w, r)
|
||||||
json.NewEncoder(w).Encode(map[string]string{"content": ""})
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ref := r.URL.Query().Get("ref")
|
||||||
|
if ref == "" {
|
||||||
|
ref = repo.DefaultBranch
|
||||||
|
}
|
||||||
|
path := r.URL.Query().Get("path")
|
||||||
|
if path == "" {
|
||||||
|
jsonError(w, "path is required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
gitdomain.SetRepoRoot(h.cfg.RepoRoot)
|
||||||
|
content, err := gitdomain.BlobCat(repo.DiskPath, ref, path)
|
||||||
|
if err != nil {
|
||||||
|
jsonError(w, "file not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
jsonOK(w, map[string]string{"content": string(content), "path": path, "ref": ref})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *RepoHandler) Commits(w http.ResponseWriter, r *http.Request) {
|
||||||
|
repo, ok := h.lookupRepo(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ref := r.URL.Query().Get("ref")
|
||||||
|
if ref == "" {
|
||||||
|
ref = repo.DefaultBranch
|
||||||
|
}
|
||||||
|
limit := 30
|
||||||
|
if l := r.URL.Query().Get("limit"); l != "" {
|
||||||
|
if n, err := strconv.Atoi(l); err == nil && n > 0 && n <= 100 {
|
||||||
|
limit = n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
gitdomain.SetRepoRoot(h.cfg.RepoRoot)
|
||||||
|
commits, err := gitdomain.Log(repo.DiskPath, ref, limit)
|
||||||
|
if err != nil {
|
||||||
|
jsonError(w, "could not read commits", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if commits == nil {
|
||||||
|
commits = []gitdomain.Commit{}
|
||||||
|
}
|
||||||
|
jsonOK(w, commits)
|
||||||
|
}
|
||||||
|
|
||||||
|
// lookupRepo resolves {owner}/{repo} URL params to a DB row, enforcing access.
|
||||||
|
func (h *RepoHandler) lookupRepo(w http.ResponseWriter, r *http.Request) (*models.Repository, bool) {
|
||||||
|
ownerName := chi.URLParam(r, "owner")
|
||||||
|
repoName := chi.URLParam(r, "repo")
|
||||||
|
|
||||||
|
var owner models.User
|
||||||
|
found, err := h.db.Where("username = ?", ownerName).Get(&owner)
|
||||||
|
if err != nil || !found {
|
||||||
|
jsonError(w, "repository not found", http.StatusNotFound)
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
var repo models.Repository
|
||||||
|
found, err = h.db.Where("owner_id = ? AND name = ?", owner.ID, repoName).Get(&repo)
|
||||||
|
if err != nil || !found {
|
||||||
|
jsonError(w, "repository not found", http.StatusNotFound)
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Private repo: only the owner may access (RBAC will be expanded in Phase 3)
|
||||||
|
if repo.IsPrivate {
|
||||||
|
callerID, _ := middleware.UserIDFromContext(r.Context())
|
||||||
|
if callerID != repo.OwnerID {
|
||||||
|
jsonError(w, "repository not found", http.StatusNotFound)
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &repo, true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,24 +5,91 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/gorilla/sessions"
|
"github.com/gorilla/sessions"
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
"xorm.io/xorm"
|
||||||
|
|
||||||
|
"github.com/forgeo/forgebucket/internal/api/middleware"
|
||||||
|
"github.com/forgeo/forgebucket/internal/models"
|
||||||
)
|
)
|
||||||
|
|
||||||
type UserHandler struct {
|
type UserHandler struct {
|
||||||
|
db *xorm.Engine
|
||||||
store sessions.Store
|
store sessions.Store
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewUserHandler(store sessions.Store) *UserHandler {
|
func NewUserHandler(db *xorm.Engine, store sessions.Store) *UserHandler {
|
||||||
return &UserHandler{store: store}
|
return &UserHandler{db: db, store: store}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *UserHandler) Me(w http.ResponseWriter, r *http.Request) {
|
func (h *UserHandler) Register(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var body struct {
|
||||||
|
Username string `json:"username"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
|
jsonError(w, "invalid request body", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if body.Username == "" || body.Email == "" || body.Password == "" {
|
||||||
|
jsonError(w, "username, email, and password are required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
hash, err := bcrypt.GenerateFromPassword([]byte(body.Password), bcrypt.DefaultCost)
|
||||||
|
if err != nil {
|
||||||
|
jsonError(w, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
user := &models.User{
|
||||||
|
Username: body.Username,
|
||||||
|
Email: body.Email,
|
||||||
|
PasswordHash: string(hash),
|
||||||
|
}
|
||||||
|
if _, err := h.db.Insert(user); err != nil {
|
||||||
|
jsonError(w, "username or email already taken", http.StatusConflict)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
json.NewEncoder(w).Encode(user)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *UserHandler) Login(w http.ResponseWriter, r *http.Request) {
|
func (h *UserHandler) Login(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var body struct {
|
||||||
|
Username string `json:"username"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
|
jsonError(w, "invalid request body", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var user models.User
|
||||||
|
found, err := h.db.Where("username = ?", body.Username).Get(&user)
|
||||||
|
if err != nil || !found {
|
||||||
|
jsonError(w, "invalid credentials", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(body.Password)); err != nil {
|
||||||
|
jsonError(w, "invalid credentials", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
session, _ := h.store.Get(r, "fb_session")
|
||||||
|
session.Values["userID"] = user.ID
|
||||||
|
session.Values["username"] = user.Username
|
||||||
|
session.Values["isAdmin"] = user.IsAdmin
|
||||||
|
if err := session.Save(r, w); err != nil {
|
||||||
|
jsonError(w, "could not save session", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
|
json.NewEncoder(w).Encode(user)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *UserHandler) Logout(w http.ResponseWriter, r *http.Request) {
|
func (h *UserHandler) Logout(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -31,3 +98,21 @@ func (h *UserHandler) Logout(w http.ResponseWriter, r *http.Request) {
|
|||||||
session.Save(r, w)
|
session.Save(r, w)
|
||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *UserHandler) Me(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID, ok := middleware.UserIDFromContext(r.Context())
|
||||||
|
if !ok {
|
||||||
|
jsonError(w, "unauthorized", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var user models.User
|
||||||
|
found, err := h.db.ID(userID).Get(&user)
|
||||||
|
if err != nil || !found {
|
||||||
|
jsonError(w, "user not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(user)
|
||||||
|
}
|
||||||
|
|||||||
+16
-19
@@ -9,13 +9,14 @@ import (
|
|||||||
"github.com/go-chi/cors"
|
"github.com/go-chi/cors"
|
||||||
gcsrf "github.com/gorilla/csrf"
|
gcsrf "github.com/gorilla/csrf"
|
||||||
"github.com/gorilla/sessions"
|
"github.com/gorilla/sessions"
|
||||||
|
"xorm.io/xorm"
|
||||||
|
|
||||||
"github.com/forgeo/forgebucket/internal/api/handlers"
|
"github.com/forgeo/forgebucket/internal/api/handlers"
|
||||||
"github.com/forgeo/forgebucket/internal/api/middleware"
|
"github.com/forgeo/forgebucket/internal/api/middleware"
|
||||||
"github.com/forgeo/forgebucket/internal/config"
|
"github.com/forgeo/forgebucket/internal/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
func New(cfg *config.Config, store sessions.Store, staticFiles fs.FS) http.Handler {
|
func New(cfg *config.Config, engine *xorm.Engine, store sessions.Store, staticFiles fs.FS) http.Handler {
|
||||||
r := chi.NewRouter()
|
r := chi.NewRouter()
|
||||||
|
|
||||||
r.Use(chimiddleware.Logger)
|
r.Use(chimiddleware.Logger)
|
||||||
@@ -34,38 +35,39 @@ func New(cfg *config.Config, store sessions.Store, staticFiles fs.FS) http.Handl
|
|||||||
gcsrf.Secure(!cfg.Debug),
|
gcsrf.Secure(!cfg.Debug),
|
||||||
gcsrf.SameSite(gcsrf.SameSiteLaxMode),
|
gcsrf.SameSite(gcsrf.SameSiteLaxMode),
|
||||||
gcsrf.ErrorHandler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
gcsrf.ErrorHandler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
http.Error(w, "CSRF validation failed", http.StatusForbidden)
|
http.Error(w, `{"error":"CSRF validation failed"}`, http.StatusForbidden)
|
||||||
})),
|
})),
|
||||||
)
|
)
|
||||||
|
|
||||||
auth := middleware.NewAuth(store)
|
auth := middleware.NewAuth(store)
|
||||||
|
|
||||||
repoH := handlers.NewRepoHandler()
|
repoH := handlers.NewRepoHandler(engine, cfg)
|
||||||
userH := handlers.NewUserHandler(store)
|
userH := handlers.NewUserHandler(engine, store)
|
||||||
prH := handlers.NewPRHandler()
|
prH := handlers.NewPRHandler(engine)
|
||||||
pipeH := handlers.NewPipelineHandler()
|
pipeH := handlers.NewPipelineHandler(engine)
|
||||||
wsH := handlers.NewWSHandler()
|
wsH := handlers.NewWSHandler()
|
||||||
|
|
||||||
// Health check (no auth, no CSRF)
|
// Health — no auth, no CSRF
|
||||||
r.Get("/api/v1/health", func(w http.ResponseWriter, r *http.Request) {
|
r.Get("/api/v1/health", func(w http.ResponseWriter, r *http.Request) {
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
w.Write([]byte(`{"status":"ok"}`))
|
w.Write([]byte(`{"status":"ok"}`))
|
||||||
})
|
})
|
||||||
|
|
||||||
// CSRF token endpoint for SPA bootstrap
|
// CSRF token bootstrap for SPA
|
||||||
r.With(csrfMiddleware).Get("/api/v1/csrf", func(w http.ResponseWriter, r *http.Request) {
|
r.With(csrfMiddleware).Get("/api/v1/csrf", func(w http.ResponseWriter, r *http.Request) {
|
||||||
w.Header().Set("X-CSRF-Token", gcsrf.Token(r))
|
w.Header().Set("X-CSRF-Token", gcsrf.Token(r))
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
w.Write([]byte(`{"ok":true}`))
|
w.Write([]byte(`{"ok":true}`))
|
||||||
})
|
})
|
||||||
|
|
||||||
// Auth routes (CSRF protected, no session required)
|
// Auth (CSRF protected, no session required)
|
||||||
r.With(csrfMiddleware).Route("/api/v1/auth", func(r chi.Router) {
|
r.With(csrfMiddleware).Route("/api/v1/auth", func(r chi.Router) {
|
||||||
r.Post("/login", userH.Login)
|
r.Post("/login", userH.Login)
|
||||||
r.Post("/logout", userH.Logout)
|
r.Post("/logout", userH.Logout)
|
||||||
|
r.Post("/register", userH.Register)
|
||||||
})
|
})
|
||||||
|
|
||||||
// Authenticated API routes
|
// Authenticated API
|
||||||
r.With(csrfMiddleware).With(auth.Require).Route("/api/v1", func(r chi.Router) {
|
r.With(csrfMiddleware).With(auth.Require).Route("/api/v1", func(r chi.Router) {
|
||||||
r.Get("/me", userH.Me)
|
r.Get("/me", userH.Me)
|
||||||
|
|
||||||
@@ -76,11 +78,13 @@ func New(cfg *config.Config, store sessions.Store, staticFiles fs.FS) http.Handl
|
|||||||
r.Get("/", repoH.Get)
|
r.Get("/", repoH.Get)
|
||||||
r.Get("/tree", repoH.Tree)
|
r.Get("/tree", repoH.Tree)
|
||||||
r.Get("/blob", repoH.Blob)
|
r.Get("/blob", repoH.Blob)
|
||||||
|
r.Get("/commits", repoH.Commits)
|
||||||
r.Route("/pulls", func(r chi.Router) {
|
r.Route("/pulls", func(r chi.Router) {
|
||||||
r.Get("/", prH.List)
|
r.Get("/", prH.List)
|
||||||
r.Post("/", prH.Create)
|
r.Post("/", prH.Create)
|
||||||
r.Get("/{prID}", prH.Get)
|
r.Get("/{prID}", prH.Get)
|
||||||
r.Post("/{prID}/merge", prH.Merge)
|
r.Post("/{prID}/merge", prH.Merge)
|
||||||
|
r.Post("/{prID}/close", prH.Close)
|
||||||
})
|
})
|
||||||
r.Route("/pipelines", func(r chi.Router) {
|
r.Route("/pipelines", func(r chi.Router) {
|
||||||
r.Get("/", pipeH.List)
|
r.Get("/", pipeH.List)
|
||||||
@@ -90,10 +94,10 @@ func New(cfg *config.Config, store sessions.Store, staticFiles fs.FS) http.Handl
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
// WebSocket hub (auth via session cookie, no CSRF needed for WS)
|
// WebSocket — session auth only, no CSRF needed for WS upgrades
|
||||||
r.With(auth.Optional).Get("/ws", wsH.Hub)
|
r.With(auth.Optional).Get("/ws", wsH.Hub)
|
||||||
|
|
||||||
// SPA fallback — serve embedded React app for all other routes
|
// SPA fallback
|
||||||
r.Handle("/*", spaHandler(staticFiles))
|
r.Handle("/*", spaHandler(staticFiles))
|
||||||
|
|
||||||
return r
|
return r
|
||||||
@@ -104,13 +108,6 @@ func spaHandler(staticFiles fs.FS) http.Handler {
|
|||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
_, err := staticFiles.Open(r.URL.Path)
|
_, err := staticFiles.Open(r.URL.Path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Unknown path → serve index.html for client-side routing
|
|
||||||
index, err := staticFiles.Open("index.html")
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, "not found", http.StatusNotFound)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
index.Close()
|
|
||||||
r.URL.Path = "/"
|
r.URL.Path = "/"
|
||||||
}
|
}
|
||||||
fileServer.ServeHTTP(w, r)
|
fileServer.ServeHTTP(w, r)
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
_ "github.com/lib/pq"
|
||||||
|
"xorm.io/xorm"
|
||||||
|
"xorm.io/xorm/log"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Open(dataSourceName string, debug bool) (*xorm.Engine, error) {
|
||||||
|
engine, err := xorm.NewEngine("postgres", dataSourceName)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("open db: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if debug {
|
||||||
|
engine.SetLogLevel(log.LOG_DEBUG)
|
||||||
|
} else {
|
||||||
|
engine.SetLogLevel(log.LOG_WARNING)
|
||||||
|
}
|
||||||
|
|
||||||
|
engine.SetMaxOpenConns(25)
|
||||||
|
engine.SetMaxIdleConns(5)
|
||||||
|
|
||||||
|
if err := engine.Ping(); err != nil {
|
||||||
|
return nil, fmt.Errorf("ping db: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return engine, nil
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ package git
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -16,47 +17,69 @@ func SetRepoRoot(root string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// run executes a git command inside repoPath with strict safety guarantees:
|
// run executes a git command inside repoPath with strict safety guarantees:
|
||||||
// - repoPath is validated to be under the configured repoRoot
|
// - repoPath is validated to be under the configured repoRoot (path traversal guard)
|
||||||
// - args are passed as discrete values — never via shell interpolation
|
// - args are passed as discrete values — never via shell interpolation
|
||||||
// - the process inherits only a minimal environment
|
// - the process inherits only a minimal, fixed environment
|
||||||
func run(repoPath string, args ...string) ([]byte, error) {
|
func run(repoPath string, args ...string) ([]byte, error) {
|
||||||
clean := filepath.Clean(repoPath)
|
clean := filepath.Clean(repoPath)
|
||||||
if repoRoot != "" && !strings.HasPrefix(clean, repoRoot+string(filepath.Separator)) && clean != repoRoot {
|
if repoRoot != "" {
|
||||||
return nil, ErrPathTraversal
|
root := repoRoot + string(filepath.Separator)
|
||||||
|
if !strings.HasPrefix(clean+string(filepath.Separator), root) && clean != repoRoot {
|
||||||
|
return nil, ErrPathTraversal
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd := exec.Command("git", args...)
|
cmd := exec.Command("git", args...)
|
||||||
cmd.Dir = clean
|
cmd.Dir = clean
|
||||||
cmd.Env = []string{
|
cmd.Env = []string{
|
||||||
"GIT_TERMINAL_PROMPT=0",
|
"GIT_TERMINAL_PROMPT=0",
|
||||||
"HOME=" + filepath.Dir(repoRoot), // needed for .gitconfig lookups
|
"HOME=/tmp", // prevent .gitconfig side-effects
|
||||||
}
|
}
|
||||||
return cmd.Output()
|
out, err := cmd.Output()
|
||||||
|
if err != nil {
|
||||||
|
if exitErr, ok := err.(*exec.ExitError); ok {
|
||||||
|
return nil, fmt.Errorf("git %s: %w: %s", args[0], err, exitErr.Stderr)
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("git %s: %w", args[0], err)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func Init(path string) error {
|
func Init(path string) error {
|
||||||
_, err := run(path, "init", "--bare")
|
// git init --bare works even if the directory doesn't exist yet
|
||||||
return err
|
cmd := exec.Command("git", "init", "--bare", path)
|
||||||
|
cmd.Env = []string{"GIT_TERMINAL_PROMPT=0", "HOME=/tmp"}
|
||||||
|
if out, err := cmd.CombinedOutput(); err != nil {
|
||||||
|
return fmt.Errorf("git init --bare: %w: %s", err, out)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
type Commit struct {
|
type Commit struct {
|
||||||
Hash string
|
Hash string `json:"hash"`
|
||||||
Author string
|
Author string `json:"author"`
|
||||||
Message string
|
Message string `json:"message"`
|
||||||
Date string
|
Date string `json:"date"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func Log(repoPath, branch string, limit int) ([]Commit, error) {
|
func Log(repoPath, branch string, limit int) ([]Commit, error) {
|
||||||
out, err := run(repoPath, "log", branch,
|
out, err := run(repoPath,
|
||||||
|
"log", branch,
|
||||||
"--format=%H\x1f%an\x1f%s\x1f%ci",
|
"--format=%H\x1f%an\x1f%s\x1f%ci",
|
||||||
"--max-count", strings.TrimSpace(string(rune(limit+'0'))),
|
fmt.Sprintf("--max-count=%d", limit),
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
raw := strings.TrimSpace(string(out))
|
||||||
|
if raw == "" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
var commits []Commit
|
var commits []Commit
|
||||||
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
|
for _, line := range strings.Split(raw, "\n") {
|
||||||
parts := strings.Split(line, "\x1f")
|
parts := strings.SplitN(line, "\x1f", 4)
|
||||||
if len(parts) != 4 {
|
if len(parts) != 4 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -71,18 +94,22 @@ func Log(repoPath, branch string, limit int) ([]Commit, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type TreeEntry struct {
|
type TreeEntry struct {
|
||||||
Mode string
|
Mode string `json:"mode"`
|
||||||
Type string
|
Type string `json:"type"`
|
||||||
Hash string
|
Hash string `json:"hash"`
|
||||||
Name string
|
Name string `json:"name"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func TreeLS(repoPath, ref, subPath string) ([]TreeEntry, error) {
|
func TreeLS(repoPath, ref, subPath string) ([]TreeEntry, error) {
|
||||||
treeRef := ref + ":" + subPath
|
treeRef := ref
|
||||||
|
if subPath != "" {
|
||||||
|
treeRef = ref + ":" + subPath
|
||||||
|
}
|
||||||
out, err := run(repoPath, "ls-tree", treeRef)
|
out, err := run(repoPath, "ls-tree", treeRef)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
var entries []TreeEntry
|
var entries []TreeEntry
|
||||||
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
|
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
|
||||||
if line == "" {
|
if line == "" {
|
||||||
|
|||||||
@@ -3,13 +3,13 @@ package models
|
|||||||
import "time"
|
import "time"
|
||||||
|
|
||||||
type FederationActor struct {
|
type FederationActor struct {
|
||||||
ID int64 `xorm:"pk autoincr"`
|
ID int64 `xorm:"pk autoincr" json:"id"`
|
||||||
UserID int64 `xorm:"notnull unique index"`
|
UserID int64 `xorm:"notnull unique index" json:"userId"`
|
||||||
APID string `xorm:"notnull unique varchar(500)"` // https://instance/users/alice
|
APID string `xorm:"notnull unique varchar(500)" json:"apId"`
|
||||||
InboxURL string `xorm:"notnull varchar(500)"`
|
InboxURL string `xorm:"notnull varchar(500)" json:"inboxUrl"`
|
||||||
OutboxURL string `xorm:"notnull varchar(500)"`
|
OutboxURL string `xorm:"notnull varchar(500)" json:"outboxUrl"`
|
||||||
PublicKey string `xorm:"text notnull"`
|
PublicKey string `xorm:"text notnull" json:"publicKey"`
|
||||||
PrivateKey string `xorm:"text notnull"` // AES-GCM encrypted at rest
|
PrivateKey string `xorm:"text notnull" json:"-"`
|
||||||
CreatedAt time.Time `xorm:"created"`
|
CreatedAt time.Time `xorm:"created" json:"createdAt"`
|
||||||
UpdatedAt time.Time `xorm:"updated"`
|
UpdatedAt time.Time `xorm:"updated" json:"updatedAt"`
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-10
@@ -11,14 +11,14 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type PullRequest struct {
|
type PullRequest struct {
|
||||||
ID int64 `xorm:"pk autoincr"`
|
ID int64 `xorm:"pk autoincr" json:"id"`
|
||||||
RepoID int64 `xorm:"notnull index"`
|
RepoID int64 `xorm:"notnull index" json:"repoId"`
|
||||||
AuthorID int64 `xorm:"notnull index"`
|
AuthorID int64 `xorm:"notnull index" json:"authorId"`
|
||||||
Title string `xorm:"notnull varchar(255)"`
|
Title string `xorm:"notnull varchar(255)" json:"title"`
|
||||||
Body string `xorm:"text"`
|
Body string `xorm:"text" json:"body"`
|
||||||
SourceBranch string `xorm:"notnull varchar(255)"`
|
SourceBranch string `xorm:"notnull varchar(255)" json:"sourceBranch"`
|
||||||
TargetBranch string `xorm:"default 'main' varchar(255)"`
|
TargetBranch string `xorm:"default 'main' varchar(255)" json:"targetBranch"`
|
||||||
Status PRStatus `xorm:"default 'open' varchar(16)"`
|
Status PRStatus `xorm:"default 'open' varchar(16)" json:"status"`
|
||||||
CreatedAt time.Time `xorm:"created"`
|
CreatedAt time.Time `xorm:"created" json:"createdAt"`
|
||||||
UpdatedAt time.Time `xorm:"updated"`
|
UpdatedAt time.Time `xorm:"updated" json:"updatedAt"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,13 +3,13 @@ package models
|
|||||||
import "time"
|
import "time"
|
||||||
|
|
||||||
type Repository struct {
|
type Repository struct {
|
||||||
ID int64 `xorm:"pk autoincr"`
|
ID int64 `xorm:"pk autoincr" json:"id"`
|
||||||
OwnerID int64 `xorm:"notnull index"`
|
OwnerID int64 `xorm:"notnull index" json:"ownerId"`
|
||||||
Name string `xorm:"notnull varchar(100)"`
|
Name string `xorm:"notnull varchar(100)" json:"name"`
|
||||||
Description string `xorm:"varchar(500)"`
|
Description string `xorm:"varchar(500)" json:"description"`
|
||||||
IsPrivate bool `xorm:"default false"`
|
IsPrivate bool `xorm:"default false" json:"isPrivate"`
|
||||||
DefaultBranch string `xorm:"default 'main' varchar(255)"`
|
DefaultBranch string `xorm:"default 'main' varchar(255)" json:"defaultBranch"`
|
||||||
DiskPath string `xorm:"notnull"` // absolute path under REPO_ROOT
|
DiskPath string `xorm:"notnull" json:"-"`
|
||||||
CreatedAt time.Time `xorm:"created"`
|
CreatedAt time.Time `xorm:"created" json:"createdAt"`
|
||||||
UpdatedAt time.Time `xorm:"updated"`
|
UpdatedAt time.Time `xorm:"updated" json:"updatedAt"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,12 +3,12 @@ package models
|
|||||||
import "time"
|
import "time"
|
||||||
|
|
||||||
type User struct {
|
type User struct {
|
||||||
ID int64 `xorm:"pk autoincr"`
|
ID int64 `xorm:"pk autoincr" json:"id"`
|
||||||
Username string `xorm:"unique notnull varchar(64)"`
|
Username string `xorm:"unique notnull varchar(64)" json:"username"`
|
||||||
Email string `xorm:"unique notnull varchar(255)"`
|
Email string `xorm:"unique notnull varchar(255)" json:"email"`
|
||||||
PasswordHash string `xorm:"notnull"`
|
PasswordHash string `xorm:"notnull" json:"-"`
|
||||||
AvatarURL string `xorm:"varchar(500)"`
|
AvatarURL string `xorm:"varchar(500)"`
|
||||||
IsAdmin bool `xorm:"default false"`
|
IsAdmin bool `xorm:"default false" json:"isAdmin"`
|
||||||
CreatedAt time.Time `xorm:"created"`
|
CreatedAt time.Time `xorm:"created" json:"createdAt"`
|
||||||
UpdatedAt time.Time `xorm:"updated"`
|
UpdatedAt time.Time `xorm:"updated" json:"updatedAt"`
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user