Files
photoprism/internal/api/cluster_theme.go
2025-09-24 08:28:38 +02:00

164 lines
5.3 KiB
Go

package api
import (
"archive/zip"
gofs "io/fs"
"net"
"path/filepath"
"github.com/gin-gonic/gin"
"github.com/photoprism/photoprism/internal/auth/acl"
"github.com/photoprism/photoprism/internal/event"
"github.com/photoprism/photoprism/internal/photoprism/get"
"github.com/photoprism/photoprism/pkg/clean"
"github.com/photoprism/photoprism/pkg/fs"
"github.com/photoprism/photoprism/pkg/service/http/header"
)
// ClusterGetTheme returns custom theme files as zip, if available.
//
// @Summary returns custom theme files as zip, if available
// @Id ClusterGetTheme
// @Tags Cluster
// @Produce application/zip
// @Success 200 {file} application/zip
// @Failure 401,403,404,429 {object} i18n.Response
// @Router /api/v1/cluster/theme [get]
func ClusterGetTheme(router *gin.RouterGroup) {
router.GET("/cluster/theme", func(c *gin.Context) {
// Get app config and client IP.
conf := get.Config()
clientIp := ClientIP(c)
// Optional IP-based allowance via ClusterCIDR.
refID := "-"
if cidr := conf.ClusterCIDR(); cidr != "" {
if _, ipnet, err := net.ParseCIDR(cidr); err == nil {
if ip := net.ParseIP(clientIp); ip != nil && ipnet.Contains(ip) {
// Allowed by CIDR; proceed without session.
refID = "cidr"
}
}
}
// If not allowed by CIDR, require regular auth.
if refID == "-" {
s := Auth(c, acl.ResourceCluster, acl.ActionDownload)
if s.Abort(c) {
return
}
refID = s.RefID
}
/*
TODO - Consider the following optional hardening measures:
1. Track a hadError flag to log "partial success" if some files fail to zip.
2. Set limits (total size/entry count) in case theme directories grow unexpectedly.
3. Optionally, return a 404 or 204 error code when no files are added, though an empty zip file is acceptable.
*/
// Abort if this is not a portal server.
if !conf.IsPortal() {
AbortFeatureDisabled(c)
return
}
themePath := conf.ThemePath()
// Resolve symbolic links.
if resolved, err := filepath.EvalSymlinks(themePath); err != nil {
event.AuditWarn([]string{clientIp, "session %s", string(acl.ResourceCluster), "theme", "download", "failed to resolve path"}, refID, clean.Error(err))
AbortNotFound(c)
return
} else {
themePath = resolved
}
// Check if theme path exists.
if !fs.PathExists(themePath) {
event.AuditDebug([]string{clientIp, "session %s", string(acl.ResourceCluster), "theme", "download", "theme path not found"}, refID)
AbortNotFound(c)
return
}
// Require a non-empty app.js file to avoid distributing empty themes.
// This aligns with bootstrap behavior, which only installs a theme when
// app.js exists locally or can be fetched from the Portal.
if !fs.FileExistsNotEmpty(filepath.Join(themePath, "app.js")) {
event.AuditDebug([]string{clientIp, "session %s", string(acl.ResourceCluster), "theme", "download", "app.js missing or empty"}, refID)
AbortNotFound(c)
return
}
event.AuditDebug([]string{clientIp, "session %s", string(acl.ResourceCluster), "theme", "download", "creating theme archive from %s"}, refID, clean.Log(themePath))
// Add response headers.
AddDownloadHeader(c, "theme.zip")
AddContentTypeHeader(c, header.ContentTypeZip)
// Create zip writer to stream the theme files.
zipWriter := zip.NewWriter(c.Writer)
defer func(w *zip.Writer) {
if closeErr := w.Close(); closeErr != nil {
event.AuditWarn([]string{clientIp, "session %s", string(acl.ResourceCluster), "theme", "download", "failed to close", "%s"}, refID, clean.Error(closeErr))
}
}(zipWriter)
err := filepath.WalkDir(themePath, func(filePath string, info gofs.DirEntry, walkErr error) error {
// Handle errors.
if walkErr != nil {
event.AuditWarn([]string{clientIp, "session %s", string(acl.ResourceCluster), "theme", "download", "failed to traverse theme path", "%s"}, refID, clean.Error(walkErr))
// If the error occurs on a directory, skip descending to avoid cascading errors.
if info != nil && info.IsDir() {
return gofs.SkipDir
}
return nil
}
// Get file base name.
name := info.Name()
// Skip any subdirectories to enhance security.
if info.IsDir() {
if filePath != themePath {
return gofs.SkipDir
}
return nil
}
// Skip non-regular files and symlinks.
if !info.Type().IsRegular() || info.Type()&gofs.ModeSymlink != 0 {
return nil
}
// Skip hidden files by name.
if fs.FileNameHidden(name) {
return nil
}
// Get the relative file name to use as alias in the zip.
alias := filepath.ToSlash(fs.RelName(filePath, themePath))
event.AuditDebug([]string{clientIp, "session %s", string(acl.ResourceCluster), "theme", "download", "adding %s to archive"}, refID, clean.Log(alias))
// Stream zipped file contents.
if zipErr := fs.ZipFile(zipWriter, filePath, alias, false); zipErr != nil {
event.AuditWarn([]string{clientIp, "session %s", string(acl.ResourceCluster), "theme", "download", "failed to add %s", "%s"}, refID, clean.Log(alias), clean.Error(zipErr))
}
return nil
})
// Log result.
if err != nil {
event.AuditErr([]string{clientIp, "session %s", string(acl.ResourceCluster), "theme", "download", event.Failed, "%s"}, refID, clean.Error(err))
} else {
event.AuditInfo([]string{clientIp, "session %s", string(acl.ResourceCluster), "theme", "download", event.Succeeded}, refID)
}
})
}