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:
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user