This repository has been archived on 2026-04-03. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
core/server/handlers/user_packages.go
Jeffrey Smith 7d14e6a439 Changeset 0.30.0 (#199)
Co-authored-by: Jeffrey Smith <jasafpro@gmail.com>
Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
2026-03-18 12:06:55 +00:00

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"
"git.gobha.me/xcaliber/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",
})
}