0
0
mirror of https://github.com/go-gitea/gitea.git synced 2025-12-08 20:01:46 +01:00
2025-12-06 15:13:35 -08:00

302 lines
8.1 KiB
Go

// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package renderplugin
import (
"archive/zip"
"context"
"errors"
"fmt"
"io"
"io/fs"
"os"
"path/filepath"
"strings"
render_model "code.gitea.io/gitea/models/render"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/renderplugin"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/storage"
"code.gitea.io/gitea/modules/util"
)
var errManifestNotFound = errors.New("manifest.json not found in plugin archive")
// InstallFromArchive installs or upgrades a plugin from an uploaded ZIP archive.
// If expectedIdentifier is non-empty the archive must contain the matching plugin id.
func InstallFromArchive(ctx context.Context, upload io.Reader, filename, expectedIdentifier string) (*render_model.Plugin, error) {
tmpFile, cleanupFile, err := setting.AppDataTempDir("render-plugins").CreateTempFileRandom("upload", "*.zip")
if err != nil {
return nil, err
}
defer cleanupFile()
if _, err := io.Copy(tmpFile, upload); err != nil {
return nil, err
}
if err := tmpFile.Close(); err != nil {
return nil, err
}
pluginDir, manifest, cleanupDir, err := extractArchive(tmpFile.Name())
if err != nil {
return nil, err
}
defer cleanupDir()
if expectedIdentifier != "" && manifest.ID != expectedIdentifier {
return nil, fmt.Errorf("uploaded plugin id %s does not match %s", manifest.ID, expectedIdentifier)
}
entryPath := filepath.Join(pluginDir, filepath.FromSlash(manifest.Entry))
if ok, _ := util.IsExist(entryPath); !ok {
return nil, fmt.Errorf("plugin entry %s not found", manifest.Entry)
}
if err := replacePluginFiles(manifest.ID, pluginDir); err != nil {
return nil, err
}
plug := &render_model.Plugin{
Identifier: manifest.ID,
Name: manifest.Name,
Version: manifest.Version,
Description: manifest.Description,
Source: strings.TrimSpace(filename),
Entry: manifest.Entry,
FilePatterns: manifest.FilePatterns,
Permissions: manifest.Permissions,
FormatVersion: manifest.SchemaVersion,
}
if err := render_model.UpsertPlugin(ctx, plug); err != nil {
return nil, err
}
return plug, nil
}
// LoadManifestFromArchive extracts and validates only the manifest from a plugin archive.
func LoadManifestFromArchive(zipPath string) (*renderplugin.Manifest, error) {
_, manifest, cleanup, err := extractArchive(zipPath)
if err != nil {
return nil, err
}
defer cleanup()
return manifest, nil
}
// Delete removes a plugin from disk and database.
func Delete(ctx context.Context, plug *render_model.Plugin) error {
if err := deletePluginFiles(plug.Identifier); err != nil {
return err
}
return render_model.DeletePlugin(ctx, plug)
}
// SetEnabled toggles plugin availability after verifying assets exist when enabling.
func SetEnabled(ctx context.Context, plug *render_model.Plugin, enabled bool) error {
if enabled {
if err := ensureEntryExists(plug); err != nil {
return err
}
}
return render_model.SetPluginEnabled(ctx, plug, enabled)
}
// BuildMetadata returns metadata for all enabled plugins.
func BuildMetadata(ctx context.Context) ([]renderplugin.Metadata, error) {
plugs, err := render_model.ListEnabledPlugins(ctx)
if err != nil {
return nil, err
}
base := setting.AppSubURL + "/assets/render-plugins/"
metas := make([]renderplugin.Metadata, 0, len(plugs))
for _, plug := range plugs {
if plug.FormatVersion != renderplugin.SupportedManifestVersion {
log.Warn("Render plugin %s disabled due to incompatible schema version %d", plug.Identifier, plug.FormatVersion)
continue
}
if err := ensureEntryExists(plug); err != nil {
log.Error("Render plugin %s entry missing: %v", plug.Identifier, err)
continue
}
assetsBase := base + plug.Identifier + "/"
metas = append(metas, renderplugin.Metadata{
ID: plug.Identifier,
Name: plug.Name,
Version: plug.Version,
Description: plug.Description,
Entry: plug.Entry,
EntryURL: assetsBase + plug.Entry,
AssetsBase: assetsBase,
FilePatterns: append([]string(nil), plug.FilePatterns...),
SchemaVersion: plug.FormatVersion,
Permissions: append([]string(nil), plug.Permissions...),
})
}
return metas, nil
}
func ensureEntryExists(plug *render_model.Plugin) error {
entryPath := renderplugin.ObjectPath(plug.Identifier, filepath.ToSlash(plug.Entry))
if _, err := renderplugin.Storage().Stat(entryPath); err != nil {
return fmt.Errorf("plugin entry %s missing: %w", plug.Entry, err)
}
return nil
}
func extractArchive(zipPath string) (string, *renderplugin.Manifest, func(), error) {
reader, err := zip.OpenReader(zipPath)
if err != nil {
return "", nil, nil, err
}
extractDir, cleanup, err := setting.AppDataTempDir("render-plugins").MkdirTempRandom("extract", "*")
if err != nil {
_ = reader.Close()
return "", nil, nil, err
}
closeAll := func() {
_ = reader.Close()
cleanup()
}
for _, file := range reader.File {
if err := extractZipEntry(file, extractDir); err != nil {
closeAll()
return "", nil, nil, err
}
}
manifestPath, err := findManifest(extractDir)
if err != nil {
closeAll()
return "", nil, nil, err
}
manifestDir := filepath.Dir(manifestPath)
manifest, err := renderplugin.LoadManifest(manifestDir)
if err != nil {
closeAll()
return "", nil, nil, err
}
return manifestDir, manifest, closeAll, nil
}
func extractZipEntry(file *zip.File, dest string) error {
cleanRel := util.PathJoinRelX(file.Name)
if cleanRel == "" || cleanRel == "." {
return nil
}
target := filepath.Join(dest, filepath.FromSlash(cleanRel))
rel, err := filepath.Rel(dest, target)
if err != nil || strings.HasPrefix(rel, "..") {
return fmt.Errorf("archive path %q escapes extraction directory", file.Name)
}
if file.FileInfo().IsDir() {
return os.MkdirAll(target, os.ModePerm)
}
if file.FileInfo().Mode()&os.ModeSymlink != 0 {
return fmt.Errorf("symlinks are not supported inside plugin archives: %s", file.Name)
}
if err := os.MkdirAll(filepath.Dir(target), os.ModePerm); err != nil {
return err
}
rc, err := file.Open()
if err != nil {
return err
}
defer rc.Close()
out, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, file.Mode().Perm())
if err != nil {
return err
}
defer out.Close()
if _, err := io.Copy(out, rc); err != nil {
return err
}
return nil
}
func findManifest(root string) (string, error) {
var manifestPath string
err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() {
return nil
}
if strings.EqualFold(d.Name(), "manifest.json") {
if manifestPath != "" {
return errors.New("multiple manifest.json files found")
}
manifestPath = path
}
return nil
})
if err != nil {
return "", err
}
if manifestPath == "" {
return "", errManifestNotFound
}
return manifestPath, nil
}
func replacePluginFiles(identifier, srcDir string) error {
if err := deletePluginFiles(identifier); err != nil {
return err
}
return uploadPluginDir(identifier, srcDir)
}
func deletePluginFiles(identifier string) error {
store := renderplugin.Storage()
prefix := renderplugin.ObjectPrefix(identifier)
if err := store.IterateObjects(prefix, func(path string, obj storage.Object) error {
_ = obj.Close()
return store.Delete(path)
}); err != nil {
if os.IsNotExist(err) {
return nil
}
return err
}
return nil
}
func uploadPluginDir(identifier, src string) error {
store := renderplugin.Storage()
return filepath.WalkDir(src, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() {
return nil
}
if d.Type()&os.ModeSymlink != 0 {
return errors.New("symlinks are not supported inside plugin archives")
}
rel, err := filepath.Rel(src, path)
if err != nil {
return err
}
file, err := os.Open(path)
if err != nil {
return err
}
info, err := file.Stat()
if err != nil {
file.Close()
return err
}
objectPath := renderplugin.ObjectPath(identifier, filepath.ToSlash(rel))
_, err = store.Save(objectPath, file, info.Size())
closeErr := file.Close()
if err != nil {
return err
}
return closeErr
})
}