diff --git a/models/packages/package_defaults.go b/models/packages/package_defaults.go new file mode 100644 index 0000000000..617e33fc07 --- /dev/null +++ b/models/packages/package_defaults.go @@ -0,0 +1,56 @@ +// Copyright 2026 The GitCaddy Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package packages + +import ( + "context" + + "code.gitcaddy.com/server/v3/models/db" +) + +func init() { + db.RegisterModel(new(PackageDefaults)) +} + +// PackageDefaults stores preconfigured package metadata defaults for an organization or user. +// These values are used by AI tools when building packages (e.g., NuGet .csproj metadata). +type PackageDefaults struct { + ID int64 `xorm:"pk autoincr"` + OwnerID int64 `xorm:"UNIQUE INDEX NOT NULL"` + Authors string `xorm:"TEXT"` + Company string `xorm:"VARCHAR(255)"` + Copyright string `xorm:"VARCHAR(512)"` + IconPath string `xorm:"VARCHAR(255)"` // relative path in avatar storage (package-icons/{ownerID}) +} + +// GetPackageDefaultsByOwnerID returns the package defaults for the given owner. +// Returns an empty PackageDefaults (with OwnerID set) if none exist yet. +func GetPackageDefaultsByOwnerID(ctx context.Context, ownerID int64) (*PackageDefaults, error) { + defaults := &PackageDefaults{OwnerID: ownerID} + has, err := db.GetEngine(ctx).Where("owner_id = ?", ownerID).Get(defaults) + if err != nil { + return nil, err + } + if !has { + return &PackageDefaults{OwnerID: ownerID}, nil + } + return defaults, nil +} + +// CreateOrUpdatePackageDefaults creates or updates the package defaults for the given owner. +func CreateOrUpdatePackageDefaults(ctx context.Context, defaults *PackageDefaults) error { + existing := &PackageDefaults{} + has, err := db.GetEngine(ctx).Where("owner_id = ?", defaults.OwnerID).Get(existing) + if err != nil { + return err + } + + if has { + defaults.ID = existing.ID + _, err = db.GetEngine(ctx).ID(defaults.ID).AllCols().Update(defaults) + } else { + _, err = db.GetEngine(ctx).Insert(defaults) + } + return err +} diff --git a/options/locale/custom_keys.json b/options/locale/custom_keys.json index 006e67f816..b271a16145 100644 --- a/options/locale/custom_keys.json +++ b/options/locale/custom_keys.json @@ -504,6 +504,19 @@ "packages.settings.unlink.error": "Failed to remove repository link.", "packages.settings.unlink.success": "Repository link was successfully removed.", "packages.settings.global_access.url": "Global URL", + "packages.settings.preconfigure": "Preconfigure Defaults", + "packages.settings.preconfigure.description": "Set default package metadata for this organization. These values are used by AI tools when building packages.", + "packages.settings.preconfigure.authors": "Authors", + "packages.settings.preconfigure.authors.placeholder": "e.g. David H. Friedel Jr", + "packages.settings.preconfigure.company": "Company", + "packages.settings.preconfigure.company.placeholder": "e.g. MarketAlly", + "packages.settings.preconfigure.copyright": "Copyright", + "packages.settings.preconfigure.copyright.placeholder": "e.g. Copyright © 2026 MarketAlly", + "packages.settings.preconfigure.icon": "Default Package Icon", + "packages.settings.preconfigure.icon.upload": "Upload Icon", + "packages.settings.preconfigure.saved": "Package defaults saved successfully.", + "packages.settings.preconfigure.icon.saved": "Package icon uploaded successfully.", + "packages.settings.preconfigure.icon.error": "Failed to upload package icon.", "packages.owner.settings.cargo.rebuild.success": "The Cargo index was successfully rebuilt.", "secrets.secrets": "Secrets", "actions.actions": "Actions", diff --git a/options/locale/locale_en-US.json b/options/locale/locale_en-US.json index e8c575dbef..c7a9f73f7c 100644 --- a/options/locale/locale_en-US.json +++ b/options/locale/locale_en-US.json @@ -3682,6 +3682,19 @@ "packages.settings.global_access.disabled": "Package global access has been disabled.", "packages.settings.global_access.error": "Failed to update global access setting.", "packages.settings.global_access.url": "Global URL", + "packages.settings.preconfigure": "Preconfigure Defaults", + "packages.settings.preconfigure.description": "Set default package metadata for this organization. These values are used by AI tools when building packages.", + "packages.settings.preconfigure.authors": "Authors", + "packages.settings.preconfigure.authors.placeholder": "e.g. David H. Friedel Jr", + "packages.settings.preconfigure.company": "Company", + "packages.settings.preconfigure.company.placeholder": "e.g. MarketAlly", + "packages.settings.preconfigure.copyright": "Copyright", + "packages.settings.preconfigure.copyright.placeholder": "e.g. Copyright © 2026 MarketAlly", + "packages.settings.preconfigure.icon": "Default Package Icon", + "packages.settings.preconfigure.icon.upload": "Upload Icon", + "packages.settings.preconfigure.saved": "Package defaults saved successfully.", + "packages.settings.preconfigure.icon.saved": "Package icon uploaded successfully.", + "packages.settings.preconfigure.icon.error": "Failed to upload package icon.", "packages.visibility": "Visibility", "packages.settings.visibility.private.text": "This package is currently private. Make it public to allow anyone to access it.", "packages.settings.visibility.private.button": "Make Private", diff --git a/routers/api/v2/mcp.go b/routers/api/v2/mcp.go index d13d29bf74..57a3101978 100644 --- a/routers/api/v2/mcp.go +++ b/routers/api/v2/mcp.go @@ -500,6 +500,24 @@ var mcpTools = []MCPTool{ "required": []string{"owner", "repo", "run_id", "artifact_name"}, }, }, + { + Name: "get_package_defaults", + Description: "Get preconfigured package defaults for an organization, with repo-specific URLs filled in. Returns authors, company, copyright, icon URL, and repository URLs for building valid package metadata.", + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "owner": map[string]any{ + "type": "string", + "description": "Organization or user name", + }, + "repo": map[string]any{ + "type": "string", + "description": "Repository name (optional, for generating repo-specific URLs)", + }, + }, + "required": []string{"owner"}, + }, + }, } // MCPHandler handles MCP protocol requests @@ -623,6 +641,8 @@ func handleToolsCall(ctx *context_service.APIContext, req *MCPRequest) { result, err = toolGetCompatibilityMatrix(ctx, params.Arguments) case "diagnose_job_failure": result, err = toolDiagnoseJobFailure(ctx, params.Arguments) + case "get_package_defaults": + result, err = toolGetPackageDefaults(ctx, params.Arguments) default: sendMCPError(ctx, req.ID, -32602, "Unknown tool", params.Name) return @@ -2004,3 +2024,43 @@ func toolGetArtifactDownloadURL(ctx *context_service.APIContext, args map[string "download_url": downloadURL, }, nil } + +func toolGetPackageDefaults(ctx *context_service.APIContext, args map[string]any) (any, error) { + owner, _ := args["owner"].(string) + repo, _ := args["repo"].(string) + + if owner == "" { + return nil, errors.New("owner is required") + } + + ownerUser, err := user_model.GetUserByName(ctx, owner) + if err != nil { + return nil, fmt.Errorf("owner not found: %s", owner) + } + + defaults, err := packages_model.GetPackageDefaultsByOwnerID(ctx, ownerUser.ID) + if err != nil { + return nil, fmt.Errorf("failed to get package defaults: %w", err) + } + + result := map[string]any{ + "owner": owner, + "authors": defaults.Authors, + "company": defaults.Company, + "copyright": defaults.Copyright, + } + + // Fill in repo-specific URLs if repo is provided + if repo != "" { + baseURL := setting.AppURL + owner + "/" + repo + result["repository_url"] = baseURL + result["package_project_url"] = baseURL + } + + // Include icon URL if an icon has been uploaded + if defaults.IconPath != "" { + result["icon_url"] = setting.AppURL + owner + "/-/package-icon" + } + + return result, nil +} diff --git a/routers/web/org/setting_packages.go b/routers/web/org/setting_packages.go index 3173989cbd..623cedb1b4 100644 --- a/routers/web/org/setting_packages.go +++ b/routers/web/org/setting_packages.go @@ -5,10 +5,15 @@ package org import ( "fmt" + "io" "net/http" + packages_model "code.gitcaddy.com/server/v3/models/packages" + "code.gitcaddy.com/server/v3/modules/avatar" "code.gitcaddy.com/server/v3/modules/setting" + "code.gitcaddy.com/server/v3/modules/storage" "code.gitcaddy.com/server/v3/modules/templates" + "code.gitcaddy.com/server/v3/modules/typesniffer" shared "code.gitcaddy.com/server/v3/routers/web/shared/packages" shared_user "code.gitcaddy.com/server/v3/routers/web/shared/user" "code.gitcaddy.com/server/v3/services/context" @@ -30,11 +35,99 @@ func Packages(ctx *context.Context) { return } + defaults, err := packages_model.GetPackageDefaultsByOwnerID(ctx, ctx.ContextUser.ID) + if err != nil { + ctx.ServerError("GetPackageDefaultsByOwnerID", err) + return + } + ctx.Data["PackageDefaults"] = defaults + shared.SetPackagesContext(ctx, ctx.ContextUser) ctx.HTML(http.StatusOK, tplSettingsPackages) } +func PackageDefaultsPost(ctx *context.Context) { + defaults, err := packages_model.GetPackageDefaultsByOwnerID(ctx, ctx.ContextUser.ID) + if err != nil { + ctx.ServerError("GetPackageDefaultsByOwnerID", err) + return + } + + defaults.Authors = ctx.FormString("authors") + defaults.Company = ctx.FormString("company") + defaults.Copyright = ctx.FormString("copyright") + + if err := packages_model.CreateOrUpdatePackageDefaults(ctx, defaults); err != nil { + ctx.ServerError("CreateOrUpdatePackageDefaults", err) + return + } + + ctx.Flash.Success(ctx.Tr("packages.settings.preconfigure.saved")) + ctx.Redirect(fmt.Sprintf("%s/org/%s/settings/packages", setting.AppSubURL, ctx.ContextUser.Name)) +} + +func PackageDefaultsIconPost(ctx *context.Context) { + file, header, err := ctx.Req.FormFile("icon") + if err != nil { + ctx.Flash.Error(ctx.Tr("packages.settings.preconfigure.icon.error")) + ctx.Redirect(fmt.Sprintf("%s/org/%s/settings/packages", setting.AppSubURL, ctx.ContextUser.Name)) + return + } + defer file.Close() + + if header.Size > setting.Avatar.MaxFileSize { + ctx.Flash.Error(ctx.Tr("settings.uploaded_avatar_is_too_big", header.Size/1024, setting.Avatar.MaxFileSize/1024)) + ctx.Redirect(fmt.Sprintf("%s/org/%s/settings/packages", setting.AppSubURL, ctx.ContextUser.Name)) + return + } + + data, err := io.ReadAll(file) + if err != nil { + ctx.ServerError("io.ReadAll", err) + return + } + + st := typesniffer.DetectContentType(data) + if !(st.IsImage() && !st.IsSvgImage()) { + ctx.Flash.Error(ctx.Tr("settings.uploaded_avatar_not_a_image")) + ctx.Redirect(fmt.Sprintf("%s/org/%s/settings/packages", setting.AppSubURL, ctx.ContextUser.Name)) + return + } + + // Process/resize the image + processedData, err := avatar.ProcessAvatarImage(data) + if err != nil { + ctx.ServerError("ProcessAvatarImage", err) + return + } + + // Save to storage + iconPath := fmt.Sprintf("package-icons/%d", ctx.ContextUser.ID) + if err := storage.SaveFrom(storage.Avatars, iconPath, func(w io.Writer) error { + _, err := w.Write(processedData) + return err + }); err != nil { + ctx.ServerError("storage.SaveFrom", err) + return + } + + // Update database + defaults, err := packages_model.GetPackageDefaultsByOwnerID(ctx, ctx.ContextUser.ID) + if err != nil { + ctx.ServerError("GetPackageDefaultsByOwnerID", err) + return + } + defaults.IconPath = iconPath + if err := packages_model.CreateOrUpdatePackageDefaults(ctx, defaults); err != nil { + ctx.ServerError("CreateOrUpdatePackageDefaults", err) + return + } + + ctx.Flash.Success(ctx.Tr("packages.settings.preconfigure.icon.saved")) + ctx.Redirect(fmt.Sprintf("%s/org/%s/settings/packages", setting.AppSubURL, ctx.ContextUser.Name)) +} + func PackagesRuleAdd(ctx *context.Context) { ctx.Data["Title"] = ctx.Tr("packages.title") ctx.Data["PageIsOrgSettings"] = true diff --git a/routers/web/user/package_icon.go b/routers/web/user/package_icon.go new file mode 100644 index 0000000000..2934acff26 --- /dev/null +++ b/routers/web/user/package_icon.go @@ -0,0 +1,45 @@ +// Copyright 2026 The GitCaddy Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package user + +import ( + "net/http" + "time" + + packages_model "code.gitcaddy.com/server/v3/models/packages" + "code.gitcaddy.com/server/v3/modules/httpcache" + "code.gitcaddy.com/server/v3/modules/storage" + "code.gitcaddy.com/server/v3/services/context" +) + +// PackageIcon serves the default package icon for an organization or user. +func PackageIcon(ctx *context.Context) { + defaults, err := packages_model.GetPackageDefaultsByOwnerID(ctx, ctx.ContextUser.ID) + if err != nil { + ctx.ServerError("GetPackageDefaultsByOwnerID", err) + return + } + + if defaults.IconPath == "" { + ctx.Status(http.StatusNotFound) + return + } + + f, err := storage.Avatars.Open(defaults.IconPath) + if err != nil { + ctx.Status(http.StatusNotFound) + return + } + defer f.Close() + + info, err := f.Stat() + if err != nil { + ctx.ServerError("Stat", err) + return + } + + httpcache.SetCacheControlInHeader(ctx.Resp.Header(), &httpcache.CacheControlOptions{MaxAge: 5 * time.Minute}) + ctx.Resp.Header().Set("Content-Type", "image/png") + http.ServeContent(ctx.Resp, ctx.Req, "package-icon.png", info.ModTime(), f) +} diff --git a/routers/web/web.go b/routers/web/web.go index dfa63e1656..bd6fd5f224 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -1080,6 +1080,10 @@ func registerWebRoutes(m *web.Router) { m.Post("/initialize", org.InitializeCargoIndex) m.Post("/rebuild", org.RebuildCargoIndex) }) + m.Group("/defaults", func() { + m.Post("", org.PackageDefaultsPost) + m.Post("/icon", org.PackageDefaultsIconPost) + }) }, packagesEnabled) m.Group("/blocked_users", func() { @@ -1118,6 +1122,7 @@ func registerWebRoutes(m *web.Router) { }) }) }, context.PackageAssignment(), reqPackageAccess(perm.AccessModeRead)) + m.Get("/package-icon", user.PackageIcon) } m.Get("/repositories", org.Repositories) diff --git a/templates/org/settings/packages.tmpl b/templates/org/settings/packages.tmpl index 91106c3f15..2a05c00636 100644 --- a/templates/org/settings/packages.tmpl +++ b/templates/org/settings/packages.tmpl @@ -1,5 +1,39 @@ {{template "org/settings/layout_head" (dict "ctxData" . "pageClass" "organization settings packages")}}
{{ctx.Locale.Tr "packages.settings.preconfigure.description"}}
+ + +