From 803672a610fff4a88c3fb8ba3e63fd838dc3a00c Mon Sep 17 00:00:00 2001 From: erangel1 Date: Thu, 7 May 2026 16:12:25 +0200 Subject: [PATCH] Git LFS section is live with: MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Enable LFS toggle — turns LFS on/off for the repo; all other controls dim when disabled File locking toggle — enables the LFS locking protocol for binary assets Maximum file size — optional per-file size cap in MB (blank = unlimited) Info callout linking to the git-lfs client install page and noting the .gitattributes requirement --- frontend/src/api/queries/lfs.ts | 31 ++++++ frontend/src/pages/RepoSettingsPage.tsx | 140 +++++++++++++++++++++++- internal/api/handlers/lfs.go | 132 ++++++++++++++++++++++ internal/api/router.go | 3 + internal/models/lfs.go | 9 ++ internal/models/migrations/001_init.go | 5 +- internal/models/migrations/007_lfs.go | 10 ++ 7 files changed, 328 insertions(+), 2 deletions(-) create mode 100644 frontend/src/api/queries/lfs.ts create mode 100644 internal/api/handlers/lfs.go create mode 100644 internal/models/lfs.go create mode 100644 internal/models/migrations/007_lfs.go diff --git a/frontend/src/api/queries/lfs.ts b/frontend/src/api/queries/lfs.ts new file mode 100644 index 0000000..9779c6c --- /dev/null +++ b/frontend/src/api/queries/lfs.ts @@ -0,0 +1,31 @@ +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import { z } from 'zod' +import { api } from '../client' + +const lfsSettingsSchema = z.object({ + enabled: z.boolean(), + lockingEnabled: z.boolean(), + maxFileSizeMB: z.number(), +}) + +export type LFSSettings = z.infer + +export function useLFSSettings(owner: string, repo: string) { + return useQuery({ + queryKey: ['repos', owner, repo, 'lfs-settings'], + queryFn: () => + api.get(`/api/v1/repos/${owner}/${repo}/lfs-settings`, lfsSettingsSchema), + enabled: Boolean(owner && repo), + }) +} + +export function useUpdateLFSSettings(owner: string, repo: string) { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: (body: Partial) => + api.put(`/api/v1/repos/${owner}/${repo}/lfs-settings`, lfsSettingsSchema, body), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['repos', owner, repo, 'lfs-settings'] }) + }, + }) +} diff --git a/frontend/src/pages/RepoSettingsPage.tsx b/frontend/src/pages/RepoSettingsPage.tsx index eec3404..3384a13 100644 --- a/frontend/src/pages/RepoSettingsPage.tsx +++ b/frontend/src/pages/RepoSettingsPage.tsx @@ -14,6 +14,7 @@ import { useDefaultDescription, useUpdateDefaultDescription, useExcludedFiles, useUpdateExcludedFiles, } from '../api/queries/prs' +import { useLFSSettings, useUpdateLFSSettings } from '../api/queries/lfs' import { useRecentRepos } from '../hooks/useRecentRepos' import { useAuth } from '../contexts/AuthContext' import { Skeleton } from '../ui/Skeleton' @@ -153,9 +154,10 @@ export default function RepoSettingsPage() { {section === 'default-reviewers' && } {section === 'default-description' && } {section === 'excluded-files' && } + {section === 'git-lfs' && } {!['repository-details','repository-permissions','access-keys','access-tokens', 'branch-restrictions','branching-model','merge-strategies','webhooks', - 'default-reviewers','default-description','excluded-files'].includes(section) && } + 'default-reviewers','default-description','excluded-files','git-lfs'].includes(section) && } ) @@ -1680,6 +1682,142 @@ function ExcludedFilesSection({ owner, repo }: { owner: string; repo: string }) ) } +// ─── Git LFS ───────────────────────────────────────────────────────────────── + +function GitLFSSection({ owner, repo }: { owner: string; repo: string }) { + const { data, isLoading } = useLFSSettings(owner, repo) + const update = useUpdateLFSSettings(owner, repo) + const [maxSizeInput, setMaxSizeInput] = useState('') + const [sizeError, setSizeError] = useState('') + const [saved, setSaved] = useState(false) + + useEffect(() => { + if (data) setMaxSizeInput(data.maxFileSizeMB === 0 ? '' : String(data.maxFileSizeMB)) + }, [data]) + + async function toggle(field: 'enabled' | 'lockingEnabled', value: boolean) { + await update.mutateAsync({ [field]: value }) + } + + async function saveMaxSize(e: React.FormEvent) { + e.preventDefault() + setSizeError('') + const raw = maxSizeInput.trim() + const mb = raw === '' ? 0 : parseInt(raw, 10) + if (raw !== '' && (isNaN(mb) || mb < 0)) { + setSizeError('Enter a positive number of megabytes, or leave blank for unlimited.') + return + } + await update.mutateAsync({ maxFileSizeMB: mb }) + setSaved(true) + setTimeout(() => setSaved(false), 2000) + } + + if (isLoading) { + return ( +
+ + + +
+ ) + } + + const enabled = data?.enabled ?? false + const lockingEnabled = data?.lockingEnabled ?? true + + return ( +
+
+

Git LFS

+

+ Git Large File Storage replaces large binary files (images, videos, datasets) with lightweight text pointers inside Git, + while storing the actual file content on the server. +

+
+ + {/* Enable / disable */} +
+
+
+

Enable Git LFS

+

+ Allow contributors to push large files using the LFS protocol. +

+
+ +
+ +
+
+

File locking

+

+ Allow contributors to lock LFS files to prevent conflicting edits on binary assets. +

+
+ +
+
+ + {/* Max file size */} +
+
+

Maximum file size

+

+ Reject LFS pushes for individual files exceeding this size. Leave blank for no limit. +

+
+
+
+ setMaxSizeInput(e.target.value)} + disabled={!enabled} + placeholder="Unlimited" + className="w-36 border border-[var(--c-border)] rounded px-3 py-2 text-sm bg-[var(--c-surface)] text-[var(--c-text)] focus:outline-none focus:border-[var(--c-brand-focus)] disabled:opacity-50 pr-10" + /> + MB +
+ + {saved && Saved} +
+ {sizeError &&

{sizeError}

} +
+ + {/* Info box */} +
+ + + +

+ Git LFS must be installed on the client to push and pull tracked files. + Add a .gitattributes file to your repository to specify which file patterns LFS should track. +

+
+
+ ) +} + // ─── Coming soon ────────────────────────────────────────────────────────────── function ComingSoon({ sectionId }: { sectionId: SectionId }) { diff --git a/internal/api/handlers/lfs.go b/internal/api/handlers/lfs.go new file mode 100644 index 0000000..260f850 --- /dev/null +++ b/internal/api/handlers/lfs.go @@ -0,0 +1,132 @@ +package handlers + +import ( + "encoding/json" + "net/http" + + "github.com/go-chi/chi/v5" + "xorm.io/xorm" + + "github.com/forgeo/forgebucket/internal/api/middleware" + "github.com/forgeo/forgebucket/internal/models" +) + +type LFSHandler struct{ db *xorm.Engine } + +func NewLFSHandler(db *xorm.Engine) *LFSHandler { return &LFSHandler{db: db} } + +func (h *LFSHandler) resolveRepo(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 { + jsonError(w, "database error: "+err.Error(), http.StatusInternalServerError) + return nil, false + } + if !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 { + jsonError(w, "database error: "+err.Error(), http.StatusInternalServerError) + return nil, false + } + if !found { + jsonError(w, "repository not found", http.StatusNotFound) + return nil, false + } + return &repo, true +} + +func (h *LFSHandler) canManage(repo *models.Repository, callerID int64) bool { + if callerID == repo.OwnerID { + return true + } + var m models.RepoMember + found, _ := h.db.Where("repo_id = ? AND user_id = ? AND permission = 'admin'", repo.ID, callerID).Get(&m) + return found +} + +func (h *LFSHandler) Get(w http.ResponseWriter, r *http.Request) { + repo, ok := h.resolveRepo(w, r) + if !ok { + return + } + + var s models.RepoLFSSettings + h.db.Where("repo_id = ?", repo.ID).Get(&s) + jsonOK(w, lfsResponse(repo.ID, &s)) +} + +func (h *LFSHandler) Update(w http.ResponseWriter, r *http.Request) { + repo, ok := h.resolveRepo(w, r) + if !ok { + return + } + callerID, _ := middleware.UserIDFromContext(r.Context()) + if !h.canManage(repo, callerID) { + jsonError(w, "forbidden", http.StatusForbidden) + return + } + + var body struct { + Enabled *bool `json:"enabled"` + LockingEnabled *bool `json:"lockingEnabled"` + MaxFileSizeMB *int64 `json:"maxFileSizeMB"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + jsonError(w, "invalid request body", http.StatusBadRequest) + return + } + + var s models.RepoLFSSettings + found, err := h.db.Where("repo_id = ?", repo.ID).Get(&s) + if err != nil { + jsonError(w, "database error: "+err.Error(), http.StatusInternalServerError) + return + } + s.RepoID = repo.ID + + if body.Enabled != nil { + s.Enabled = *body.Enabled + } + if body.LockingEnabled != nil { + s.LockingEnabled = *body.LockingEnabled + } + if body.MaxFileSizeMB != nil { + s.MaxFileSizeMB = *body.MaxFileSizeMB + } + + if found { + if _, err := h.db.ID(s.ID).Cols("enabled", "locking_enabled", "max_file_size_mb").Update(&s); err != nil { + jsonError(w, "could not update LFS settings", http.StatusInternalServerError) + return + } + } else { + if _, err := h.db.Insert(&s); err != nil { + jsonError(w, "could not save LFS settings", http.StatusInternalServerError) + return + } + } + + jsonOK(w, lfsResponse(repo.ID, &s)) +} + +type lfsSettingsResponse struct { + Enabled bool `json:"enabled"` + LockingEnabled bool `json:"lockingEnabled"` + MaxFileSizeMB int64 `json:"maxFileSizeMB"` +} + +func lfsResponse(_ int64, s *models.RepoLFSSettings) lfsSettingsResponse { + return lfsSettingsResponse{ + Enabled: s.Enabled, + LockingEnabled: s.LockingEnabled, + MaxFileSizeMB: s.MaxFileSizeMB, + } +} diff --git a/internal/api/router.go b/internal/api/router.go index 9d5c80b..ce5301f 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -49,6 +49,7 @@ func New(cfg *config.Config, engine *xorm.Engine, store sessions.Store, staticFi workflowH := handlers.NewWorkflowHandler(engine) webhookH := handlers.NewWebhookHandler(engine) prSettingsH := handlers.NewPRSettingsHandler(engine) + lfsH := handlers.NewLFSHandler(engine) // ── Git smart-HTTP transport ─────────────────────────────────────────────── // These routes MUST be registered before the SPA catch-all and outside CSRF. @@ -182,6 +183,8 @@ func New(cfg *config.Config, engine *xorm.Engine, store sessions.Store, staticFi r.With(csrf).Put("/default-description", prSettingsH.UpdateDefaultDescription) r.Get("/excluded-files", prSettingsH.GetExcludedFiles) r.With(csrf).Put("/excluded-files", prSettingsH.UpdateExcludedFiles) + r.Get("/lfs-settings", lfsH.Get) + r.With(csrf).Put("/lfs-settings", lfsH.Update) }) }) }) diff --git a/internal/models/lfs.go b/internal/models/lfs.go new file mode 100644 index 0000000..add5dd6 --- /dev/null +++ b/internal/models/lfs.go @@ -0,0 +1,9 @@ +package models + +type RepoLFSSettings struct { + ID int64 `xorm:"'id' pk autoincr"` + RepoID int64 `xorm:"'repo_id' notnull unique"` + Enabled bool `xorm:"'enabled' default false"` + LockingEnabled bool `xorm:"'locking_enabled' default true"` + MaxFileSizeMB int64 `xorm:"'max_file_size_mb' default 0"` // 0 = unlimited +} diff --git a/internal/models/migrations/001_init.go b/internal/models/migrations/001_init.go index 1d95cd5..b975893 100644 --- a/internal/models/migrations/001_init.go +++ b/internal/models/migrations/001_init.go @@ -28,5 +28,8 @@ func Run(engine *xorm.Engine) error { if err := Run005(engine); err != nil { return err } - return Run006(engine) + if err := Run006(engine); err != nil { + return err + } + return Run007(engine) } diff --git a/internal/models/migrations/007_lfs.go b/internal/models/migrations/007_lfs.go new file mode 100644 index 0000000..770e7c2 --- /dev/null +++ b/internal/models/migrations/007_lfs.go @@ -0,0 +1,10 @@ +package migrations + +import ( + "github.com/forgeo/forgebucket/internal/models" + "xorm.io/xorm" +) + +func Run007(engine *xorm.Engine) error { + return engine.Sync2(&models.RepoLFSSettings{}) +}