mirror of
https://github.com/photoprism/photoprism.git
synced 2025-12-11 16:24:11 +01:00
238 lines
7.2 KiB
Go
238 lines
7.2 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/config"
|
|
"github.com/photoprism/photoprism/internal/entity"
|
|
"github.com/photoprism/photoprism/internal/event"
|
|
"github.com/photoprism/photoprism/internal/photoprism/get"
|
|
reg "github.com/photoprism/photoprism/internal/service/cluster/registry"
|
|
"github.com/photoprism/photoprism/internal/service/cluster/theme"
|
|
"github.com/photoprism/photoprism/pkg/clean"
|
|
"github.com/photoprism/photoprism/pkg/fs"
|
|
"github.com/photoprism/photoprism/pkg/http/header"
|
|
"github.com/photoprism/photoprism/pkg/log/status"
|
|
)
|
|
|
|
// 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 := "-"
|
|
var session *entity.Session
|
|
|
|
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
|
|
session = s
|
|
}
|
|
|
|
/*
|
|
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.Portal() {
|
|
AbortFeatureDisabled(c)
|
|
return
|
|
}
|
|
|
|
themePath := conf.PortalThemePath()
|
|
|
|
// Resolve symbolic links.
|
|
if resolved, err := filepath.EvalSymlinks(themePath); err != nil {
|
|
event.AuditWarn([]string{clientIp, "session %s", string(acl.ResourceCluster), "download theme resolve", status.Error(err)}, refID)
|
|
AbortNotFound(c)
|
|
return
|
|
} else {
|
|
themePath = resolved
|
|
}
|
|
|
|
// Check if theme path exists.
|
|
if !fs.PathExists(themePath) {
|
|
event.AuditDebug([]string{clientIp, "session %s", string(acl.ResourceCluster), "download theme path", status.NotFound}, 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), "download theme app.js", status.NotFound}, refID)
|
|
AbortNotFound(c)
|
|
return
|
|
}
|
|
|
|
event.AuditDebug([]string{clientIp, "session %s", string(acl.ResourceCluster), "download theme create archive", "%s", "started"}, refID, clean.Log(themePath))
|
|
|
|
if version, err := theme.DetectVersion(themePath); err == nil {
|
|
updateNodeThemeVersion(conf, session, version, clientIp, refID)
|
|
} else {
|
|
event.AuditDebug([]string{clientIp, "session %s", string(acl.ResourceCluster), "detect theme version", "%s", status.Failed}, refID, clean.Error(err))
|
|
}
|
|
|
|
// 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), "download theme close", status.Error(closeErr)}, refID)
|
|
}
|
|
}(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), "download theme traverse", status.Error(walkErr)}, refID)
|
|
|
|
// 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), "download theme add", "%s", status.Added}, 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), "download theme add %s", status.Error(zipErr)}, refID, clean.Log(alias))
|
|
}
|
|
|
|
return nil
|
|
})
|
|
|
|
// Log result.
|
|
if err != nil {
|
|
event.AuditErr([]string{clientIp, "session %s", string(acl.ResourceCluster), "download theme", status.Error(err)}, refID)
|
|
} else {
|
|
event.AuditInfo([]string{clientIp, "session %s", string(acl.ResourceCluster), "download theme", status.Succeeded}, refID)
|
|
}
|
|
})
|
|
}
|
|
|
|
// updateNodeThemeVersion persists the reported theme version for the active
|
|
// node when the request is authenticated as a cluster client.
|
|
func updateNodeThemeVersion(conf *config.Config, session *entity.Session, version, clientIP, refID string) {
|
|
if conf == nil || session == nil {
|
|
return
|
|
}
|
|
|
|
normalized := clean.TypeUnicode(version)
|
|
|
|
if normalized == "" {
|
|
return
|
|
}
|
|
|
|
client := session.GetClient()
|
|
|
|
if client == nil || client.ClientUID == "" {
|
|
return
|
|
}
|
|
|
|
regy, err := reg.NewClientRegistryWithConfig(conf)
|
|
|
|
if err != nil {
|
|
event.AuditDebug([]string{clientIP, "session %s", string(acl.ResourceCluster), "theme metadata registry", "%s", status.Failed}, refID, clean.Error(err))
|
|
return
|
|
}
|
|
|
|
var node *reg.Node
|
|
|
|
if client.NodeUUID != "" {
|
|
if n, err := regy.Get(client.NodeUUID); err == nil {
|
|
node = n
|
|
}
|
|
}
|
|
|
|
if node == nil {
|
|
if n, err := regy.FindByClientID(client.ClientUID); err == nil {
|
|
node = n
|
|
}
|
|
}
|
|
|
|
if node == nil {
|
|
event.AuditDebug([]string{clientIP, "session %s", string(acl.ResourceCluster), "theme metadata node", status.Skipped}, refID)
|
|
return
|
|
}
|
|
|
|
if node.Theme == normalized {
|
|
return
|
|
}
|
|
|
|
node.Theme = normalized
|
|
|
|
if err = regy.Put(node); err != nil {
|
|
event.AuditWarn([]string{clientIP, "session %s", string(acl.ResourceCluster), "theme metadata", status.Error(err)}, refID)
|
|
return
|
|
}
|
|
|
|
event.AuditDebug([]string{clientIP, "session %s", string(acl.ResourceCluster), "theme metadata", status.Updated}, refID)
|
|
}
|