Files
seanime-docker/seanime-2.9.10/internal/extension/plugin.go
2025-09-20 14:08:38 +01:00

400 lines
11 KiB
Go

package extension
import (
"crypto/sha256"
"fmt"
"strings"
)
const (
PluginManifestVersion = "1"
)
var (
PluginPermissionStorage PluginPermissionScope = "storage" // Allows the plugin to store its own data
PluginPermissionDatabase PluginPermissionScope = "database" // Allows the plugin to read non-auth data from the database and write to it
PluginPermissionPlayback PluginPermissionScope = "playback" // Allows the plugin to use the playback manager
PluginPermissionAnilist PluginPermissionScope = "anilist" // Allows the plugin to use the Anilist client
PluginPermissionAnilistToken PluginPermissionScope = "anilist-token" // Allows the plugin to see and use the Anilist token
PluginPermissionSystem PluginPermissionScope = "system" // Allows the plugin to use the OS/Filesystem/Filepath functions. SystemPermissions must be granted additionally.
PluginPermissionCron PluginPermissionScope = "cron" // Allows the plugin to use the cron manager
PluginPermissionNotification PluginPermissionScope = "notification" // Allows the plugin to use the notification manager
PluginPermissionDiscord PluginPermissionScope = "discord" // Allows the plugin to use the discord rpc
PluginPermissionTorrentClient PluginPermissionScope = "torrent-client" // Allows the plugin to use the torrent client
)
type PluginManifest struct {
Version string `json:"version"`
// Permissions is a list of permissions that the plugin is asking for.
// The user must acknowledge these permissions before the plugin can be loaded.
Permissions PluginPermissions `json:"permissions,omitempty"`
}
type PluginPermissions struct {
Scopes []PluginPermissionScope `json:"scopes,omitempty"`
Allow PluginAllowlist `json:"allow,omitempty"`
}
// PluginAllowlist is a list of system permissions that the plugin is asking for.
//
// The user must acknowledge these permissions before the plugin can be loaded.
type PluginAllowlist struct {
// ReadPaths is a list of paths that the plugin is allowed to read from.
ReadPaths []string `json:"readPaths,omitempty"`
// WritePaths is a list of paths that the plugin is allowed to write to.
WritePaths []string `json:"writePaths,omitempty"`
// CommandScopes defines the commands that the plugin is allowed to execute.
// Each command scope has a unique identifier and configuration.
CommandScopes []CommandScope `json:"commandScopes,omitempty"`
}
// CommandScope defines a specific command or set of commands that can be executed
// with specific arguments and validation rules.
type CommandScope struct {
// Description explains why this command scope is needed
Description string `json:"description,omitempty"`
// Command is the executable program
Command string `json:"command"`
// Args defines the allowed arguments for this command
// If nil or empty, no arguments are allowed
// If contains "$ARGS", any arguments are allowed at that position
Args []CommandArg `json:"args,omitempty"`
}
// CommandArg represents an argument for a command
type CommandArg struct {
// Value is the fixed argument value
// If empty, Validator must be set
Value string `json:"value,omitempty"`
// Validator is a Perl compatible regex pattern to validate dynamic argument values
// Special values:
// - "$ARGS" allows any arguments at this position
// - "$PATH" allows any valid file path
Validator string `json:"validator,omitempty"`
}
// ReadAllowCommands returns a human-readable representation of the commands
// that the plugin is allowed to execute.
func (p *PluginAllowlist) ReadAllowCommands() []string {
if p == nil {
return []string{}
}
result := make([]string, 0)
// Add commands from CommandScopes
if len(p.CommandScopes) > 0 {
for _, scope := range p.CommandScopes {
cmd := scope.Command
// Build argument string
args := ""
for i, arg := range scope.Args {
if i > 0 {
args += " "
}
if arg.Value != "" {
args += arg.Value
} else if arg.Validator == "$ARGS" {
args += "[any arguments]"
} else if arg.Validator == "$PATH" {
args += "[any path]"
} else if arg.Validator != "" {
args += "[matching: " + arg.Validator + "]"
}
}
if args != "" {
cmd += " " + args
}
// Add description if available
if scope.Description != "" {
cmd += " - " + scope.Description
}
result = append(result, cmd)
}
}
return result
}
func (p *PluginPermissions) GetHash() string {
if p == nil {
return ""
}
if len(p.Scopes) == 0 &&
len(p.Allow.ReadPaths) == 0 &&
len(p.Allow.WritePaths) == 0 &&
len(p.Allow.CommandScopes) == 0 {
return ""
}
h := sha256.New()
// Hash scopes
for _, scope := range p.Scopes {
h.Write([]byte(scope))
}
// Hash allowlist read paths
for _, path := range p.Allow.ReadPaths {
h.Write([]byte("read:" + path))
}
// Hash allowlist write paths
for _, path := range p.Allow.WritePaths {
h.Write([]byte("write:" + path))
}
// Hash command scopes
for _, cmd := range p.Allow.CommandScopes {
h.Write([]byte("cmd:" + cmd.Command + ":" + cmd.Description))
for _, arg := range cmd.Args {
h.Write([]byte("arg:" + arg.Value + ":" + arg.Validator))
}
}
return fmt.Sprintf("%x", h.Sum(nil))
}
func (p *PluginPermissions) GetDescription() string {
if p == nil {
return ""
}
// Check if any permissions exist
if len(p.Scopes) == 0 &&
len(p.Allow.ReadPaths) == 0 &&
len(p.Allow.WritePaths) == 0 &&
len(p.Allow.CommandScopes) == 0 {
return "No permissions requested."
}
var desc strings.Builder
// Add scopes section if any exist
if len(p.Scopes) > 0 {
desc.WriteString("Application:\n")
for _, scope := range p.Scopes {
desc.WriteString("• ")
switch scope {
case PluginPermissionStorage:
desc.WriteString("Storage: Store plugin data locally\n")
case PluginPermissionDatabase:
desc.WriteString("Database: Read and write non-auth data\n")
case PluginPermissionPlayback:
desc.WriteString("Playback: Control media playback and media players\n")
case PluginPermissionAnilist:
desc.WriteString("AniList: View and edit your AniList lists\n")
case PluginPermissionAnilistToken:
desc.WriteString("AniList Token: View and use your AniList token\n")
case PluginPermissionSystem:
desc.WriteString("System: Access OS functions (accessing files, running commands, etc.)\n")
case PluginPermissionCron:
desc.WriteString("Cron: Schedule automated tasks\n")
case PluginPermissionNotification:
desc.WriteString("Notification: Send system notifications\n")
case PluginPermissionDiscord:
desc.WriteString("Discord: Set Discord Rich Presence\n")
case PluginPermissionTorrentClient:
desc.WriteString("Torrent Client: Control torrent clients\n")
default:
desc.WriteString(string(scope) + "\n")
}
}
desc.WriteString("\n")
}
// Add file permissions if any exist
hasFilePaths := len(p.Allow.ReadPaths) > 0 || len(p.Allow.WritePaths) > 0
if hasFilePaths {
desc.WriteString("File System:\n")
if len(p.Allow.ReadPaths) > 0 {
desc.WriteString("• Read from:\n")
for _, path := range p.Allow.ReadPaths {
desc.WriteString("\t - " + explainPath(path) + "\n")
}
}
if len(p.Allow.WritePaths) > 0 {
desc.WriteString("• Write to:\n")
for _, path := range p.Allow.WritePaths {
desc.WriteString("\t - " + explainPath(path) + "\n")
}
}
desc.WriteString("\n")
}
// Add command permissions if any exist
if len(p.Allow.CommandScopes) > 0 {
desc.WriteString("Commands:\n")
for _, cmd := range p.Allow.CommandScopes {
cmdDesc := "• " + cmd.Command
// Format arguments
if len(cmd.Args) > 0 {
argsDesc := ""
for _, arg := range cmd.Args {
if arg.Value != "" {
argsDesc += " " + arg.Value
} else if arg.Validator == "$ARGS" {
argsDesc += " [any arguments]"
} else if arg.Validator == "$PATH" {
argsDesc += " [any file path]"
} else if arg.Validator != "" {
argsDesc += " [pattern: " + arg.Validator + "]"
}
}
cmdDesc += argsDesc
}
// Add command description if available
if cmd.Description != "" {
cmdDesc += "\n\t Purpose: " + cmd.Description
}
desc.WriteString(cmdDesc + "\n")
}
}
return strings.TrimSpace(desc.String())
}
// explainPath adds human-readable descriptions to paths containing environment variables
func explainPath(path string) string {
environmentVars := map[string]string{
"$SEANIME_ANIME_LIBRARY": "Your anime library directories",
"$HOME": "Your system's Home directory",
"$CACHE": "Your system's Cache directory",
"$TEMP": "Your system's Temporary directory",
"$CONFIG": "Your system's Config directory",
"$DOWNLOAD": "Your system's Downloads directory",
"$DESKTOP": "Your system's Desktop directory",
"$DOCUMENT": "Your system's Documents directory",
}
result := path
// Check if we need to add an explanation
needsExplanation := false
explanation := ""
for envVar, description := range environmentVars {
if strings.Contains(path, envVar) {
if explanation != "" {
explanation += ", "
}
explanation += fmt.Sprintf("%s = %s", envVar, description)
needsExplanation = true
}
}
if needsExplanation {
result += " (" + explanation + ")"
}
return result
}
////////////////////////////////////////////////////////////////////////////////////////////////////////
type PluginExtension interface {
BaseExtension
GetPermissionHash() string
}
type PluginExtensionImpl struct {
ext *Extension
}
func NewPluginExtension(ext *Extension) PluginExtension {
return &PluginExtensionImpl{
ext: ext,
}
}
func (m *PluginExtensionImpl) GetPermissionHash() string {
if m.ext.Plugin == nil {
return ""
}
return m.ext.Plugin.Permissions.GetHash()
}
func (m *PluginExtensionImpl) GetExtension() *Extension {
return m.ext
}
func (m *PluginExtensionImpl) GetType() Type {
return m.ext.Type
}
func (m *PluginExtensionImpl) GetID() string {
return m.ext.ID
}
func (m *PluginExtensionImpl) GetName() string {
return m.ext.Name
}
func (m *PluginExtensionImpl) GetVersion() string {
return m.ext.Version
}
func (m *PluginExtensionImpl) GetManifestURI() string {
return m.ext.ManifestURI
}
func (m *PluginExtensionImpl) GetLanguage() Language {
return m.ext.Language
}
func (m *PluginExtensionImpl) GetLang() string {
return GetExtensionLang(m.ext.Lang)
}
func (m *PluginExtensionImpl) GetDescription() string {
return m.ext.Description
}
func (m *PluginExtensionImpl) GetAuthor() string {
return m.ext.Author
}
func (m *PluginExtensionImpl) GetPayload() string {
return m.ext.Payload
}
func (m *PluginExtensionImpl) GetWebsite() string {
return m.ext.Website
}
func (m *PluginExtensionImpl) GetIcon() string {
return m.ext.Icon
}
func (m *PluginExtensionImpl) GetPermissions() []string {
return m.ext.Permissions
}
func (m *PluginExtensionImpl) GetUserConfig() *UserConfig {
return m.ext.UserConfig
}
func (m *PluginExtensionImpl) GetSavedUserConfig() *SavedUserConfig {
return m.ext.SavedUserConfig
}
func (m *PluginExtensionImpl) GetPayloadURI() string {
return m.ext.PayloadURI
}
func (m *PluginExtensionImpl) GetIsDevelopment() bool {
return m.ext.IsDevelopment
}