Changeset 0.30.0 (#199)
Co-authored-by: Jeffrey Smith <jasafpro@gmail.com> Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
This commit is contained in:
353
server/handlers/user_packages.go
Normal file
353
server/handlers/user_packages.go
Normal file
@@ -0,0 +1,353 @@
|
||||
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",
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user