diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..e83dcc5 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/cmd/forgebucket/main.go b/cmd/forgebucket/main.go index d114d34..6dbd69f 100644 --- a/cmd/forgebucket/main.go +++ b/cmd/forgebucket/main.go @@ -16,29 +16,42 @@ import ( "github.com/forgeo/forgebucket/internal/api" "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" ) func main() { - // Load .env if present (dev convenience; production uses real env vars) _ = godotenv.Load() cfg, err := config.Load() 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.Options = &sessions.Options{ Path: "/", - MaxAge: 86400 * 7, // 7 days + MaxAge: 86400 * 7, HttpOnly: true, Secure: !cfg.Debug, SameSite: http.SameSiteLaxMode, } - staticFS := web.FS() - handler := api.New(cfg, store, staticFS) + handler := api.New(cfg, engine, store, web.FS()) srv := &http.Server{ Addr: fmt.Sprintf(":%s", cfg.Port), @@ -51,7 +64,7 @@ func main() { go func() { log.Printf("ForgeBucket listening on http://localhost:%s", cfg.Port) 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) defer cancel() if err := srv.Shutdown(ctx); err != nil { - log.Printf("shutdown error: %v", err) + log.Printf("shutdown: %v", err) } log.Println("ForgeBucket stopped") } diff --git a/cmd/migrate/main.go b/cmd/migrate/main.go new file mode 100644 index 0000000..10dd660 --- /dev/null +++ b/cmd/migrate/main.go @@ -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.") +} diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml new file mode 100644 index 0000000..d06dde2 --- /dev/null +++ b/docker-compose.prod.yml @@ -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: diff --git a/docker-compose.yml b/docker-compose.yml index 2fff948..dfaddf3 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,5 +1,8 @@ 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: postgres: image: postgres:16-alpine @@ -18,20 +21,5 @@ services: timeout: 5s 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: postgres_data: - repo_data: diff --git a/internal/api/handlers/helpers.go b/internal/api/handlers/helpers.go new file mode 100644 index 0000000..224d4a0 --- /dev/null +++ b/internal/api/handlers/helpers.go @@ -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) +} diff --git a/internal/api/handlers/pipelines.go b/internal/api/handlers/pipelines.go index 470a4c3..84713bd 100644 --- a/internal/api/handlers/pipelines.go +++ b/internal/api/handlers/pipelines.go @@ -1,22 +1,53 @@ package handlers import ( - "encoding/json" "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 { - return &PipelineHandler{} +func NewPipelineHandler(db *xorm.Engine) *PipelineHandler { + return &PipelineHandler{db: db} } func (h *PipelineHandler) List(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode([]any{}) + ownerName := chi.URLParam(r, "owner") + 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) { - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) + jsonError(w, "not implemented", http.StatusNotImplemented) +} + +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 } diff --git a/internal/api/handlers/prs.go b/internal/api/handlers/prs.go index 5a26460..f05e29e 100644 --- a/internal/api/handlers/prs.go +++ b/internal/api/handlers/prs.go @@ -3,31 +3,173 @@ package handlers import ( "encoding/json" "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 { - return &PRHandler{} +func NewPRHandler(db *xorm.Engine) *PRHandler { + return &PRHandler{db: db} } func (h *PRHandler) List(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode([]any{}) + repoID, ok := h.repoIDFromURL(w, r) + 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) { + 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.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) { - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) + pr, ok := h.lookupPR(w, r) + if !ok { + return + } + jsonOK(w, pr) } func (h *PRHandler) Merge(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]string{"status": "merged"}) + 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.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 } diff --git a/internal/api/handlers/repos.go b/internal/api/handlers/repos.go index 5fe6d45..4da90e7 100644 --- a/internal/api/handlers/repos.go +++ b/internal/api/handlers/repos.go @@ -3,36 +3,199 @@ package handlers import ( "encoding/json" "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 { - return &RepoHandler{} +func NewRepoHandler(db *xorm.Engine, cfg *config.Config) *RepoHandler { + return &RepoHandler{db: db, cfg: cfg} } func (h *RepoHandler) List(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode([]any{}) + userID, _ := middleware.UserIDFromContext(r.Context()) + + 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) { + 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.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) { - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) + repo, ok := h.lookupRepo(w, r) + if !ok { + return + } + jsonOK(w, repo) } func (h *RepoHandler) Tree(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode([]any{}) + repo, ok := h.lookupRepo(w, r) + 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) { - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]string{"content": ""}) + repo, ok := h.lookupRepo(w, r) + 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 } diff --git a/internal/api/handlers/users.go b/internal/api/handlers/users.go index e271ab2..89e9fde 100644 --- a/internal/api/handlers/users.go +++ b/internal/api/handlers/users.go @@ -5,24 +5,91 @@ import ( "net/http" "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 { + db *xorm.Engine store sessions.Store } -func NewUserHandler(store sessions.Store) *UserHandler { - return &UserHandler{store: store} +func NewUserHandler(db *xorm.Engine, store sessions.Store) *UserHandler { + 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") - 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) { + 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") - json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) + json.NewEncoder(w).Encode(user) } 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) 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) +} diff --git a/internal/api/router.go b/internal/api/router.go index c16afc5..561dbe7 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -9,13 +9,14 @@ import ( "github.com/go-chi/cors" gcsrf "github.com/gorilla/csrf" "github.com/gorilla/sessions" + "xorm.io/xorm" "github.com/forgeo/forgebucket/internal/api/handlers" "github.com/forgeo/forgebucket/internal/api/middleware" "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.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.SameSite(gcsrf.SameSiteLaxMode), 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) - repoH := handlers.NewRepoHandler() - userH := handlers.NewUserHandler(store) - prH := handlers.NewPRHandler() - pipeH := handlers.NewPipelineHandler() + repoH := handlers.NewRepoHandler(engine, cfg) + userH := handlers.NewUserHandler(engine, store) + prH := handlers.NewPRHandler(engine) + pipeH := handlers.NewPipelineHandler(engine) 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) { w.Header().Set("Content-Type", "application/json") 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) { w.Header().Set("X-CSRF-Token", gcsrf.Token(r)) w.Header().Set("Content-Type", "application/json") 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.Post("/login", userH.Login) 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.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("/tree", repoH.Tree) r.Get("/blob", repoH.Blob) + r.Get("/commits", repoH.Commits) r.Route("/pulls", func(r chi.Router) { r.Get("/", prH.List) r.Post("/", prH.Create) r.Get("/{prID}", prH.Get) r.Post("/{prID}/merge", prH.Merge) + r.Post("/{prID}/close", prH.Close) }) r.Route("/pipelines", func(r chi.Router) { 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) - // SPA fallback — serve embedded React app for all other routes + // SPA fallback r.Handle("/*", spaHandler(staticFiles)) return r @@ -104,13 +108,6 @@ func spaHandler(staticFiles fs.FS) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _, err := staticFiles.Open(r.URL.Path) 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 = "/" } fileServer.ServeHTTP(w, r) diff --git a/internal/db/db.go b/internal/db/db.go new file mode 100644 index 0000000..8031c9d --- /dev/null +++ b/internal/db/db.go @@ -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 +} diff --git a/internal/domain/git/binary.go b/internal/domain/git/binary.go index fd380a2..3a1f863 100644 --- a/internal/domain/git/binary.go +++ b/internal/domain/git/binary.go @@ -2,6 +2,7 @@ package git import ( "errors" + "fmt" "os/exec" "path/filepath" "strings" @@ -16,47 +17,69 @@ func SetRepoRoot(root string) { } // run executes a git command inside repoPath with strict safety guarantees: -// - repoPath is validated to be under the configured repoRoot -// - args are passed as discrete values — never via shell interpolation -// - the process inherits only a minimal environment +// - repoPath is validated to be under the configured repoRoot (path traversal guard) +// - args are passed as discrete values — never via shell interpolation +// - the process inherits only a minimal, fixed environment func run(repoPath string, args ...string) ([]byte, error) { clean := filepath.Clean(repoPath) - if repoRoot != "" && !strings.HasPrefix(clean, repoRoot+string(filepath.Separator)) && clean != repoRoot { - return nil, ErrPathTraversal + if repoRoot != "" { + root := repoRoot + string(filepath.Separator) + if !strings.HasPrefix(clean+string(filepath.Separator), root) && clean != repoRoot { + return nil, ErrPathTraversal + } } cmd := exec.Command("git", args...) cmd.Dir = clean cmd.Env = []string{ "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 { - _, err := run(path, "init", "--bare") - return err + // git init --bare works even if the directory doesn't exist yet + 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 { - Hash string - Author string - Message string - Date string + Hash string `json:"hash"` + Author string `json:"author"` + Message string `json:"message"` + Date string `json:"date"` } 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", - "--max-count", strings.TrimSpace(string(rune(limit+'0'))), + fmt.Sprintf("--max-count=%d", limit), ) if err != nil { return nil, err } + + raw := strings.TrimSpace(string(out)) + if raw == "" { + return nil, nil + } + var commits []Commit - for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") { - parts := strings.Split(line, "\x1f") + for _, line := range strings.Split(raw, "\n") { + parts := strings.SplitN(line, "\x1f", 4) if len(parts) != 4 { continue } @@ -71,18 +94,22 @@ func Log(repoPath, branch string, limit int) ([]Commit, error) { } type TreeEntry struct { - Mode string - Type string - Hash string - Name string + Mode string `json:"mode"` + Type string `json:"type"` + Hash string `json:"hash"` + Name string `json:"name"` } 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) if err != nil { return nil, err } + var entries []TreeEntry for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") { if line == "" { diff --git a/internal/models/federation.go b/internal/models/federation.go index adb9bc3..df883b7 100644 --- a/internal/models/federation.go +++ b/internal/models/federation.go @@ -3,13 +3,13 @@ package models import "time" type FederationActor struct { - ID int64 `xorm:"pk autoincr"` - UserID int64 `xorm:"notnull unique index"` - APID string `xorm:"notnull unique varchar(500)"` // https://instance/users/alice - InboxURL string `xorm:"notnull varchar(500)"` - OutboxURL string `xorm:"notnull varchar(500)"` - PublicKey string `xorm:"text notnull"` - PrivateKey string `xorm:"text notnull"` // AES-GCM encrypted at rest - CreatedAt time.Time `xorm:"created"` - UpdatedAt time.Time `xorm:"updated"` + ID int64 `xorm:"pk autoincr" json:"id"` + UserID int64 `xorm:"notnull unique index" json:"userId"` + APID string `xorm:"notnull unique varchar(500)" json:"apId"` + InboxURL string `xorm:"notnull varchar(500)" json:"inboxUrl"` + OutboxURL string `xorm:"notnull varchar(500)" json:"outboxUrl"` + PublicKey string `xorm:"text notnull" json:"publicKey"` + PrivateKey string `xorm:"text notnull" json:"-"` + CreatedAt time.Time `xorm:"created" json:"createdAt"` + UpdatedAt time.Time `xorm:"updated" json:"updatedAt"` } diff --git a/internal/models/pr.go b/internal/models/pr.go index 1244956..894f04c 100644 --- a/internal/models/pr.go +++ b/internal/models/pr.go @@ -11,14 +11,14 @@ const ( ) type PullRequest struct { - ID int64 `xorm:"pk autoincr"` - RepoID int64 `xorm:"notnull index"` - AuthorID int64 `xorm:"notnull index"` - Title string `xorm:"notnull varchar(255)"` - Body string `xorm:"text"` - SourceBranch string `xorm:"notnull varchar(255)"` - TargetBranch string `xorm:"default 'main' varchar(255)"` - Status PRStatus `xorm:"default 'open' varchar(16)"` - CreatedAt time.Time `xorm:"created"` - UpdatedAt time.Time `xorm:"updated"` + ID int64 `xorm:"pk autoincr" json:"id"` + RepoID int64 `xorm:"notnull index" json:"repoId"` + AuthorID int64 `xorm:"notnull index" json:"authorId"` + Title string `xorm:"notnull varchar(255)" json:"title"` + Body string `xorm:"text" json:"body"` + SourceBranch string `xorm:"notnull varchar(255)" json:"sourceBranch"` + TargetBranch string `xorm:"default 'main' varchar(255)" json:"targetBranch"` + Status PRStatus `xorm:"default 'open' varchar(16)" json:"status"` + CreatedAt time.Time `xorm:"created" json:"createdAt"` + UpdatedAt time.Time `xorm:"updated" json:"updatedAt"` } diff --git a/internal/models/repo.go b/internal/models/repo.go index 83a8275..62d6058 100644 --- a/internal/models/repo.go +++ b/internal/models/repo.go @@ -3,13 +3,13 @@ package models import "time" type Repository struct { - ID int64 `xorm:"pk autoincr"` - OwnerID int64 `xorm:"notnull index"` - Name string `xorm:"notnull varchar(100)"` - Description string `xorm:"varchar(500)"` - IsPrivate bool `xorm:"default false"` - DefaultBranch string `xorm:"default 'main' varchar(255)"` - DiskPath string `xorm:"notnull"` // absolute path under REPO_ROOT - CreatedAt time.Time `xorm:"created"` - UpdatedAt time.Time `xorm:"updated"` + ID int64 `xorm:"pk autoincr" json:"id"` + OwnerID int64 `xorm:"notnull index" json:"ownerId"` + Name string `xorm:"notnull varchar(100)" json:"name"` + Description string `xorm:"varchar(500)" json:"description"` + IsPrivate bool `xorm:"default false" json:"isPrivate"` + DefaultBranch string `xorm:"default 'main' varchar(255)" json:"defaultBranch"` + DiskPath string `xorm:"notnull" json:"-"` + CreatedAt time.Time `xorm:"created" json:"createdAt"` + UpdatedAt time.Time `xorm:"updated" json:"updatedAt"` } diff --git a/internal/models/user.go b/internal/models/user.go index 4bfb6aa..a1388c2 100644 --- a/internal/models/user.go +++ b/internal/models/user.go @@ -3,12 +3,12 @@ package models import "time" type User struct { - ID int64 `xorm:"pk autoincr"` - Username string `xorm:"unique notnull varchar(64)"` - Email string `xorm:"unique notnull varchar(255)"` - PasswordHash string `xorm:"notnull"` + ID int64 `xorm:"pk autoincr" json:"id"` + Username string `xorm:"unique notnull varchar(64)" json:"username"` + Email string `xorm:"unique notnull varchar(255)" json:"email"` + PasswordHash string `xorm:"notnull" json:"-"` AvatarURL string `xorm:"varchar(500)"` - IsAdmin bool `xorm:"default false"` - CreatedAt time.Time `xorm:"created"` - UpdatedAt time.Time `xorm:"updated"` + IsAdmin bool `xorm:"default false" json:"isAdmin"` + CreatedAt time.Time `xorm:"created" json:"createdAt"` + UpdatedAt time.Time `xorm:"updated" json:"updatedAt"` }