did something

This commit is contained in:
2026-05-19 22:55:26 +02:00
parent ec9a286d33
commit 2a81bda00e
12 changed files with 5774 additions and 213 deletions
+6 -3
View File
@@ -28,9 +28,12 @@ func (h *InstanceHandler) Get(w http.ResponseWriter, r *http.Request) {
})
}
// sshHost extracts the hostname from InstanceURL. Falls back to the request
// host when InstanceURL is unset (common in local development).
// sshHost resolves the SSH hostname to display in clone URLs.
// Priority: SSH_HOST env var > InstanceURL hostname > request Host header > localhost.
func (h *InstanceHandler) sshHost(r *http.Request) string {
if h.cfg.SSHHost != "" {
return h.cfg.SSHHost
}
if h.cfg.InstanceURL != "" {
if u, err := url.Parse(h.cfg.InstanceURL); err == nil && u.Hostname() != "" {
return u.Hostname()
@@ -41,5 +44,5 @@ func (h *InstanceHandler) sshHost(r *http.Request) string {
if u, err := url.Parse("http://" + host); err == nil {
return u.Hostname()
}
return host
return "localhost"
}
+124 -6
View File
@@ -1,7 +1,10 @@
package handlers
import (
"archive/zip"
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
@@ -653,6 +656,7 @@ func (h *RepoHandler) lookupRepo(w http.ResponseWriter, r *http.Request) (*model
// SearchFiles handles GET /repos/{owner}/{repo}/files?q=...&ref=...
// Returns up to 20 matching file paths (case-insensitive substring match).
// When q is empty, returns all file paths up to 500 (used by the sidebar tree).
func (h *RepoHandler) SearchFiles(w http.ResponseWriter, r *http.Request) {
repo, ok := h.lookupRepo(w, r)
if !ok {
@@ -660,17 +664,17 @@ func (h *RepoHandler) SearchFiles(w http.ResponseWriter, r *http.Request) {
}
query := strings.TrimSpace(r.URL.Query().Get("q"))
if query == "" {
jsonOK(w, []string{})
return
}
ref := r.URL.Query().Get("ref")
if ref == "" {
ref = repo.DefaultBranch
}
files, err := gitdomain.SearchFiles(repo.DiskPath, ref, query, 20)
limit := 20
if query == "" {
limit = 500
}
files, err := gitdomain.SearchFiles(repo.DiskPath, ref, query, limit)
if err != nil {
jsonError(w, "search failed", http.StatusInternalServerError)
return
@@ -680,3 +684,117 @@ func (h *RepoHandler) SearchFiles(w http.ResponseWriter, r *http.Request) {
}
jsonOK(w, files)
}
// UploadFiles handles POST /repos/{owner}/{repo}/upload — multipart upload.
// Accepts multiple regular files (field "file[]") and/or a ZIP archive (field "zip").
// All files are committed in a single git commit.
func (h *RepoHandler) UploadFiles(w http.ResponseWriter, r *http.Request) {
repo, ok := h.lookupRepo(w, r)
if !ok {
return
}
username, _ := r.Context().Value(middleware.ContextKeyUsername).(string)
if !HasPermission(h.db, repo, username, "write") {
jsonError(w, "you do not have write access to this repository", http.StatusForbidden)
return
}
const maxUpload = 50 << 20 // 50 MB
if err := r.ParseMultipartForm(maxUpload); err != nil {
jsonError(w, "could not parse upload: "+err.Error(), http.StatusBadRequest)
return
}
branch := r.FormValue("branch")
if branch == "" {
branch = repo.DefaultBranch
}
message := r.FormValue("message")
if message == "" {
message = "Upload files"
}
var uploads []gitdomain.FileUpload
// Regular files (field "file[]" or "file"). Browser sends webkitRelativePath
// via the custom header X-File-Path; fall back to the bare filename.
for _, fhs := range r.MultipartForm.File {
for _, fh := range fhs {
if fh.Size == 0 {
continue
}
f, err := fh.Open()
if err != nil {
continue
}
data, err := io.ReadAll(io.LimitReader(f, 10<<20)) // 10 MB per file
f.Close()
if err != nil {
continue
}
// Prefer the relative path sent by the browser (folder upload),
// otherwise use the bare filename.
relPath := fh.Filename
if rp := fh.Header.Get("X-File-Path"); rp != "" {
relPath = rp
}
if strings.EqualFold(fh.Header.Get("Content-Disposition"), "") {
// Skip the "zip" field — handled separately below.
}
clean := filepath.Clean(filepath.FromSlash(relPath))
if strings.HasPrefix(clean, "..") || filepath.IsAbs(clean) {
jsonError(w, fmt.Sprintf("invalid path: %s", relPath), http.StatusBadRequest)
return
}
uploads = append(uploads, gitdomain.FileUpload{Path: clean, Content: data})
}
}
// ZIP archive (field "zip").
if zipFHs, ok := r.MultipartForm.File["zip"]; ok && len(zipFHs) > 0 {
fh := zipFHs[0]
f, err := fh.Open()
if err == nil {
zipData, err := io.ReadAll(io.LimitReader(f, maxUpload))
f.Close()
if err == nil {
zr, err := zip.NewReader(bytes.NewReader(zipData), int64(len(zipData)))
if err == nil {
for _, zf := range zr.File {
if zf.FileInfo().IsDir() {
continue
}
clean := filepath.Clean(filepath.FromSlash(zf.Name))
if strings.HasPrefix(clean, "..") || filepath.IsAbs(clean) {
continue
}
rc, err := zf.Open()
if err != nil {
continue
}
data, err := io.ReadAll(io.LimitReader(rc, 10<<20))
rc.Close()
if err != nil {
continue
}
uploads = append(uploads, gitdomain.FileUpload{Path: clean, Content: data})
}
}
}
}
}
if len(uploads) == 0 {
jsonError(w, "no files found in upload", http.StatusBadRequest)
return
}
if err := gitdomain.WriteManyFiles(repo.DiskPath, branch, message, username, username+"@forgebucket", uploads); err != nil {
jsonError(w, "commit failed: "+err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]int{"committed": len(uploads)}) //nolint:errcheck
}
+1
View File
@@ -184,6 +184,7 @@ func New(cfg *config.Config, engine *xorm.Engine, store sessions.Store, bus even
r.Get("/archive", archiveH.Download)
r.Get("/insights", insightsH.Get)
r.Get("/files", repoH.SearchFiles)
r.With(csrf).Post("/upload", repoH.UploadFiles)
r.Get("/diff", repoH.Diff)
r.Route("/pulls", func(r chi.Router) {
r.Get("/", prH.List)
+2
View File
@@ -45,6 +45,7 @@ type Config struct {
OCIRoot string
// SSH server
SSHHost string // env: SSH_HOST, empty = auto-detect from request/instance URL
SSHPort string // env: SSH_PORT, default "2222"
SSHHostKeyPath string // env: SSH_HOST_KEY_PATH, empty = generate ephemeral
@@ -72,6 +73,7 @@ func Load() (*Config, error) {
cfg.SessionSecret = requireEnv("SESSION_SECRET", &missing)
cfg.CSRFSecret = requireEnv("CSRF_SECRET", &missing)
cfg.SSHHost = os.Getenv("SSH_HOST")
cfg.SSHPort = getEnv("SSH_PORT", "2222")
cfg.SSHHostKeyPath = os.Getenv("SSH_HOST_KEY_PATH")
+78
View File
@@ -253,6 +253,84 @@ func WriteFile(repoPath, branch, filePath, content, authorName, authorEmail, mes
return nil
}
// FileUpload holds a file path and its content for a batch commit.
type FileUpload struct {
Path string // repo-relative path, e.g. "src/main.go"
Content []byte
}
// WriteManyFiles commits all files in a single commit to branch. Each file path
// must be a clean relative path — no ".." or absolute paths allowed.
func WriteManyFiles(repoPath, branch, message, authorName, authorEmail string, files []FileUpload) error {
if len(files) == 0 {
return errors.New("no files to commit")
}
// Validate all paths before touching the filesystem.
for _, f := range files {
clean := filepath.Clean(filepath.FromSlash(f.Path))
if strings.HasPrefix(clean, "..") || filepath.IsAbs(clean) {
return fmt.Errorf("invalid file path: %s", f.Path)
}
}
tmpDir, err := os.MkdirTemp("", "fb-upload-*")
if err != nil {
return fmt.Errorf("mktemp: %w", err)
}
baseEnv := []string{"GIT_TERMINAL_PROMPT=0", "HOME=/tmp"}
authorEnv := append(baseEnv,
"GIT_AUTHOR_NAME="+authorName,
"GIT_AUTHOR_EMAIL="+authorEmail,
"GIT_COMMITTER_NAME="+authorName,
"GIT_COMMITTER_EMAIL="+authorEmail,
)
addWt := exec.Command("git", "worktree", "add", "--force", tmpDir, branch)
addWt.Dir = filepath.Clean(repoPath)
addWt.Env = baseEnv
if out, err := addWt.CombinedOutput(); err != nil {
os.RemoveAll(tmpDir)
return fmt.Errorf("worktree add: %w: %s", err, out)
}
defer func() {
rmWt := exec.Command("git", "worktree", "remove", "--force", tmpDir)
rmWt.Dir = filepath.Clean(repoPath)
rmWt.Env = baseEnv
rmWt.Run()
os.RemoveAll(tmpDir)
}()
for _, f := range files {
clean := filepath.Clean(filepath.FromSlash(f.Path))
fullPath := filepath.Join(tmpDir, clean)
if err := os.MkdirAll(filepath.Dir(fullPath), 0755); err != nil {
return fmt.Errorf("mkdirall %s: %w", clean, err)
}
if err := os.WriteFile(fullPath, f.Content, 0644); err != nil {
return fmt.Errorf("writefile %s: %w", clean, err)
}
}
addC := exec.Command("git", "add", ".")
addC.Dir = tmpDir
addC.Env = authorEnv
if out, err := addC.CombinedOutput(); err != nil {
return fmt.Errorf("git add: %w: %s", err, out)
}
commitC := exec.Command("git", "commit", "-m", message)
commitC.Dir = tmpDir
commitC.Env = authorEnv
if out, err := commitC.CombinedOutput(); err != nil {
return fmt.Errorf("git commit: %w: %s", err, out)
}
return nil
}
type Branch struct {
Name string `json:"name"`
}