diff --git a/models/migrations/migrations.go b/models/migrations/migrations.go index ecca8f167d..b276437eee 100644 --- a/models/migrations/migrations.go +++ b/models/migrations/migrations.go @@ -405,6 +405,7 @@ func prepareMigrationTasks() []*migration { newMigration(328, "Add wiki index table for search", v1_26.AddWikiIndexTable), newMigration(329, "Add release archive columns", v1_26.AddReleaseArchiveColumns), newMigration(330, "Add runner capabilities column", v1_26.AddRunnerCapabilitiesColumn), + newMigration(331, "Add is_homepage_pinned to user table", v1_26.AddIsHomepagePinnedToUser), } return preparedMigrations } diff --git a/models/migrations/v1_26/v331.go b/models/migrations/v1_26/v331.go new file mode 100644 index 0000000000..bd14f2f71a --- /dev/null +++ b/models/migrations/v1_26/v331.go @@ -0,0 +1,16 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package v1_26 + +import ( + "xorm.io/xorm" +) + +// AddIsHomepagePinnedToUser adds is_homepage_pinned column to user table for organizations +func AddIsHomepagePinnedToUser(x *xorm.Engine) error { + type User struct { + IsHomepagePinned bool `xorm:"NOT NULL DEFAULT false"` + } + return x.Sync(new(User)) +} diff --git a/models/organization/org.go b/models/organization/org.go index b4d28f5405..8d81f5fe94 100644 --- a/models/organization/org.go +++ b/models/organization/org.go @@ -596,3 +596,21 @@ func getUserTeamIDsQueryBuilder(orgID, userID int64) *builder.Builder { "team_user.uid": userID, }) } + +// GetHomepagePinnedOrganizations returns all organizations that are pinned to the homepage +func GetHomepagePinnedOrganizations(ctx context.Context) ([]*Organization, error) { + orgs := make([]*Organization, 0, 10) + return orgs, db.GetEngine(ctx). + Where("type = ?", user_model.UserTypeOrganization). + And("is_homepage_pinned = ?", true). + And("visibility = ?", structs.VisibleTypePublic). + OrderBy("name ASC"). + Find(&orgs) +} + +// SetHomepagePinned updates the homepage pinned status for an organization +func (org *Organization) SetHomepagePinned(ctx context.Context, pinned bool) error { + org.IsHomepagePinned = pinned + _, err := db.GetEngine(ctx).ID(org.ID).Cols("is_homepage_pinned").Update(org) + return err +} diff --git a/models/user/user.go b/models/user/user.go index 44987ac381..35236baf6f 100644 --- a/models/user/user.go +++ b/models/user/user.go @@ -121,6 +121,8 @@ type User struct { // true: the user is only allowed to see organizations/repositories that they has explicit rights to. // (ex: in private Gitea instances user won't be allowed to see even organizations/repositories that are set as public) IsRestricted bool `xorm:"NOT NULL DEFAULT false"` + // IsHomepagePinned indicates if this organization should appear on the homepage + IsHomepagePinned bool `xorm:"NOT NULL DEFAULT false"` AllowGitHook bool AllowImportLocal bool // Allow migrate repository by local path diff --git a/modules/setting/config.go b/modules/setting/config.go index fb99325a95..1a31285265 100644 --- a/modules/setting/config.go +++ b/modules/setting/config.go @@ -53,9 +53,20 @@ type RepositoryStruct struct { GitGuideRemoteName *config.Value[string] } +type ThemeStruct struct { + DisableRegistration *config.Value[bool] + CustomSiteIconURL *config.Value[string] + CustomHomeLogoURL *config.Value[string] + CustomHomeHTML *config.Value[string] + CustomHomeTitle *config.Value[string] + CustomHomeTagline *config.Value[string] + PinnedOrgDisplayFormat *config.Value[string] +} + type ConfigStruct struct { Picture *PictureStruct Repository *RepositoryStruct + Theme *ThemeStruct } var ( @@ -74,6 +85,15 @@ func initDefaultConfig() { OpenWithEditorApps: config.ValueJSON[OpenWithEditorAppsType]("repository.open-with.editor-apps"), GitGuideRemoteName: config.ValueJSON[string]("repository.git-guide-remote-name").WithDefault("origin"), }, + Theme: &ThemeStruct{ + DisableRegistration: config.ValueJSON[bool]("theme.disable_registration").WithFileConfig(config.CfgSecKey{Sec: "service", Key: "DISABLE_REGISTRATION"}), + CustomSiteIconURL: config.ValueJSON[string]("theme.custom_site_icon_url").WithDefault(""), + CustomHomeLogoURL: config.ValueJSON[string]("theme.custom_home_logo_url").WithDefault(""), + CustomHomeHTML: config.ValueJSON[string]("theme.custom_home_html").WithDefault(""), + CustomHomeTitle: config.ValueJSON[string]("theme.custom_home_title").WithDefault(""), + CustomHomeTagline: config.ValueJSON[string]("theme.custom_home_tagline").WithDefault(""), + PinnedOrgDisplayFormat: config.ValueJSON[string]("theme.pinned_org_display_format").WithDefault("condensed"), + }, } } diff --git a/modules/templates/helper.go b/modules/templates/helper.go index 13888a4dbc..538d77ed54 100644 --- a/modules/templates/helper.go +++ b/modules/templates/helper.go @@ -39,6 +39,7 @@ func NewFuncMap() template.FuncMap { "QueryEscape": queryEscape, "QueryBuild": QueryBuild, "SanitizeHTML": SanitizeHTML, + "SafeHTML": SafeHTML, "URLJoin": util.URLJoin, "DotEscape": dotEscape, @@ -172,6 +173,11 @@ func SanitizeHTML(s string) template.HTML { return markup.Sanitize(s) } +// SafeHTML marks a string as safe HTML (no sanitization). Use with caution - only for trusted admin content. +func SafeHTML(s string) template.HTML { + return template.HTML(s) +} + func htmlFormat(s any, args ...any) template.HTML { if len(args) == 0 { // to prevent developers from calling "HTMLFormat $userInput" by mistake which will lead to XSS diff --git a/options/locale/locale_en-US.json b/options/locale/locale_en-US.json index f9c26b7327..939bfc41b0 100644 --- a/options/locale/locale_en-US.json +++ b/options/locale/locale_en-US.json @@ -338,6 +338,7 @@ "home.collaborative_repos": "Collaborative Repositories", "home.my_orgs": "My Organizations", "home.my_mirrors": "My Mirrors", + "home.featured_organizations": "Featured Organizations", "home.view_home": "View %s", "home.filter": "Other Filters", "home.filter_by_team_repositories": "Filter by team repositories", @@ -2798,6 +2799,9 @@ "org.settings.confirm_delete_account": "Confirm Deletion", "org.settings.delete_failed": "Deleting organization failed due to an internal error", "org.settings.delete_successful": "Organization %s has been deleted successfully.", + "org.settings.homepage_pinning": "Homepage Visibility", + "org.settings.pin_to_homepage": "Pin this organization to the homepage", + "org.settings.pin_to_homepage_help": "When enabled, this organization will be featured on the public homepage. Only administrators can change this setting.", "org.settings.hooks_desc": "Add webhooks which will be triggered for all repositories under this organization.", "org.settings.labels_desc": "Add labels which can be used on issues for all repositories under this organization.", "org.members.membership_visibility": "Membership Visibility:", @@ -3828,5 +3832,43 @@ "org.stats": "Stats", "org.recent_activity": "Recent Activity", "org.profile_repo_no_permission": "You do not have permission to create repositories in this organization.", - "org.profile_repo_create_failed": "Failed to create the profile repository." + "org.profile_repo_create_failed": "Failed to create the profile repository.", + "admin.config.theme_config": "Theme Configuration", + "admin.config.disable_registration": "Disable Registration", + "admin.config.disable_registration_desc": "When enabled, new users cannot sign up. Only administrators can create new accounts.", + "admin.config.custom_home_logo": "Homepage Logo", + "admin.config.custom_logo_url_placeholder": "Enter URL or upload a file below", + "admin.config.upload_logo": "Upload Logo File", + "admin.config.reset_logo": "Reset to Default", + "admin.config.custom_home_html": "Custom Homepage Content", + "admin.config.custom_home_html_placeholder": "Enter custom HTML for the homepage (shown to non-logged-in users)", + "admin.config.custom_home_html_help": "This HTML will replace the default homepage content. Leave empty to use the default.", + "admin.config.custom_home_title": "Homepage Title", + "admin.config.custom_home_title_placeholder": "Leave empty to use app name", + "admin.config.custom_home_title_help": "Custom title displayed on the homepage. Leave empty to use the default app name.", + "admin.config.custom_home_tagline": "Homepage Tagline", + "admin.config.custom_home_tagline_placeholder": "Leave empty to use default tagline", + "admin.config.custom_home_tagline_help": "Custom tagline displayed below the title on the homepage. Leave empty to use the default.", + "admin.config.pinned_org_display_format": "Pinned Organization Display Format", + "admin.config.pinned_org_format_condensed": "Condensed (icon on left)", + "admin.config.pinned_org_format_regular": "Regular (icon above)", + "admin.config.pinned_org_display_format_help": "Choose how pinned organizations are displayed on the homepage.", + "admin.config.logo_upload_success": "Homepage logo uploaded successfully", + "admin.config.logo_url_success": "Homepage logo URL updated successfully", + "admin.config.logo_reset_success": "Homepage logo reset to default", + "admin.config.logo_invalid_type": "Invalid file type. Allowed: SVG, PNG, JPG, GIF", + "admin.config.home_logo_help": "This logo will be displayed on the homepage for non-logged-in users. Recommended: SVG, PNG, JPG or GIF.", + "admin.config.custom_site_icon": "Site Icon (Favicon & Navbar)", + "admin.config.custom_icon_url_placeholder": "Enter icon URL or upload a file below", + "admin.config.upload_icon": "Upload Icon File", + "admin.config.reset_icon": "Reset to Default", + "admin.config.current_icon": "Current Icon", + "admin.config.icon_url": "Icon URL", + "admin.config.icon_upload_success": "Site icon uploaded successfully", + "admin.config.icon_url_success": "Site icon URL updated successfully", + "admin.config.icon_reset_success": "Site icon reset to default", + "admin.config.icon_invalid_type": "Invalid file type. Allowed: SVG, PNG, ICO", + "admin.config.site_icon_help": "This icon will be used as the site favicon and in the navbar. Recommended: SVG or PNG, square dimensions.", + "admin.config.current_logo": "Current Homepage Logo", + "admin.config.logo_url": "Logo URL" } \ No newline at end of file diff --git a/routers/web/admin/config.go b/routers/web/admin/config.go index 774b31ab98..49d311a542 100644 --- a/routers/web/admin/config.go +++ b/routers/web/admin/config.go @@ -5,8 +5,11 @@ package admin import ( + "io" "net/http" "net/url" + "os" + "path/filepath" "strconv" "strings" @@ -235,6 +238,11 @@ func ChangeConfig(ctx *context.Context) { cfg.Picture.EnableFederatedAvatar.DynKey(): marshalBool, cfg.Repository.OpenWithEditorApps.DynKey(): marshalOpenWithApps, cfg.Repository.GitGuideRemoteName.DynKey(): marshalString(cfg.Repository.GitGuideRemoteName.DefaultValue()), + cfg.Theme.DisableRegistration.DynKey(): marshalBool, + cfg.Theme.CustomHomeHTML.DynKey(): marshalString(""), + cfg.Theme.CustomHomeTitle.DynKey(): marshalString(""), + cfg.Theme.CustomHomeTagline.DynKey(): marshalString(""), + cfg.Theme.PinnedOrgDisplayFormat.DynKey(): marshalString("condensed"), } _ = ctx.Req.ParseForm() @@ -272,3 +280,167 @@ loop: config.GetDynGetter().InvalidateCache() ctx.JSONOK() } + +// ChangeThemeLogo handles homepage logo upload and custom URL +func ChangeThemeLogo(ctx *context.Context) { + cfg := setting.Config() + + action := ctx.FormString("action") + if action == "reset" { + configSettings := map[string]string{ + cfg.Theme.CustomHomeLogoURL.DynKey(): "\"\"", + } + if err := system_model.SetSettings(ctx, configSettings); err != nil { + ctx.ServerError("SetSettings", err) + return + } + config.GetDynGetter().InvalidateCache() + ctx.Flash.Success(ctx.Tr("admin.config.logo_reset_success")) + ctx.Redirect(setting.AppSubURL + "/-/admin/config/settings") + return + } + + // Check for file upload first + file, header, err := ctx.Req.FormFile("logo_file") + if err == nil && header != nil { + defer file.Close() + + ext := strings.ToLower(filepath.Ext(header.Filename)) + allowedExts := map[string]bool{".svg": true, ".png": true, ".jpg": true, ".jpeg": true, ".gif": true} + if !allowedExts[ext] { + ctx.Flash.Error(ctx.Tr("admin.config.logo_invalid_type")) + ctx.Redirect(setting.AppSubURL + "/-/admin/config/settings") + return + } + + customDir := filepath.Join(setting.CustomPath, "public", "assets", "img") + if err := os.MkdirAll(customDir, 0755); err != nil { + ctx.ServerError("MkdirAll", err) + return + } + + fileName := "custom-home-logo" + ext + filePath := filepath.Join(customDir, fileName) + destFile, err := os.Create(filePath) + if err != nil { + ctx.ServerError("Create", err) + return + } + defer destFile.Close() + + if _, err := io.Copy(destFile, file); err != nil { + ctx.ServerError("Copy", err) + return + } + + fileURL := setting.AppSubURL + "/assets/img/" + fileName + marshaledValue, _ := json.Marshal(fileURL) + configSettings := map[string]string{ + cfg.Theme.CustomHomeLogoURL.DynKey(): string(marshaledValue), + } + if err := system_model.SetSettings(ctx, configSettings); err != nil { + ctx.ServerError("SetSettings", err) + return + } + config.GetDynGetter().InvalidateCache() + ctx.Flash.Success(ctx.Tr("admin.config.logo_upload_success")) + ctx.Redirect(setting.AppSubURL + "/-/admin/config/settings") + return + } + + // Check for custom URL + customURL := ctx.FormString("custom_logo_url") + marshaledValue, _ := json.Marshal(customURL) + configSettings := map[string]string{ + cfg.Theme.CustomHomeLogoURL.DynKey(): string(marshaledValue), + } + if err := system_model.SetSettings(ctx, configSettings); err != nil { + ctx.ServerError("SetSettings", err) + return + } + config.GetDynGetter().InvalidateCache() + ctx.Flash.Success(ctx.Tr("admin.config.logo_url_success")) + ctx.Redirect(setting.AppSubURL + "/-/admin/config/settings") +} + +// ChangeThemeIcon handles site icon (favicon + navbar) upload and custom URL +func ChangeThemeIcon(ctx *context.Context) { + cfg := setting.Config() + + action := ctx.FormString("action") + if action == "reset" { + configSettings := map[string]string{ + cfg.Theme.CustomSiteIconURL.DynKey(): "\"\"", + } + if err := system_model.SetSettings(ctx, configSettings); err != nil { + ctx.ServerError("SetSettings", err) + return + } + config.GetDynGetter().InvalidateCache() + ctx.Flash.Success(ctx.Tr("admin.config.icon_reset_success")) + ctx.Redirect(setting.AppSubURL + "/-/admin/config/settings") + return + } + + // Check for file upload first + file, header, err := ctx.Req.FormFile("icon_file") + if err == nil && header != nil { + defer file.Close() + + ext := strings.ToLower(filepath.Ext(header.Filename)) + allowedExts := map[string]bool{".svg": true, ".png": true, ".ico": true} + if !allowedExts[ext] { + ctx.Flash.Error(ctx.Tr("admin.config.icon_invalid_type")) + ctx.Redirect(setting.AppSubURL + "/-/admin/config/settings") + return + } + + customDir := filepath.Join(setting.CustomPath, "public", "assets", "img") + if err := os.MkdirAll(customDir, 0755); err != nil { + ctx.ServerError("MkdirAll", err) + return + } + + fileName := "custom-site-icon" + ext + filePath := filepath.Join(customDir, fileName) + destFile, err := os.Create(filePath) + if err != nil { + ctx.ServerError("Create", err) + return + } + defer destFile.Close() + + if _, err := io.Copy(destFile, file); err != nil { + ctx.ServerError("Copy", err) + return + } + + fileURL := setting.AppSubURL + "/assets/img/" + fileName + marshaledValue, _ := json.Marshal(fileURL) + configSettings := map[string]string{ + cfg.Theme.CustomSiteIconURL.DynKey(): string(marshaledValue), + } + if err := system_model.SetSettings(ctx, configSettings); err != nil { + ctx.ServerError("SetSettings", err) + return + } + config.GetDynGetter().InvalidateCache() + ctx.Flash.Success(ctx.Tr("admin.config.icon_upload_success")) + ctx.Redirect(setting.AppSubURL + "/-/admin/config/settings") + return + } + + // Check for custom URL + customURL := ctx.FormString("custom_icon_url") + marshaledValue, _ := json.Marshal(customURL) + configSettings := map[string]string{ + cfg.Theme.CustomSiteIconURL.DynKey(): string(marshaledValue), + } + if err := system_model.SetSettings(ctx, configSettings); err != nil { + ctx.ServerError("SetSettings", err) + return + } + config.GetDynGetter().InvalidateCache() + ctx.Flash.Success(ctx.Tr("admin.config.icon_url_success")) + ctx.Redirect(setting.AppSubURL + "/-/admin/config/settings") +} diff --git a/routers/web/home.go b/routers/web/home.go index 7efa5f344e..d1dae2e264 100644 --- a/routers/web/home.go +++ b/routers/web/home.go @@ -10,6 +10,7 @@ import ( "code.gitea.io/gitea/models/db" repo_model "code.gitea.io/gitea/models/repo" + organization_model "code.gitea.io/gitea/models/organization" user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/optional" @@ -61,6 +62,14 @@ func Home(ctx *context.Context) { ctx.Data["PageIsHome"] = true ctx.Data["IsRepoIndexerEnabled"] = setting.Indexer.RepoIndexerEnabled + + // Load pinned organizations for homepage + pinnedOrgs, err := organization_model.GetHomepagePinnedOrganizations(ctx) + if err != nil { + log.Error("GetHomepagePinnedOrganizations: %v", err) + } else { + ctx.Data["PinnedOrganizations"] = pinnedOrgs + } ctx.HTML(http.StatusOK, tplHome) } diff --git a/routers/web/org/setting.go b/routers/web/org/setting.go index 0e4dab8fb6..1d8dd5f344 100644 --- a/routers/web/org/setting.go +++ b/routers/web/org/setting.go @@ -45,6 +45,7 @@ func Settings(ctx *context.Context) { ctx.Data["PageIsSettingsOptions"] = true ctx.Data["CurrentVisibility"] = ctx.Org.Organization.Visibility ctx.Data["RepoAdminChangeTeamAccess"] = ctx.Org.Organization.RepoAdminChangeTeamAccess + ctx.Data["IsHomepagePinned"] = ctx.Org.Organization.IsHomepagePinned ctx.Data["ContextUser"] = ctx.ContextUser if _, err := shared_user.RenderUserOrgHeader(ctx); err != nil { @@ -89,6 +90,14 @@ func SettingsPost(ctx *context.Context) { opts.MaxRepoCreation = optional.Some(form.MaxRepoCreation) } + // Handle homepage pinning (admin only) + if ctx.Doer.IsAdmin { + if err := org.SetHomepagePinned(ctx, form.IsHomepagePinned); err != nil { + ctx.ServerError("SetHomepagePinned", err) + return + } + } + if err := user_service.UpdateUser(ctx, org.AsUser(), opts); err != nil { ctx.ServerError("UpdateUser", err) return diff --git a/routers/web/web.go b/routers/web/web.go index decf6ef008..31b0eb5f5b 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -778,6 +778,8 @@ func registerWebRoutes(m *web.Router) { m.Post("/test_mail", admin.SendTestMail) m.Post("/test_cache", admin.TestCache) m.Get("/settings", admin.ConfigSettings) + m.Post("/theme/logo", admin.ChangeThemeLogo) + m.Post("/theme/icon", admin.ChangeThemeIcon) }) m.Group("/monitor", func() { diff --git a/services/forms/org.go b/services/forms/org.go index 3997e1da84..dae76a92b4 100644 --- a/services/forms/org.go +++ b/services/forms/org.go @@ -26,6 +26,7 @@ type CreateOrgForm struct { OrgName string `binding:"Required;Username;MaxSize(40)" locale:"org.org_name_holder"` Visibility structs.VisibleType RepoAdminChangeTeamAccess bool + IsHomepagePinned bool } // Validate validates the fields @@ -43,6 +44,7 @@ type UpdateOrgSettingForm struct { Location string `binding:"MaxSize(50)"` MaxRepoCreation int RepoAdminChangeTeamAccess bool + IsHomepagePinned bool } // Validate validates the fields diff --git a/templates/admin/config_settings/config_settings.tmpl b/templates/admin/config_settings/config_settings.tmpl index 1ef764a58b..d8cfc96d26 100644 --- a/templates/admin/config_settings/config_settings.tmpl +++ b/templates/admin/config_settings/config_settings.tmpl @@ -1,5 +1,7 @@ {{template "admin/layout_head" (dict "ctxData" . "pageClass" "admin config")}} +{{template "admin/config_settings/theme" .}} + {{template "admin/config_settings/avatars" .}} {{template "admin/config_settings/repository" .}} diff --git a/templates/admin/config_settings/theme.tmpl b/templates/admin/config_settings/theme.tmpl new file mode 100644 index 0000000000..871dffdb10 --- /dev/null +++ b/templates/admin/config_settings/theme.tmpl @@ -0,0 +1,120 @@ +
{{ctx.Locale.Tr "org.settings.pin_to_homepage_help"}}
+