did something
This commit is contained in:
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user