feat: add support for custom favicons (#4770)

* feat: add support for custom favicons

* Commit updated API documentation

* chore(i18n): add localization of text

* fix(js): regenerate package lock file

* chore(test): add favicon test

* fix: max size not respected

* Commit updated API documentation

* fix: move favicon.ico to the static dir

* fix: fix tests

* fix: remove hard-coded content-type of icon

* chore: update extracted translations

* feat(admin): add support for resetting to default icon

* chore: use a higher res default favicon

* Commit updated API documentation

---------

Co-authored-by: Owncast <owncast@owncast.online>
This commit is contained in:
Gabe Kangas
2026-01-28 18:07:19 -08:00
committed by GitHub
co-authored by Owncast
parent f8560db614
commit 6abaf20f2b
77 changed files with 981 additions and 177 deletions
+95
View File
@@ -3,6 +3,7 @@ package admin
import (
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"net/netip"
@@ -282,6 +283,100 @@ func SetLogo(w http.ResponseWriter, r *http.Request) {
webutils.WriteSimpleResponse(w, true, "changed")
}
// SetFavicon will handle a new favicon image file being uploaded.
func SetFavicon(w http.ResponseWriter, r *http.Request) {
if !requirePOST(w, r) {
return
}
// Parse multipart form with 200KB max
if err := r.ParseMultipartForm(200 << 10); err != nil {
webutils.WriteSimpleResponse(w, false, "no file provided")
return
}
file, header, err := r.FormFile("favicon")
if err != nil {
webutils.WriteSimpleResponse(w, false, "no file provided")
return
}
defer file.Close()
// Validate content type (PNG or ICO only)
contentType := header.Header.Get("Content-Type")
var extension string
switch contentType {
case "image/png":
extension = ".png"
case "image/x-icon", "image/vnd.microsoft.icon":
extension = ".ico"
default:
webutils.WriteSimpleResponse(w, false, "favicon must be PNG or ICO format")
return
}
// Read file content
bytes, err := io.ReadAll(file)
if err != nil {
webutils.WriteSimpleResponse(w, false, "unable to read file")
return
}
// Enforce 200KB size limit
const maxFaviconSize = 200 * 1024 // 200KB
if len(bytes) > maxFaviconSize {
webutils.WriteSimpleResponse(w, false, "file too large, max 200KB")
return
}
imgPath := filepath.Join("data", "favicon"+extension)
if err := os.WriteFile(imgPath, bytes, 0o600); err != nil {
webutils.WriteSimpleResponse(w, false, err.Error())
return
}
configRepository := configrepository.Get()
if err := configRepository.SetFaviconPath("favicon" + extension); err != nil {
webutils.WriteSimpleResponse(w, false, err.Error())
return
}
webutils.WriteSimpleResponse(w, true, "favicon updated")
}
// ResetFavicon will reset the favicon to the default by removing the custom one.
func ResetFavicon(w http.ResponseWriter, r *http.Request) {
configRepository := configrepository.Get()
// Get the current favicon path before clearing it
currentFavicon := configRepository.GetFaviconPath()
// Clear the favicon path in the database
if err := configRepository.SetFaviconPath(""); err != nil {
webutils.WriteSimpleResponse(w, false, err.Error())
return
}
// Delete the favicon file if it exists
if currentFavicon != "" {
faviconPath := filepath.Join("data", currentFavicon)
if err := os.Remove(faviconPath); err != nil && !os.IsNotExist(err) {
log.Debugln("error removing favicon file:", err)
}
}
// Also try to remove any favicon files that might exist with different extensions
for _, ext := range []string{".ico", ".png"} {
faviconPath := filepath.Join("data", "favicon"+ext)
if err := os.Remove(faviconPath); err != nil && !os.IsNotExist(err) {
log.Debugln("error removing favicon file:", err)
}
}
webutils.WriteSimpleResponse(w, true, "favicon reset to default")
}
// SetNSFW will handle the web config request to set the NSFW flag.
func SetNSFW(w http.ResponseWriter, r *http.Request) {
if !requirePOST(w, r) {
+12
View File
@@ -175,6 +175,18 @@ func (*ServerInterfaceImpl) SetLogoOptions(w http.ResponseWriter, r *http.Reques
middleware.RequireAdminAuth(admin.SetLogo)(w, r)
}
func (*ServerInterfaceImpl) SetFavicon(w http.ResponseWriter, r *http.Request) {
middleware.RequireAdminAuth(admin.SetFavicon)(w, r)
}
func (*ServerInterfaceImpl) SetFaviconOptions(w http.ResponseWriter, r *http.Request) {
middleware.RequireAdminAuth(admin.SetFavicon)(w, r)
}
func (*ServerInterfaceImpl) ResetFavicon(w http.ResponseWriter, r *http.Request) {
middleware.RequireAdminAuth(admin.ResetFavicon)(w, r)
}
func (*ServerInterfaceImpl) SetTags(w http.ResponseWriter, r *http.Request) {
middleware.RequireAdminAuth(admin.SetTags)(w, r)
}
+55
View File
@@ -0,0 +1,55 @@
package handlers
import (
"net/http"
"os"
"path/filepath"
"strconv"
"github.com/owncast/owncast/config"
"github.com/owncast/owncast/persistence/configrepository"
"github.com/owncast/owncast/static"
"github.com/owncast/owncast/utils"
log "github.com/sirupsen/logrus"
)
// GetFavicon will return the favicon image as a response.
func GetFavicon(w http.ResponseWriter, r *http.Request) {
configRepository := configrepository.Get()
faviconFilename := configRepository.GetFaviconPath()
if faviconFilename == "" {
returnDefaultFavicon(w)
return
}
faviconPath := filepath.Join(config.DataDirectory, faviconFilename)
faviconBytes, err := os.ReadFile(faviconPath) //nolint:gosec
if err != nil {
returnDefaultFavicon(w)
return
}
contentType := "image/x-icon"
if filepath.Ext(faviconFilename) == ".png" {
contentType = "image/png" //nolint:goconst
}
cacheTime := utils.GetCacheDurationSecondsForPath(faviconPath)
writeFaviconResponse(faviconBytes, contentType, w, cacheTime)
}
func returnDefaultFavicon(w http.ResponseWriter) {
faviconBytes := static.GetFavicon()
cacheTime := utils.GetCacheDurationSecondsForPath("favicon.png")
writeFaviconResponse(faviconBytes, "image/png", w, cacheTime)
}
func writeFaviconResponse(data []byte, contentType string, w http.ResponseWriter, cacheSeconds int) {
w.Header().Set("Content-Type", contentType)
w.Header().Set("Content-Length", strconv.Itoa(len(data)))
w.Header().Set("Cache-Control", "public, max-age="+strconv.Itoa(cacheSeconds))
if _, err := w.Write(data); err != nil {
log.Println("unable to write favicon.")
}
}
@@ -8,6 +8,7 @@ import (
"time"
"github.com/oapi-codegen/runtime"
openapi_types "github.com/oapi-codegen/runtime/types"
)
const (
@@ -569,26 +570,29 @@ type Viewer struct {
// WebConfig defines model for WebConfig.
type WebConfig struct {
AppearanceVariables *map[string]string `json:"appearanceVariables,omitempty"`
Authentication *AuthenticationConfig `json:"authentication,omitempty"`
ChatDisabled *bool `json:"chatDisabled,omitempty"`
CustomStyles *string `json:"customStyles,omitempty"`
ExternalActions *[]ExternalAction `json:"externalActions,omitempty"`
ExtraPageContent *string `json:"extraPageContent,omitempty"`
Federation *FederationConfig `json:"federation,omitempty"`
HideViewerCount *bool `json:"hideViewerCount,omitempty"`
Logo *string `json:"logo,omitempty"`
MaxSocketPayloadSize *int `json:"maxSocketPayloadSize,omitempty"`
Name *string `json:"name,omitempty"`
Notifications *NotificationConfig `json:"notifications,omitempty"`
Nsfw *bool `json:"nsfw,omitempty"`
OfflineMessage *string `json:"offlineMessage,omitempty"`
SocialHandles *[]SocialHandle `json:"socialHandles,omitempty"`
SocketHostOverride *string `json:"socketHostOverride,omitempty"`
StreamTitle *string `json:"streamTitle,omitempty"`
Summary *string `json:"summary,omitempty"`
Tags *[]string `json:"tags,omitempty"`
Version *string `json:"version,omitempty"`
AppearanceVariables *map[string]string `json:"appearanceVariables,omitempty"`
Authentication *AuthenticationConfig `json:"authentication,omitempty"`
ChatDisabled *bool `json:"chatDisabled,omitempty"`
// ChatRequireAuthentication Whether users must authenticate before sending chat messages
ChatRequireAuthentication *bool `json:"chatRequireAuthentication,omitempty"`
CustomStyles *string `json:"customStyles,omitempty"`
ExternalActions *[]ExternalAction `json:"externalActions,omitempty"`
ExtraPageContent *string `json:"extraPageContent,omitempty"`
Federation *FederationConfig `json:"federation,omitempty"`
HideViewerCount *bool `json:"hideViewerCount,omitempty"`
Logo *string `json:"logo,omitempty"`
MaxSocketPayloadSize *int `json:"maxSocketPayloadSize,omitempty"`
Name *string `json:"name,omitempty"`
Notifications *NotificationConfig `json:"notifications,omitempty"`
Nsfw *bool `json:"nsfw,omitempty"`
OfflineMessage *string `json:"offlineMessage,omitempty"`
SocialHandles *[]SocialHandle `json:"socialHandles,omitempty"`
SocketHostOverride *string `json:"socketHostOverride,omitempty"`
StreamTitle *string `json:"streamTitle,omitempty"`
Summary *string `json:"summary,omitempty"`
Tags *[]string `json:"tags,omitempty"`
Version *string `json:"version,omitempty"`
}
// Webhook defines model for Webhook.
@@ -695,6 +699,12 @@ type SetExternalActionsJSONBody struct {
Value *[]ExternalAction `json:"value,omitempty"`
}
// SetFaviconMultipartBody defines parameters for SetFavicon.
type SetFaviconMultipartBody struct {
// Favicon Favicon file (PNG or ICO, max 200KB)
Favicon *openapi_types.File `json:"favicon,omitempty"`
}
// SetBrowserNotificationConfigurationJSONBody defines parameters for SetBrowserNotificationConfiguration.
type SetBrowserNotificationConfigurationJSONBody struct {
Value *BrowserNotificationConfiguration `json:"value,omitempty"`
@@ -945,6 +955,9 @@ type SetDisableSearchIndexingJSONRequestBody = AdminConfigValue
// SetExternalActionsJSONRequestBody defines body for SetExternalActions for application/json ContentType.
type SetExternalActionsJSONRequestBody SetExternalActionsJSONBody
// SetFaviconMultipartRequestBody defines body for SetFavicon for multipart/form-data ContentType.
type SetFaviconMultipartRequestBody SetFaviconMultipartBody
// SetFederationBlockDomainsJSONRequestBody defines body for SetFederationBlockDomains for application/json ContentType.
type SetFederationBlockDomainsJSONRequestBody = AdminConfigValue
@@ -182,6 +182,15 @@ type ServerInterface interface {
// Update external action links
// (POST /admin/config/externalactions)
SetExternalActions(w http.ResponseWriter, r *http.Request)
// Reset favicon to default
// (DELETE /admin/config/favicon)
ResetFavicon(w http.ResponseWriter, r *http.Request)
// (OPTIONS /admin/config/favicon)
SetFaviconOptions(w http.ResponseWriter, r *http.Request)
// Upload custom favicon
// (POST /admin/config/favicon)
SetFavicon(w http.ResponseWriter, r *http.Request)
// (OPTIONS /admin/config/federation/blockdomains)
SetFederationBlockDomainsOptions(w http.ResponseWriter, r *http.Request)
@@ -970,6 +979,23 @@ func (_ Unimplemented) SetExternalActions(w http.ResponseWriter, r *http.Request
w.WriteHeader(http.StatusNotImplemented)
}
// Reset favicon to default
// (DELETE /admin/config/favicon)
func (_ Unimplemented) ResetFavicon(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotImplemented)
}
// (OPTIONS /admin/config/favicon)
func (_ Unimplemented) SetFaviconOptions(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotImplemented)
}
// Upload custom favicon
// (POST /admin/config/favicon)
func (_ Unimplemented) SetFavicon(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotImplemented)
}
// (OPTIONS /admin/config/federation/blockdomains)
func (_ Unimplemented) SetFederationBlockDomainsOptions(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotImplemented)
@@ -2758,6 +2784,57 @@ func (siw *ServerInterfaceWrapper) SetExternalActions(w http.ResponseWriter, r *
handler.ServeHTTP(w, r)
}
// ResetFavicon operation middleware
func (siw *ServerInterfaceWrapper) ResetFavicon(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
ctx = context.WithValue(ctx, BasicAuthScopes, []string{})
r = r.WithContext(ctx)
handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
siw.Handler.ResetFavicon(w, r)
}))
for _, middleware := range siw.HandlerMiddlewares {
handler = middleware(handler)
}
handler.ServeHTTP(w, r)
}
// SetFaviconOptions operation middleware
func (siw *ServerInterfaceWrapper) SetFaviconOptions(w http.ResponseWriter, r *http.Request) {
handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
siw.Handler.SetFaviconOptions(w, r)
}))
for _, middleware := range siw.HandlerMiddlewares {
handler = middleware(handler)
}
handler.ServeHTTP(w, r)
}
// SetFavicon operation middleware
func (siw *ServerInterfaceWrapper) SetFavicon(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
ctx = context.WithValue(ctx, BasicAuthScopes, []string{})
r = r.WithContext(ctx)
handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
siw.Handler.SetFavicon(w, r)
}))
for _, middleware := range siw.HandlerMiddlewares {
handler = middleware(handler)
}
handler.ServeHTTP(w, r)
}
// SetFederationBlockDomainsOptions operation middleware
func (siw *ServerInterfaceWrapper) SetFederationBlockDomainsOptions(w http.ResponseWriter, r *http.Request) {
handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -5852,6 +5929,15 @@ func HandlerWithOptions(si ServerInterface, options ChiServerOptions) http.Handl
r.Group(func(r chi.Router) {
r.Post(options.BaseURL+"/admin/config/externalactions", wrapper.SetExternalActions)
})
r.Group(func(r chi.Router) {
r.Delete(options.BaseURL+"/admin/config/favicon", wrapper.ResetFavicon)
})
r.Group(func(r chi.Router) {
r.Options(options.BaseURL+"/admin/config/favicon", wrapper.SetFaviconOptions)
})
r.Group(func(r chi.Router) {
r.Post(options.BaseURL+"/admin/config/favicon", wrapper.SetFavicon)
})
r.Group(func(r chi.Router) {
r.Options(options.BaseURL+"/admin/config/federation/blockdomains", wrapper.SetFederationBlockDomainsOptions)
})
+1 -1
View File
@@ -36,7 +36,7 @@ func GetLogo(w http.ResponseWriter, r *http.Request) {
} else if filepath.Ext(imageFilename) == ".gif" {
contentType = "image/gif"
} else if filepath.Ext(imageFilename) == ".png" {
contentType = "image/png"
contentType = "image/png" //nolint:goconst
}
cacheTime := utils.GetCacheDurationSecondsForPath(imagePath)