354 lines
9.2 KiB
Go
354 lines
9.2 KiB
Go
package handlers
|
|
|
|
// user_packages.go — v0.30.0 CS5
|
|
//
|
|
// Non-admin package management. Users install personal-scoped packages;
|
|
// team admins install team-scoped packages.
|
|
//
|
|
// Restrictions for non-global scope:
|
|
// - Tier: browser only (no starlark/sidecar)
|
|
// - Permissions: only notifications.send allowed
|
|
// - Prevents privilege escalation through user-installed packages
|
|
|
|
import (
|
|
"archive/zip"
|
|
"encoding/json"
|
|
"io"
|
|
"log"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
|
|
"chat-switchboard/store"
|
|
)
|
|
|
|
// allowedNonGlobalPermissions is the set of permissions that non-admin
|
|
// (team/personal scope) packages may declare.
|
|
var allowedNonGlobalPermissions = map[string]bool{
|
|
"notifications.send": true,
|
|
}
|
|
|
|
// UserPackageHandler manages user and team scoped package operations.
|
|
type UserPackageHandler struct {
|
|
stores store.Stores
|
|
packagesDir string
|
|
}
|
|
|
|
// NewUserPackageHandler creates a new user package handler.
|
|
func NewUserPackageHandler(s store.Stores, packagesDir string) *UserPackageHandler {
|
|
return &UserPackageHandler{stores: s, packagesDir: packagesDir}
|
|
}
|
|
|
|
// ListVisiblePackages returns packages visible to the current user
|
|
// (global + user's teams + personal).
|
|
// GET /api/v1/packages
|
|
func (h *UserPackageHandler) ListVisiblePackages(c *gin.Context) {
|
|
userID := c.GetString("user_id")
|
|
if userID == "" {
|
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
|
|
return
|
|
}
|
|
|
|
pkgs, err := h.stores.Packages.ListVisiblePackages(c.Request.Context(), userID)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list packages"})
|
|
return
|
|
}
|
|
if pkgs == nil {
|
|
pkgs = []store.PackageRegistration{}
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{"data": pkgs})
|
|
}
|
|
|
|
// InstallPersonalPackage installs a package with personal scope.
|
|
// POST /api/v1/packages/install
|
|
func (h *UserPackageHandler) InstallPersonalPackage(c *gin.Context) {
|
|
userID := c.GetString("user_id")
|
|
if userID == "" {
|
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
|
|
return
|
|
}
|
|
|
|
h.installScoped(c, "personal", "", userID)
|
|
}
|
|
|
|
// InstallTeamPackage installs a package with team scope.
|
|
// POST /api/v1/teams/:teamId/packages/install
|
|
func (h *UserPackageHandler) InstallTeamPackage(c *gin.Context) {
|
|
userID := c.GetString("user_id")
|
|
teamID := c.Param("teamId")
|
|
if userID == "" || teamID == "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "missing user or team context"})
|
|
return
|
|
}
|
|
|
|
h.installScoped(c, "team", teamID, userID)
|
|
}
|
|
|
|
// DeletePersonalPackage deletes a personal-scoped package owned by the user.
|
|
// DELETE /api/v1/packages/:id
|
|
func (h *UserPackageHandler) DeletePersonalPackage(c *gin.Context) {
|
|
userID := c.GetString("user_id")
|
|
id := c.Param("id")
|
|
|
|
pkg, err := h.stores.Packages.Get(c.Request.Context(), id)
|
|
if err != nil || pkg == nil {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "package not found"})
|
|
return
|
|
}
|
|
|
|
if pkg.Scope != "personal" || pkg.InstalledBy == nil || *pkg.InstalledBy != userID {
|
|
c.JSON(http.StatusForbidden, gin.H{"error": "can only delete your own personal packages"})
|
|
return
|
|
}
|
|
|
|
if err := h.stores.Packages.Delete(c.Request.Context(), id); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete package"})
|
|
return
|
|
}
|
|
|
|
// Clean up assets
|
|
if h.packagesDir != "" {
|
|
os.RemoveAll(filepath.Join(h.packagesDir, id))
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{"deleted": id})
|
|
}
|
|
|
|
// DeleteTeamPackage deletes a team-scoped package.
|
|
// DELETE /api/v1/teams/:teamId/packages/:id
|
|
func (h *UserPackageHandler) DeleteTeamPackage(c *gin.Context) {
|
|
teamID := c.Param("teamId")
|
|
id := c.Param("id")
|
|
|
|
pkg, err := h.stores.Packages.Get(c.Request.Context(), id)
|
|
if err != nil || pkg == nil {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "package not found"})
|
|
return
|
|
}
|
|
|
|
if pkg.Scope != "team" || pkg.TeamID == nil || *pkg.TeamID != teamID {
|
|
c.JSON(http.StatusForbidden, gin.H{"error": "package does not belong to this team"})
|
|
return
|
|
}
|
|
|
|
if err := h.stores.Packages.Delete(c.Request.Context(), id); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete package"})
|
|
return
|
|
}
|
|
|
|
if h.packagesDir != "" {
|
|
os.RemoveAll(filepath.Join(h.packagesDir, id))
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{"deleted": id})
|
|
}
|
|
|
|
// installScoped handles the common install flow for team/personal packages.
|
|
func (h *UserPackageHandler) installScoped(c *gin.Context, scope, teamID, userID string) {
|
|
file, header, err := c.Request.FormFile("file")
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "no file uploaded"})
|
|
return
|
|
}
|
|
defer file.Close()
|
|
|
|
validExt := strings.HasSuffix(header.Filename, ".pkg") ||
|
|
strings.HasSuffix(header.Filename, ".surface") ||
|
|
strings.HasSuffix(header.Filename, ".zip")
|
|
if !validExt {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "file must be a .pkg, .surface, or .zip archive"})
|
|
return
|
|
}
|
|
|
|
if header.Size > 50*1024*1024 {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "archive too large (max 50MB)"})
|
|
return
|
|
}
|
|
|
|
// Read into temp file
|
|
tmpFile, err := os.CreateTemp("", "userpkg-*.zip")
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create temp file"})
|
|
return
|
|
}
|
|
tmpPath := tmpFile.Name()
|
|
defer os.Remove(tmpPath)
|
|
|
|
if _, err := io.Copy(tmpFile, file); err != nil {
|
|
tmpFile.Close()
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to read upload"})
|
|
return
|
|
}
|
|
tmpFile.Close()
|
|
|
|
// Open as zip and extract manifest
|
|
zr, err := zip.OpenReader(tmpPath)
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid zip archive"})
|
|
return
|
|
}
|
|
defer zr.Close()
|
|
|
|
var manifest map[string]any
|
|
for _, f := range zr.File {
|
|
name := filepath.Base(f.Name)
|
|
if name == "manifest.json" && !f.FileInfo().IsDir() {
|
|
rc, err := f.Open()
|
|
if err != nil {
|
|
continue
|
|
}
|
|
data, _ := io.ReadAll(rc)
|
|
rc.Close()
|
|
json.Unmarshal(data, &manifest)
|
|
break
|
|
}
|
|
}
|
|
|
|
if manifest == nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "archive missing manifest.json"})
|
|
return
|
|
}
|
|
|
|
pkgID, _ := manifest["id"].(string)
|
|
title, _ := manifest["title"].(string)
|
|
if pkgID == "" || title == "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "manifest must have 'id' and 'title'"})
|
|
return
|
|
}
|
|
|
|
if !validPackageID.MatchString(pkgID) {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "package id must be a lowercase slug"})
|
|
return
|
|
}
|
|
|
|
// Enforce tier restriction: browser only for non-global
|
|
tier, _ := manifest["tier"].(string)
|
|
if tier == "" {
|
|
tier = "browser"
|
|
}
|
|
if tier != "browser" {
|
|
c.JSON(http.StatusForbidden, gin.H{
|
|
"error": "only browser-tier packages can be installed at team/personal scope",
|
|
})
|
|
return
|
|
}
|
|
|
|
// Enforce permission restriction
|
|
if permsRaw, ok := manifest["permissions"].([]any); ok {
|
|
for _, p := range permsRaw {
|
|
if perm, ok := p.(string); ok {
|
|
if !allowedNonGlobalPermissions[perm] {
|
|
c.JSON(http.StatusForbidden, gin.H{
|
|
"error": "permission " + perm + " is not allowed for " + scope + "-scoped packages",
|
|
})
|
|
return
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Check for conflicts
|
|
existing, _ := h.stores.Packages.Get(c.Request.Context(), pkgID)
|
|
if existing != nil && existing.Source == "core" {
|
|
c.JSON(http.StatusConflict, gin.H{"error": "cannot overwrite core package"})
|
|
return
|
|
}
|
|
|
|
// Extract static assets
|
|
if h.packagesDir != "" {
|
|
destDir := filepath.Join(h.packagesDir, pkgID)
|
|
os.MkdirAll(destDir, 0755)
|
|
for _, f := range zr.File {
|
|
if f.FileInfo().IsDir() {
|
|
continue
|
|
}
|
|
relPath := extractableRelPath(f.Name)
|
|
if relPath == "" {
|
|
continue
|
|
}
|
|
destPath := filepath.Join(destDir, relPath)
|
|
if !strings.HasPrefix(filepath.Clean(destPath), filepath.Clean(destDir)) {
|
|
continue
|
|
}
|
|
os.MkdirAll(filepath.Dir(destPath), 0755)
|
|
rc, err := f.Open()
|
|
if err != nil {
|
|
continue
|
|
}
|
|
out, err := os.Create(destPath)
|
|
if err != nil {
|
|
rc.Close()
|
|
continue
|
|
}
|
|
io.Copy(out, rc)
|
|
out.Close()
|
|
rc.Close()
|
|
}
|
|
}
|
|
|
|
version, _ := manifest["version"].(string)
|
|
if version == "" {
|
|
version = "0.0.0"
|
|
}
|
|
|
|
pkgType, _ := manifest["type"].(string)
|
|
if pkgType == "" {
|
|
pkgType = "surface"
|
|
}
|
|
|
|
description, _ := manifest["description"].(string)
|
|
author, _ := manifest["author"].(string)
|
|
|
|
// Create the package with scope
|
|
pkg := &store.PackageRegistration{
|
|
ID: pkgID,
|
|
Title: title,
|
|
Type: pkgType,
|
|
Version: version,
|
|
Description: description,
|
|
Author: author,
|
|
Tier: tier,
|
|
IsSystem: false,
|
|
Scope: scope,
|
|
Enabled: true,
|
|
Status: "active",
|
|
Source: "extension",
|
|
Manifest: manifest,
|
|
}
|
|
|
|
if teamID != "" {
|
|
pkg.TeamID = &teamID
|
|
}
|
|
pkg.InstalledBy = &userID
|
|
|
|
if existing != nil {
|
|
// Update existing
|
|
if err := h.stores.Packages.Update(c.Request.Context(), pkgID, pkg); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update package"})
|
|
return
|
|
}
|
|
} else {
|
|
// Create new
|
|
if err := h.stores.Packages.Create(c.Request.Context(), pkg); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create package"})
|
|
return
|
|
}
|
|
}
|
|
|
|
log.Printf("[packages] installed %s-scoped package %s by user %s", scope, pkgID, userID)
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"id": pkgID,
|
|
"title": title,
|
|
"type": pkgType,
|
|
"version": version,
|
|
"scope": scope,
|
|
"source": "extension",
|
|
})
|
|
}
|