diff --git a/modules/pages/config.go b/modules/pages/config.go index 018b1132fd..1c3e0be0ad 100644 --- a/modules/pages/config.go +++ b/modules/pages/config.go @@ -74,11 +74,29 @@ type LandingConfig struct { // BrandConfig represents brand/identity settings type BrandConfig struct { - Name string `yaml:"name,omitempty"` - LogoURL string `yaml:"logo_url,omitempty"` - LogoSource string `yaml:"logo_source,omitempty"` // "url" (default), "repo", or "org" — selects avatar source - Tagline string `yaml:"tagline,omitempty"` - FaviconURL string `yaml:"favicon_url,omitempty"` + Name string `yaml:"name,omitempty"` + LogoURL string `yaml:"logo_url,omitempty"` + UploadedLogo string `yaml:"uploaded_logo,omitempty"` + LogoSource string `yaml:"logo_source,omitempty"` // "url" (default), "repo", or "org" — selects avatar source + Tagline string `yaml:"tagline,omitempty"` + FaviconURL string `yaml:"favicon_url,omitempty"` + UploadedFavicon string `yaml:"uploaded_favicon,omitempty"` +} + +// ResolvedLogoURL returns the uploaded logo path or the external URL. +func (b *BrandConfig) ResolvedLogoURL() string { + if b.UploadedLogo != "" { + return "/repo-avatars/" + b.UploadedLogo + } + return b.LogoURL +} + +// ResolvedFaviconURL returns the uploaded favicon path or the external URL. +func (b *BrandConfig) ResolvedFaviconURL() string { + if b.UploadedFavicon != "" { + return "/repo-avatars/" + b.UploadedFavicon + } + return b.FaviconURL } // HeroConfig represents hero section settings diff --git a/options/locale/custom_keys.json b/options/locale/custom_keys.json index 70e2abf16d..a06306e17a 100644 --- a/options/locale/custom_keys.json +++ b/options/locale/custom_keys.json @@ -692,8 +692,27 @@ "repo.settings.pages.saved": "Settings saved successfully", "repo.settings.pages.brand_name": "Brand Name", "repo.settings.pages.brand_name_help": "The name displayed on your landing page", + "repo.settings.pages.brand_logo": "Logo", "repo.settings.pages.brand_logo_url": "Logo URL", "repo.settings.pages.brand_logo_url_help": "URL to your logo image (SVG or PNG)", + "repo.settings.pages.brand_upload_logo": "Upload Logo", + "repo.settings.pages.brand_delete_logo": "Delete Logo", + "repo.settings.pages.brand_logo_uploaded": "Logo uploaded successfully.", + "repo.settings.pages.brand_logo_deleted": "Logo deleted.", + "repo.settings.pages.brand_upload_btn": "Upload", + "repo.settings.pages.brand_upload_help": "Upload a JPG, PNG, WebP, or GIF image (max 5 MB). Uploaded images take priority over the URL field.", + "repo.settings.pages.brand_upload_error": "Failed to upload image.", + "repo.settings.pages.brand_image_too_large": "Image is too large. Maximum size is 5 MB.", + "repo.settings.pages.brand_not_an_image": "The uploaded file is not a valid image.", + "repo.settings.pages.brand_or": "or use a URL", + "repo.settings.pages.brand_favicon": "Favicon", + "repo.settings.pages.brand_favicon_url": "Favicon URL", + "repo.settings.pages.brand_favicon_url_help": "URL to a custom favicon for your landing page (ICO, PNG, or SVG). Leave blank to use the default.", + "repo.settings.pages.brand_upload_favicon": "Upload Favicon", + "repo.settings.pages.brand_delete_favicon": "Delete Favicon", + "repo.settings.pages.brand_favicon_uploaded": "Favicon uploaded successfully.", + "repo.settings.pages.brand_favicon_deleted": "Favicon deleted.", + "repo.settings.pages.brand_favicon_upload_help": "Upload an ICO, PNG, WebP, or GIF favicon (max 5 MB). Uploaded favicons take priority over the URL field.", "repo.settings.pages.brand_tagline": "Tagline", "repo.settings.pages.headline": "Headline", "repo.settings.pages.subheadline": "Subheadline", diff --git a/options/locale/locale_en-US.json b/options/locale/locale_en-US.json index 4d83c12a76..ee331c2da1 100644 --- a/options/locale/locale_en-US.json +++ b/options/locale/locale_en-US.json @@ -4459,8 +4459,19 @@ "repo.settings.pages.saved": "Settings saved successfully", "repo.settings.pages.brand_name": "Brand Name", "repo.settings.pages.brand_name_help": "The name displayed on your landing page", + "repo.settings.pages.brand_logo": "Logo", "repo.settings.pages.brand_logo_url": "Logo URL", "repo.settings.pages.brand_logo_url_help": "URL to your logo image (SVG or PNG)", + "repo.settings.pages.brand_upload_logo": "Upload Logo", + "repo.settings.pages.brand_delete_logo": "Delete Logo", + "repo.settings.pages.brand_logo_uploaded": "Logo uploaded successfully.", + "repo.settings.pages.brand_logo_deleted": "Logo deleted.", + "repo.settings.pages.brand_upload_btn": "Upload", + "repo.settings.pages.brand_upload_help": "Upload a JPG, PNG, WebP, or GIF image (max 5 MB). Uploaded images take priority over the URL field.", + "repo.settings.pages.brand_upload_error": "Failed to upload image.", + "repo.settings.pages.brand_image_too_large": "Image is too large. Maximum size is 5 MB.", + "repo.settings.pages.brand_not_an_image": "The uploaded file is not a valid image.", + "repo.settings.pages.brand_or": "or use a URL", "repo.settings.pages.brand_tagline": "Tagline", "repo.settings.pages.headline": "Headline", "repo.settings.pages.subheadline": "Subheadline", @@ -4513,8 +4524,14 @@ "repo.settings.pages.og_image": "Open Graph Image URL", "repo.settings.pages.use_media_kit_og": "Use Media Kit social card", "repo.settings.pages.use_media_kit_og_help": "Use the social card from your Media Kit settings as the Open Graph image instead of a custom URL.", + "repo.settings.pages.brand_favicon": "Favicon", "repo.settings.pages.brand_favicon_url": "Favicon URL", "repo.settings.pages.brand_favicon_url_help": "URL to a custom favicon for your landing page (ICO, PNG, or SVG). Leave blank to use the default.", + "repo.settings.pages.brand_upload_favicon": "Upload Favicon", + "repo.settings.pages.brand_delete_favicon": "Delete Favicon", + "repo.settings.pages.brand_favicon_uploaded": "Favicon uploaded successfully.", + "repo.settings.pages.brand_favicon_deleted": "Favicon deleted.", + "repo.settings.pages.brand_favicon_upload_help": "Upload an ICO, PNG, WebP, or GIF favicon (max 5 MB). Uploaded favicons take priority over the URL field.", "repo.settings.pages.navigation": "Navigation Links", "repo.settings.pages.navigation_desc": "Control which built-in links appear in the header and footer navigation.", "repo.settings.pages.nav_show_docs": "Show Docs link (links to wiki)", diff --git a/routers/web/pages/pages.go b/routers/web/pages/pages.go index 9f4285dcec..dd64068d9c 100644 --- a/routers/web/pages/pages.go +++ b/routers/web/pages/pages.go @@ -297,7 +297,7 @@ func resolveLogoURL(ctx *context.Context, repo *repo_model.Repository, config *p } return repo.Owner.AvatarLink(ctx) default: // "url" or empty - return config.Brand.LogoURL + return config.Brand.ResolvedLogoURL() } } diff --git a/routers/web/repo/setting/pages.go b/routers/web/repo/setting/pages.go index 675779647d..14dfe6dc68 100644 --- a/routers/web/repo/setting/pages.go +++ b/routers/web/repo/setting/pages.go @@ -246,9 +246,13 @@ func PagesBrand(ctx *context.Context) { func PagesBrandPost(ctx *context.Context) { config := getPagesLandingConfig(ctx) config.Brand.Name = ctx.FormString("brand_name") - config.Brand.LogoURL = ctx.FormString("brand_logo_url") config.Brand.Tagline = ctx.FormString("brand_tagline") - config.Brand.FaviconURL = ctx.FormString("brand_favicon_url") + if config.Brand.UploadedLogo == "" { + config.Brand.LogoURL = ctx.FormString("brand_logo_url") + } + if config.Brand.UploadedFavicon == "" { + config.Brand.FaviconURL = ctx.FormString("brand_favicon_url") + } if err := savePagesLandingConfig(ctx, config); err != nil { ctx.ServerError("SavePagesConfig", err) return @@ -257,6 +261,160 @@ func PagesBrandPost(ctx *context.Context) { ctx.Redirect(ctx.Repo.Repository.Link() + "/settings/pages/brand") } +const maxBrandImageSize = 5 * 1024 * 1024 // 5 MB + +// PagesBrandUploadLogo handles logo image file upload. +func PagesBrandUploadLogo(ctx *context.Context) { + repo := ctx.Repo.Repository + + file, header, err := ctx.Req.FormFile("brand_logo") + if err != nil { + ctx.Flash.Error(ctx.Tr("repo.settings.pages.brand_upload_error")) + ctx.Redirect(repo.Link() + "/settings/pages/brand") + return + } + defer file.Close() + + if header.Size > maxBrandImageSize { + ctx.Flash.Error(ctx.Tr("repo.settings.pages.brand_image_too_large")) + ctx.Redirect(repo.Link() + "/settings/pages/brand") + return + } + + data, err := io.ReadAll(file) + if err != nil { + ctx.ServerError("ReadAll", err) + return + } + + st := typesniffer.DetectContentType(data) + if !(st.IsImage() && !st.IsSvgImage()) { + ctx.Flash.Error(ctx.Tr("repo.settings.pages.brand_not_an_image")) + ctx.Redirect(repo.Link() + "/settings/pages/brand") + return + } + + hash := sha256.Sum256(data) + filename := fmt.Sprintf("pages-logo-%d-%x", repo.ID, hash[:8]) + + config := getPagesLandingConfig(ctx) + + if config.Brand.UploadedLogo != "" { + _ = storage.RepoAvatars.Delete(config.Brand.UploadedLogo) + } + + if err := storage.SaveFrom(storage.RepoAvatars, filename, func(w io.Writer) error { + _, err := w.Write(data) + return err + }); err != nil { + ctx.ServerError("SaveFrom", err) + return + } + + config.Brand.UploadedLogo = filename + if err := savePagesLandingConfig(ctx, config); err != nil { + ctx.ServerError("SavePagesConfig", err) + return + } + + ctx.Flash.Success(ctx.Tr("repo.settings.pages.brand_logo_uploaded")) + ctx.Redirect(repo.Link() + "/settings/pages/brand") +} + +// PagesBrandDeleteLogo removes the uploaded logo image. +func PagesBrandDeleteLogo(ctx *context.Context) { + repo := ctx.Repo.Repository + config := getPagesLandingConfig(ctx) + + if config.Brand.UploadedLogo != "" { + _ = storage.RepoAvatars.Delete(config.Brand.UploadedLogo) + config.Brand.UploadedLogo = "" + if err := savePagesLandingConfig(ctx, config); err != nil { + ctx.ServerError("SavePagesConfig", err) + return + } + } + + ctx.Flash.Success(ctx.Tr("repo.settings.pages.brand_logo_deleted")) + ctx.Redirect(repo.Link() + "/settings/pages/brand") +} + +// PagesBrandUploadFavicon handles favicon file upload. +func PagesBrandUploadFavicon(ctx *context.Context) { + repo := ctx.Repo.Repository + + file, header, err := ctx.Req.FormFile("brand_favicon") + if err != nil { + ctx.Flash.Error(ctx.Tr("repo.settings.pages.brand_upload_error")) + ctx.Redirect(repo.Link() + "/settings/pages/brand") + return + } + defer file.Close() + + if header.Size > maxBrandImageSize { + ctx.Flash.Error(ctx.Tr("repo.settings.pages.brand_image_too_large")) + ctx.Redirect(repo.Link() + "/settings/pages/brand") + return + } + + data, err := io.ReadAll(file) + if err != nil { + ctx.ServerError("ReadAll", err) + return + } + + st := typesniffer.DetectContentType(data) + if !(st.IsImage() && !st.IsSvgImage()) { + ctx.Flash.Error(ctx.Tr("repo.settings.pages.brand_not_an_image")) + ctx.Redirect(repo.Link() + "/settings/pages/brand") + return + } + + hash := sha256.Sum256(data) + filename := fmt.Sprintf("pages-favicon-%d-%x", repo.ID, hash[:8]) + + config := getPagesLandingConfig(ctx) + + if config.Brand.UploadedFavicon != "" { + _ = storage.RepoAvatars.Delete(config.Brand.UploadedFavicon) + } + + if err := storage.SaveFrom(storage.RepoAvatars, filename, func(w io.Writer) error { + _, err := w.Write(data) + return err + }); err != nil { + ctx.ServerError("SaveFrom", err) + return + } + + config.Brand.UploadedFavicon = filename + if err := savePagesLandingConfig(ctx, config); err != nil { + ctx.ServerError("SavePagesConfig", err) + return + } + + ctx.Flash.Success(ctx.Tr("repo.settings.pages.brand_favicon_uploaded")) + ctx.Redirect(repo.Link() + "/settings/pages/brand") +} + +// PagesBrandDeleteFavicon removes the uploaded favicon. +func PagesBrandDeleteFavicon(ctx *context.Context) { + repo := ctx.Repo.Repository + config := getPagesLandingConfig(ctx) + + if config.Brand.UploadedFavicon != "" { + _ = storage.RepoAvatars.Delete(config.Brand.UploadedFavicon) + config.Brand.UploadedFavicon = "" + if err := savePagesLandingConfig(ctx, config); err != nil { + ctx.ServerError("SavePagesConfig", err) + return + } + } + + ctx.Flash.Success(ctx.Tr("repo.settings.pages.brand_favicon_deleted")) + ctx.Redirect(repo.Link() + "/settings/pages/brand") +} + func PagesHero(ctx *context.Context) { ctx.Data["Title"] = ctx.Tr("repo.settings.pages.hero") ctx.Data["PageIsSettingsPages"] = true diff --git a/routers/web/web.go b/routers/web/web.go index 5ce2533439..a391705323 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -1333,6 +1333,10 @@ func registerWebRoutes(m *web.Router) { m.Group("/pages", func() { m.Combo("").Get(repo_setting.Pages).Post(repo_setting.PagesPost) m.Combo("/brand").Get(repo_setting.PagesBrand).Post(repo_setting.PagesBrandPost) + m.Post("/brand/upload_logo", repo_setting.PagesBrandUploadLogo) + m.Post("/brand/delete_logo", repo_setting.PagesBrandDeleteLogo) + m.Post("/brand/upload_favicon", repo_setting.PagesBrandUploadFavicon) + m.Post("/brand/delete_favicon", repo_setting.PagesBrandDeleteFavicon) m.Combo("/hero").Get(repo_setting.PagesHero).Post(repo_setting.PagesHeroPost) m.Post("/hero/upload_image", repo_setting.PagesHeroUploadImage) m.Post("/hero/delete_image", repo_setting.PagesHeroDeleteImage) diff --git a/templates/pages/base_head.tmpl b/templates/pages/base_head.tmpl index f7e50c0ae8..2265aee553 100644 --- a/templates/pages/base_head.tmpl +++ b/templates/pages/base_head.tmpl @@ -22,8 +22,8 @@ {{if .LangSwitcherEnabled}}{{range .AvailableLanguages}} {{end}} {{end}} - {{if .Config.Brand.FaviconURL}} - + {{if or .Config.Brand.UploadedFavicon .Config.Brand.FaviconURL}} + {{else}} {{end}} diff --git a/templates/repo/settings/pages_brand.tmpl b/templates/repo/settings/pages_brand.tmpl index 8d8d0f9470..b74091e896 100644 --- a/templates/repo/settings/pages_brand.tmpl +++ b/templates/repo/settings/pages_brand.tmpl @@ -11,20 +11,74 @@

{{ctx.Locale.Tr "repo.settings.pages.brand_name_help"}}

+
{{ctx.Locale.Tr "repo.settings.pages.brand_logo"}}
+ {{if .Config.Brand.UploadedLogo}} +
+ +
+
+ {{$.CsrfTokenHtml}} + +
+
+
+ {{else}} +
+ +
+ {{$.CsrfTokenHtml}} + + +
+

{{ctx.Locale.Tr "repo.settings.pages.brand_upload_help"}}

+
+
{{ctx.Locale.Tr "repo.settings.pages.brand_or"}}

{{ctx.Locale.Tr "repo.settings.pages.brand_logo_url_help"}}

+ {{end}}
+
{{ctx.Locale.Tr "repo.settings.pages.brand_favicon"}}
+ {{if .Config.Brand.UploadedFavicon}} +
+ +
+
+ {{$.CsrfTokenHtml}} + +
+
+
+ {{else}} +
+ +
+ {{$.CsrfTokenHtml}} + + +
+

{{ctx.Locale.Tr "repo.settings.pages.brand_favicon_upload_help"}}

+
+
{{ctx.Locale.Tr "repo.settings.pages.brand_or"}}

{{ctx.Locale.Tr "repo.settings.pages.brand_favicon_url_help"}}

+ {{end}}