mirror of
https://github.com/Dvorinka/Containr.git
synced 2026-10-28 07:41:27 +00:00
feat: public platform, admin console, templates, landing docs
Projects become publicly browsable after admin approval; management and secret-adjacent reads (logs, variables, exec, connection URLs) require owner/member/admin auth, and platform controls sit behind RequireAdmin. - Backend: OptionalAuth middleware, read/authed/admin route groups, projects.is_approved + service_templates.owner_id migration, visibility-aware sqlc queries, admin overview/users/approval endpoints, EnsureAdminAccount bootstrap (ADMIN_* env), 30 seeded templates with owner CRUD, managed-database connection URLs gated owner+admin, fix DB.Stats() infinite recursion. - Frontend: expandable persisted sidebar + clickable user menu, public landing page with searchable docs browser (GitHub sync, bundled snapshot + localStorage fallback), DocsBrowser shared with /docs, template create/edit/JSON-upload for owned templates, database create dialog, admin console (stats, approvals, project edit/delete, user admin toggles), HA/Security/Usage polish with dead CSS tokens mapped to real theme vars, anonymous-safe gating on every mutation control. - Tests: safe window.localStorage access fixes happy-dom crashes in PlatformShell and canvas storage. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
8f5789bf93
commit
8b6214a0f5
62 files changed
+4986
-1133
No files matched your search
@@ -3,16 +3,19 @@ package api
|
||||
import (
|
||||
"containr/internal/database"
|
||||
"containr/internal/database/sqlcdb"
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"github.com/sqlc-dev/pqtype"
|
||||
)
|
||||
|
||||
type ServiceTemplate struct {
|
||||
@@ -24,6 +27,7 @@ type ServiceTemplate struct {
|
||||
Config string `json:"config" db:"config"`
|
||||
Variables string `json:"variables" db:"variables"`
|
||||
IsOfficial bool `json:"is_official" db:"is_official"`
|
||||
OwnerID string `json:"owner_id,omitempty" db:"owner_id"`
|
||||
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
|
||||
}
|
||||
@@ -67,12 +71,18 @@ func handleGetTemplates(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
queries := sqlcdb.New(db.DB)
|
||||
|
||||
userID := optionalUserUUID(c)
|
||||
owner := uuid.NullUUID{UUID: userID, Valid: userID != uuid.Nil}
|
||||
|
||||
var templateRows []sqlcdb.ServiceTemplate
|
||||
var err error
|
||||
if category != "" {
|
||||
templateRows, err = queries.ListServiceTemplatesByCategory(ctx, category)
|
||||
templateRows, err = queries.ListServiceTemplatesByCategoryForUser(ctx, sqlcdb.ListServiceTemplatesByCategoryForUserParams{
|
||||
Category: category,
|
||||
OwnerID: owner,
|
||||
})
|
||||
} else {
|
||||
templateRows, err = queries.ListServiceTemplates(ctx)
|
||||
templateRows, err = queries.ListServiceTemplatesForUser(ctx, owner)
|
||||
}
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch templates"})
|
||||
@@ -103,7 +113,7 @@ func handleGetTemplate(c *gin.Context) {
|
||||
}
|
||||
|
||||
t := mapSQLCTemplate(row)
|
||||
if t.ID == "" {
|
||||
if t.ID == "" || !templateVisibleTo(c, t) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Template not found"})
|
||||
return
|
||||
}
|
||||
@@ -169,7 +179,7 @@ func handleCreateFromTemplate(c *gin.Context) {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch project"})
|
||||
return
|
||||
}
|
||||
if ownerID.String() != userID {
|
||||
if ownerID.String() != userID && !contextIsAdmin(c) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
|
||||
return
|
||||
}
|
||||
@@ -185,8 +195,8 @@ func handleCreateFromTemplate(c *gin.Context) {
|
||||
}
|
||||
|
||||
template := mapSQLCTemplate(templateRow)
|
||||
if template.ID == "" {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Project not found"})
|
||||
if template.ID == "" || !templateVisibleTo(c, template) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Template not found"})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -363,6 +373,243 @@ func handleCreateFromTemplate(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
// templateWriteRequest is the admin payload for creating/updating a user
|
||||
// template. config must decode into TemplateConfig; variables into
|
||||
// []TemplateVariable.
|
||||
type templateWriteRequest struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
Description string `json:"description"`
|
||||
Category string `json:"category"`
|
||||
Logo string `json:"logo"`
|
||||
Config json.RawMessage `json:"config" binding:"required"`
|
||||
Variables json.RawMessage `json:"variables"`
|
||||
}
|
||||
|
||||
var templateCategories = map[string]bool{
|
||||
"web": true, "frontend": true, "api": true, "database": true,
|
||||
"worker": true, "cron": true, "app": true, "custom": true,
|
||||
}
|
||||
|
||||
func normalizeTemplateCategory(category string) string {
|
||||
normalized := strings.ToLower(strings.TrimSpace(category))
|
||||
if templateCategories[normalized] {
|
||||
return normalized
|
||||
}
|
||||
return "custom"
|
||||
}
|
||||
|
||||
func validateTemplateWrite(c *gin.Context, req *templateWriteRequest) bool {
|
||||
req.Name = strings.TrimSpace(req.Name)
|
||||
if len(req.Name) < 2 || len(req.Name) > 120 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "name must be 2-120 characters"})
|
||||
return false
|
||||
}
|
||||
req.Category = normalizeTemplateCategory(req.Category)
|
||||
req.Logo = strings.TrimSpace(req.Logo)
|
||||
if len(req.Logo) > 500 || (req.Logo != "" && !strings.HasPrefix(req.Logo, "https://") && !strings.HasPrefix(req.Logo, "http://")) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "logo must be an http(s) URL up to 500 chars"})
|
||||
return false
|
||||
}
|
||||
if len(req.Config) == 0 || len(req.Config) > 64*1024 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "config is required and must be under 64KB"})
|
||||
return false
|
||||
}
|
||||
var config TemplateConfig
|
||||
if err := json.Unmarshal(req.Config, &config); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "config must be a valid template config object"})
|
||||
return false
|
||||
}
|
||||
if _, err := normalizeTemplateServiceType(config.Type); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return false
|
||||
}
|
||||
if len(req.Variables) > 0 {
|
||||
if len(req.Variables) > 64*1024 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "variables must be under 64KB"})
|
||||
return false
|
||||
}
|
||||
var variables []TemplateVariable
|
||||
if err := json.Unmarshal(req.Variables, &variables); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "variables must be an array"})
|
||||
return false
|
||||
}
|
||||
for _, v := range variables {
|
||||
if strings.TrimSpace(v.Key) == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "every variable needs a key"})
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func templateVariablesParam(raw json.RawMessage) pqtype.NullRawMessage {
|
||||
if len(raw) == 0 {
|
||||
return pqtype.NullRawMessage{}
|
||||
}
|
||||
return pqtype.NullRawMessage{RawMessage: raw, Valid: true}
|
||||
}
|
||||
|
||||
func handleCreateTemplate(c *gin.Context) {
|
||||
userID, ok := requireAuthenticatedUserID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
db := c.MustGet("db").(*database.DB)
|
||||
queries := sqlcdb.New(db.DB)
|
||||
|
||||
var req templateWriteRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if !validateTemplateWrite(c, &req) {
|
||||
return
|
||||
}
|
||||
|
||||
ownerUUID, err := uuid.Parse(userID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid user context"})
|
||||
return
|
||||
}
|
||||
|
||||
id := "usr-" + uuid.NewString()
|
||||
if err := queries.CreateUserTemplate(c.Request.Context(), sqlcdb.CreateUserTemplateParams{
|
||||
ID: id,
|
||||
Name: req.Name,
|
||||
Description: nullableText(strings.TrimSpace(req.Description)),
|
||||
Category: req.Category,
|
||||
Logo: nullableText(req.Logo),
|
||||
Config: req.Config,
|
||||
Variables: templateVariablesParam(req.Variables),
|
||||
OwnerID: uuid.NullUUID{UUID: ownerUUID, Valid: true},
|
||||
}); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create template"})
|
||||
return
|
||||
}
|
||||
|
||||
LogAudit(userID, "template", id, "create", map[string]interface{}{"name": req.Name})
|
||||
|
||||
row, err := queries.GetServiceTemplateByID(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusCreated, gin.H{"template": gin.H{"id": id, "name": req.Name}})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, gin.H{"template": mapSQLCTemplate(row)})
|
||||
}
|
||||
|
||||
func handleUpdateTemplate(c *gin.Context) {
|
||||
userID, ok := requireAuthenticatedUserID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
db := c.MustGet("db").(*database.DB)
|
||||
queries := sqlcdb.New(db.DB)
|
||||
templateID := c.Param("id")
|
||||
|
||||
var req templateWriteRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if !validateTemplateWrite(c, &req) {
|
||||
return
|
||||
}
|
||||
|
||||
ownerUUID, err := uuid.Parse(userID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid user context"})
|
||||
return
|
||||
}
|
||||
|
||||
rows, err := queries.UpdateUserTemplate(c.Request.Context(), sqlcdb.UpdateUserTemplateParams{
|
||||
Name: req.Name,
|
||||
Description: nullableText(strings.TrimSpace(req.Description)),
|
||||
Category: req.Category,
|
||||
Logo: nullableText(req.Logo),
|
||||
Config: req.Config,
|
||||
Variables: templateVariablesParam(req.Variables),
|
||||
ID: templateID,
|
||||
OwnerID: uuid.NullUUID{UUID: ownerUUID, Valid: true},
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update template"})
|
||||
return
|
||||
}
|
||||
if rows == 0 {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Template not found or not editable"})
|
||||
return
|
||||
}
|
||||
|
||||
LogAudit(userID, "template", templateID, "update", map[string]interface{}{"name": req.Name})
|
||||
|
||||
row, err := queries.GetServiceTemplateByID(c.Request.Context(), templateID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Template updated"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"template": mapSQLCTemplate(row)})
|
||||
}
|
||||
|
||||
func handleDeleteTemplate(c *gin.Context) {
|
||||
userID, ok := requireAuthenticatedUserID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
db := c.MustGet("db").(*database.DB)
|
||||
queries := sqlcdb.New(db.DB)
|
||||
templateID := c.Param("id")
|
||||
|
||||
ownerUUID, err := uuid.Parse(userID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid user context"})
|
||||
return
|
||||
}
|
||||
|
||||
rows, err := queries.DeleteUserTemplate(c.Request.Context(), sqlcdb.DeleteUserTemplateParams{
|
||||
ID: templateID,
|
||||
OwnerID: uuid.NullUUID{UUID: ownerUUID, Valid: true},
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete template"})
|
||||
return
|
||||
}
|
||||
if rows == 0 {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Template not found or not deletable"})
|
||||
return
|
||||
}
|
||||
|
||||
LogAudit(userID, "template", templateID, "delete", nil)
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Template deleted"})
|
||||
}
|
||||
|
||||
// SeedOfficialTemplates upserts the built-in catalog on startup so new
|
||||
// templates ship with releases without needing a migration per entry.
|
||||
func SeedOfficialTemplates(ctx context.Context, db *database.DB) {
|
||||
if db == nil || db.DB == nil {
|
||||
return
|
||||
}
|
||||
queries := sqlcdb.New(db.DB)
|
||||
for _, t := range SeedTemplates() {
|
||||
variables := pqtype.NullRawMessage{}
|
||||
if strings.TrimSpace(t.Variables) != "" {
|
||||
variables = pqtype.NullRawMessage{RawMessage: json.RawMessage(t.Variables), Valid: true}
|
||||
}
|
||||
if err := queries.UpsertServiceTemplate(ctx, sqlcdb.UpsertServiceTemplateParams{
|
||||
ID: t.ID,
|
||||
Name: t.Name,
|
||||
Description: nullableText(t.Description),
|
||||
Category: t.Category,
|
||||
Logo: nullableText(t.Logo),
|
||||
Config: json.RawMessage(t.Config),
|
||||
Variables: variables,
|
||||
IsOfficial: sql.NullBool{Bool: true, Valid: true},
|
||||
}); err != nil {
|
||||
log.Printf("Failed to seed template %s: %v", t.ID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func SeedTemplates() []ServiceTemplate {
|
||||
templates := []ServiceTemplate{
|
||||
{
|
||||
@@ -505,6 +752,167 @@ func SeedTemplates() []ServiceTemplate {
|
||||
Variables: `[{"key":"IMAGE","label":"Docker Image","default":"","required":true,"secret":false},{"key":"TAG","label":"Image Tag","default":"latest","required":false,"secret":false}]`,
|
||||
IsOfficial: true,
|
||||
},
|
||||
// Self-hosted applications — runtime doubles as the image reference.
|
||||
{
|
||||
ID: "tpl-nextcloud",
|
||||
Name: "Nextcloud",
|
||||
Description: "Self-hosted file sync, share and collaboration platform",
|
||||
Category: "app",
|
||||
Logo: "https://cdn.simpleicons.org/nextcloud",
|
||||
Config: `{"type":"web","runtime":"nextcloud:30-apache","port":80,"health_check":"/status.php"}`,
|
||||
Variables: `[{"key":"NEXTCLOUD_ADMIN_USER","label":"Admin User","default":"admin","required":false,"secret":false},{"key":"NEXTCLOUD_ADMIN_PASSWORD","label":"Admin Password","default":"","required":true,"secret":true},{"key":"NEXTCLOUD_TRUSTED_DOMAINS","label":"Trusted Domains","default":"","required":false,"secret":false}]`,
|
||||
IsOfficial: true,
|
||||
},
|
||||
{
|
||||
ID: "tpl-gitea",
|
||||
Name: "Gitea",
|
||||
Description: "Lightweight self-hosted Git service",
|
||||
Category: "app",
|
||||
Logo: "https://cdn.simpleicons.org/gitea",
|
||||
Config: `{"type":"web","runtime":"gitea/gitea:1","port":3000,"health_check":"/api/healthz"}`,
|
||||
Variables: `[{"key":"GITEA__server__DOMAIN","label":"Server Domain","default":"localhost","required":false,"secret":false},{"key":"GITEA__server__ROOT_URL","label":"Root URL","default":"","required":false,"secret":false}]`,
|
||||
IsOfficial: true,
|
||||
},
|
||||
{
|
||||
ID: "tpl-ghost",
|
||||
Name: "Ghost",
|
||||
Description: "Independent publishing platform for blogs and newsletters",
|
||||
Category: "app",
|
||||
Logo: "https://cdn.simpleicons.org/ghost",
|
||||
Config: `{"type":"web","runtime":"ghost:5-alpine","port":2368,"health_check":"/ghost/api/admin/site/"}`,
|
||||
Variables: `[{"key":"url","label":"Public URL","default":"","required":true,"secret":false},{"key":"database__client","label":"Database Client","default":"sqlite3","required":false,"secret":false}]`,
|
||||
IsOfficial: true,
|
||||
},
|
||||
{
|
||||
ID: "tpl-uptime-kuma",
|
||||
Name: "Uptime Kuma",
|
||||
Description: "Self-hosted monitoring tool for services and websites",
|
||||
Category: "app",
|
||||
Logo: "https://cdn.simpleicons.org/uptimerobot",
|
||||
Config: `{"type":"web","runtime":"louislam/uptime-kuma:1","port":3001,"health_check":"/"}`,
|
||||
Variables: `[]`,
|
||||
IsOfficial: true,
|
||||
},
|
||||
{
|
||||
ID: "tpl-vaultwarden",
|
||||
Name: "Vaultwarden",
|
||||
Description: "Lightweight Bitwarden-compatible password manager",
|
||||
Category: "app",
|
||||
Logo: "https://cdn.simpleicons.org/bitwarden",
|
||||
Config: `{"type":"web","runtime":"vaultwarden/server:latest","port":80,"health_check":"/alive"}`,
|
||||
Variables: `[{"key":"ADMIN_TOKEN","label":"Admin Token","default":"","required":false,"secret":true},{"key":"SIGNUPS_ALLOWED","label":"Allow Signups","default":"false","required":false,"secret":false},{"key":"DOMAIN","label":"Domain URL","default":"","required":false,"secret":false}]`,
|
||||
IsOfficial: true,
|
||||
},
|
||||
{
|
||||
ID: "tpl-jellyfin",
|
||||
Name: "Jellyfin",
|
||||
Description: "Free software media system for movies, shows and music",
|
||||
Category: "app",
|
||||
Logo: "https://cdn.simpleicons.org/jellyfin",
|
||||
Config: `{"type":"web","runtime":"jellyfin/jellyfin:latest","port":8096,"health_check":"/health"}`,
|
||||
Variables: `[]`,
|
||||
IsOfficial: true,
|
||||
},
|
||||
{
|
||||
ID: "tpl-n8n",
|
||||
Name: "n8n",
|
||||
Description: "Workflow automation tool with hundreds of integrations",
|
||||
Category: "app",
|
||||
Logo: "https://cdn.simpleicons.org/n8n",
|
||||
Config: `{"type":"web","runtime":"n8nio/n8n:latest","port":5678,"health_check":"/healthz"}`,
|
||||
Variables: `[{"key":"N8N_HOST","label":"Hostname","default":"","required":false,"secret":false},{"key":"N8N_ENCRYPTION_KEY","label":"Encryption Key","default":"","required":true,"secret":true},{"key":"WEBHOOK_URL","label":"Webhook URL","default":"","required":false,"secret":false}]`,
|
||||
IsOfficial: true,
|
||||
},
|
||||
{
|
||||
ID: "tpl-grafana",
|
||||
Name: "Grafana",
|
||||
Description: "Observability dashboards for metrics, logs and traces",
|
||||
Category: "app",
|
||||
Logo: "https://cdn.simpleicons.org/grafana",
|
||||
Config: `{"type":"web","runtime":"grafana/grafana:latest","port":3000,"health_check":"/api/health"}`,
|
||||
Variables: `[{"key":"GF_SECURITY_ADMIN_PASSWORD","label":"Admin Password","default":"","required":true,"secret":true}]`,
|
||||
IsOfficial: true,
|
||||
},
|
||||
{
|
||||
ID: "tpl-immich",
|
||||
Name: "Immich",
|
||||
Description: "Self-hosted photo and video backup",
|
||||
Category: "app",
|
||||
Logo: "https://cdn.simpleicons.org/immich",
|
||||
Config: `{"type":"web","runtime":"ghcr.io/immich-app/immich-server:release","port":2283,"health_check":"/api/server/ping"}`,
|
||||
Variables: `[{"key":"DB_PASSWORD","label":"Database Password","default":"","required":true,"secret":true},{"key":"DB_HOSTNAME","label":"Database Host","default":"","required":false,"secret":false}]`,
|
||||
IsOfficial: true,
|
||||
},
|
||||
{
|
||||
ID: "tpl-meilisearch",
|
||||
Name: "Meilisearch",
|
||||
Description: "Fast, typo-tolerant search engine API",
|
||||
Category: "app",
|
||||
Logo: "https://cdn.simpleicons.org/meilisearch",
|
||||
Config: `{"type":"web","runtime":"getmeili/meilisearch:v1","port":7700,"health_check":"/health"}`,
|
||||
Variables: `[{"key":"MEILI_MASTER_KEY","label":"Master Key","default":"","required":true,"secret":true},{"key":"MEILI_ENV","label":"Environment","default":"production","required":false,"secret":false}]`,
|
||||
IsOfficial: true,
|
||||
},
|
||||
{
|
||||
ID: "tpl-plausible",
|
||||
Name: "Plausible Analytics",
|
||||
Description: "Lightweight, privacy-friendly web analytics",
|
||||
Category: "app",
|
||||
Logo: "https://cdn.simpleicons.org/plausibleanalytics",
|
||||
Config: `{"type":"web","runtime":"ghcr.io/plausible/community-edition:v2","port":8000,"health_check":"/api/health"}`,
|
||||
Variables: `[{"key":"BASE_URL","label":"Base URL","default":"","required":true,"secret":false},{"key":"SECRET_KEY_BASE","label":"Secret Key Base","default":"","required":true,"secret":true}]`,
|
||||
IsOfficial: true,
|
||||
},
|
||||
{
|
||||
ID: "tpl-minio",
|
||||
Name: "MinIO",
|
||||
Description: "S3-compatible object storage",
|
||||
Category: "app",
|
||||
Logo: "https://cdn.simpleicons.org/minio",
|
||||
Config: `{"type":"web","runtime":"minio/minio:latest","start_command":"server /data --console-address \":9001\"","port":9001,"health_check":"/minio/health/live"}`,
|
||||
Variables: `[{"key":"MINIO_ROOT_USER","label":"Root User","default":"minioadmin","required":true,"secret":false},{"key":"MINIO_ROOT_PASSWORD","label":"Root Password","default":"","required":true,"secret":true}]`,
|
||||
IsOfficial: true,
|
||||
},
|
||||
{
|
||||
ID: "tpl-appwrite",
|
||||
Name: "Appwrite",
|
||||
Description: "Backend-as-a-service for web and mobile apps",
|
||||
Category: "app",
|
||||
Logo: "https://cdn.simpleicons.org/appwrite",
|
||||
Config: `{"type":"web","runtime":"appwrite/appwrite:1.6","port":80,"health_check":"/health"}`,
|
||||
Variables: `[]`,
|
||||
IsOfficial: true,
|
||||
},
|
||||
{
|
||||
ID: "tpl-wordpress",
|
||||
Name: "WordPress",
|
||||
Description: "Classic CMS powering a large share of the web",
|
||||
Category: "app",
|
||||
Logo: "https://cdn.simpleicons.org/wordpress",
|
||||
Config: `{"type":"web","runtime":"wordpress:6-apache","port":80,"health_check":"/wp-login.php"}`,
|
||||
Variables: `[{"key":"WORDPRESS_DB_HOST","label":"Database Host","default":"","required":true,"secret":false},{"key":"WORDPRESS_DB_USER","label":"Database User","default":"wordpress","required":true,"secret":false},{"key":"WORDPRESS_DB_PASSWORD","label":"Database Password","default":"","required":true,"secret":true},{"key":"WORDPRESS_DB_NAME","label":"Database Name","default":"wordpress","required":true,"secret":false}]`,
|
||||
IsOfficial: true,
|
||||
},
|
||||
{
|
||||
ID: "tpl-paperless",
|
||||
Name: "Paperless-ngx",
|
||||
Description: "Document management system with OCR",
|
||||
Category: "app",
|
||||
Logo: "https://cdn.simpleicons.org/paperlessngx",
|
||||
Config: `{"type":"web","runtime":"ghcr.io/paperless-ngx/paperless-ngx:latest","port":8000,"health_check":"/api/"}`,
|
||||
Variables: `[{"key":"PAPERLESS_ADMIN_PASSWORD","label":"Admin Password","default":"","required":true,"secret":true},{"key":"PAPERLESS_URL","label":"Public URL","default":"","required":false,"secret":false}]`,
|
||||
IsOfficial: true,
|
||||
},
|
||||
{
|
||||
ID: "tpl-ntfy",
|
||||
Name: "ntfy",
|
||||
Description: "Push notification service over HTTP",
|
||||
Category: "app",
|
||||
Logo: "https://cdn.simpleicons.org/ntfy",
|
||||
Config: `{"type":"web","runtime":"binwiederhier/ntfy:latest","start_command":"serve","port":80,"health_check":"/v1/health"}`,
|
||||
Variables: `[]`,
|
||||
IsOfficial: true,
|
||||
},
|
||||
}
|
||||
return templates
|
||||
}
|
||||
@@ -605,12 +1013,30 @@ func defaultTemplateResources(serviceType string) (cpu, memory string) {
|
||||
}
|
||||
}
|
||||
|
||||
// templateVisibleTo reports whether the caller may see a template: official
|
||||
// templates are public, user templates are owner/admin only.
|
||||
func templateVisibleTo(c *gin.Context, t ServiceTemplate) bool {
|
||||
if t.IsOfficial {
|
||||
return true
|
||||
}
|
||||
if contextIsAdmin(c) {
|
||||
return true
|
||||
}
|
||||
userID := optionalUserID(c)
|
||||
return userID != "" && t.OwnerID == userID
|
||||
}
|
||||
|
||||
func mapSQLCTemplate(row sqlcdb.ServiceTemplate) ServiceTemplate {
|
||||
variables := "[]"
|
||||
if row.Variables.Valid && len(row.Variables.RawMessage) > 0 {
|
||||
variables = string(row.Variables.RawMessage)
|
||||
}
|
||||
|
||||
ownerID := ""
|
||||
if row.OwnerID.Valid {
|
||||
ownerID = row.OwnerID.UUID.String()
|
||||
}
|
||||
|
||||
return ServiceTemplate{
|
||||
ID: row.ID,
|
||||
Name: row.Name,
|
||||
@@ -620,6 +1046,7 @@ func mapSQLCTemplate(row sqlcdb.ServiceTemplate) ServiceTemplate {
|
||||
Config: string(row.Config),
|
||||
Variables: variables,
|
||||
IsOfficial: row.IsOfficial.Valid && row.IsOfficial.Bool,
|
||||
OwnerID: ownerID,
|
||||
CreatedAt: templateNullTime(row.CreatedAt),
|
||||
UpdatedAt: templateNullTime(row.UpdatedAt),
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user