feat: enhance LDAP integration with new configuration options and browsing capabilities

This commit is contained in:
Björn Blomberg
2026-04-14 16:07:52 +02:00
parent d6fd50e511
commit 61f33d350a
6 changed files with 631 additions and 163 deletions

View File

@@ -73,16 +73,12 @@ func (m *Manager) Login(database *db.DB, username, password string) (token, role
cfg := m.cfg
m.mu.RUnlock()
if cfg == nil || cfg.LDAP.Host == "" {
return "", "", errors.New("invalid credentials")
}
if err := ldapBind(cfg.LDAP.Host, cfg.LDAP.Port, cfg.LDAP.BindDN, cfg.LDAP.BindPassword); err != nil {
if cfg == nil || cfg.LDAP.Url == "" {
return "", "", errors.New("invalid credentials")
}
if err := ldapUserBind(cfg, username, password); err != nil {
return "", "", errors.New("invalid credentials")
return "", "", err
}
tok, err := m.createSession(username, "user")
@@ -90,44 +86,104 @@ func (m *Manager) Login(database *db.DB, username, password string) (token, role
}
// TestLDAP tests an LDAP configuration by performing a service-account bind.
func TestLDAP(host string, port int, bindDN, bindPassword string) error {
return ldapBind(host, port, bindDN, bindPassword)
}
func ldapBind(host string, port int, bindDN, bindPassword string) error {
l, err := ldap.Dial("tcp", fmt.Sprintf("%s:%d", host, port))
func TestLDAP(url string, adminUser, adminPassword string) error {
l, err := ldap.DialURL(url)
if err != nil {
return err
}
defer l.Close()
return l.Bind(bindDN, bindPassword)
if adminUser != "" {
return l.Bind(adminUser, adminPassword)
}
return nil
}
func ldapUserBind(cfg *config.Config, username, password string) error {
l, err := ldap.Dial("tcp", fmt.Sprintf("%s:%d", cfg.LDAP.Host, cfg.LDAP.Port))
l, err := ldap.DialURL(cfg.LDAP.Url)
if err != nil {
return err
}
defer l.Close()
if err := l.Bind(cfg.LDAP.BindDN, cfg.LDAP.BindPassword); err != nil {
return err
if cfg.LDAP.AdminUser != "" {
if err := l.Bind(cfg.LDAP.AdminUser, cfg.LDAP.AdminPass); err != nil {
return err
}
}
sr, err := l.Search(&ldap.SearchRequest{
BaseDN: cfg.LDAP.BaseDN,
Filter: fmt.Sprintf("(uid=%s)", ldap.EscapeFilter(username)),
Scope: ldap.ScopeWholeSubtree,
})
searchRequest := ldap.NewSearchRequest(
"", // Search from root or a specific BaseDN if needed. Assuming root here since URL can contain it, or we rely on LDAP configured properly
ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false,
fmt.Sprintf("(|(uid=%s)(sAMAccountName=%s))", ldap.EscapeFilter(username), ldap.EscapeFilter(username)),
[]string{"dn"},
nil,
)
sr, err := l.Search(searchRequest)
if err != nil {
return err
}
if len(sr.Entries) != 1 {
if len(sr.Entries) == 0 {
return errors.New("user not found in LDAP")
}
// Bind to the exact DN of the user discovered
return l.Bind(sr.Entries[0].DN, password)
}
// BrowseLDAP returns users and groups from the LDAP server.
func BrowseLDAP(url string, adminUser, adminPassword string) ([]string, []string, error) {
l, err := ldap.DialURL(url)
if err != nil {
return nil, nil, err
}
defer l.Close()
if adminUser != "" {
if err := l.Bind(adminUser, adminPassword); err != nil {
return nil, nil, err
}
}
// Search for Users
userReq := ldap.NewSearchRequest(
"", ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false,
"(|(objectClass=person)(objectClass=user))",
[]string{"uid", "sAMAccountName", "cn"},
nil,
)
var users []string
if sr, err := l.Search(userReq); err == nil {
for _, e := range sr.Entries {
if v := e.GetAttributeValue("sAMAccountName"); v != "" {
users = append(users, v)
} else if v := e.GetAttributeValue("uid"); v != "" {
users = append(users, v)
} else if v := e.GetAttributeValue("cn"); v != "" {
users = append(users, v)
}
}
}
// Search for Groups
groupReq := ldap.NewSearchRequest(
"", ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false,
"(|(objectClass=groupOfNames)(objectClass=group))",
[]string{"cn"},
nil,
)
var groups []string
if sr, err := l.Search(groupReq); err == nil {
for _, e := range sr.Entries {
if v := e.GetAttributeValue("cn"); v != "" {
groups = append(groups, v)
}
}
}
return users, groups, nil
}
func (m *Manager) createSession(username, role string) (string, error) {
token, err := generateToken()
if err != nil {

View File

@@ -16,11 +16,9 @@ type Config struct {
}
type LDAPConfig struct {
Host string `json:"host"`
Port int `json:"port"`
BaseDN string `json:"base_dn"`
BindDN string `json:"bind_dn"`
BindPassword string `json:"bind_password"`
Url string `json:"url"`
AdminUser string `json:"admin_user"`
AdminPass string `json:"admin_pass"`
}
// ErrRequireSetup is returned when config is missing or empty,

View File

@@ -51,8 +51,36 @@ func (d *DB) init() error {
username TEXT NOT NULL UNIQUE,
pass_hash TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'user',
is_ldap BOOLEAN NOT NULL DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
);
CREATE TABLE IF NOT EXISTS groups (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
is_ldap BOOLEAN NOT NULL DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS user_groups (
user_id INTEGER NOT NULL,
group_id INTEGER NOT NULL,
PRIMARY KEY(user_id, group_id),
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY(group_id) REFERENCES groups(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS acl (
id INTEGER PRIMARY KEY AUTOINCREMENT,
path TEXT NOT NULL,
subject_type TEXT NOT NULL CHECK(subject_type IN ('user', 'group')),
subject_id INTEGER NOT NULL,
can_search BOOLEAN NOT NULL DEFAULT 0,
can_view BOOLEAN NOT NULL DEFAULT 0,
can_read BOOLEAN NOT NULL DEFAULT 0,
can_edit BOOLEAN NOT NULL DEFAULT 0,
can_create BOOLEAN NOT NULL DEFAULT 0,
can_delete BOOLEAN NOT NULL DEFAULT 0,
can_move BOOLEAN NOT NULL DEFAULT 0,
UNIQUE(path, subject_type, subject_id)
);
`)
return err
}
@@ -153,3 +181,96 @@ func (d *DB) UserCount() (int, error) {
err := d.sql.QueryRow(`SELECT COUNT(*) FROM users`).Scan(&n)
return n, err
}
// --- LDAP & Roles ---
// CreateOrUpdateLDAPUser inserts or updates an LDAP user (without password, role='user').
func (d *DB) CreateOrUpdateLDAPUser(username string) error {
_, err := d.sql.Exec(`
INSERT INTO users (username, pass_hash, role, is_ldap)
VALUES (?, '', 'user', 1)
ON CONFLICT(username) DO UPDATE SET is_ldap=1;
`, username)
return err
}
// CreateOrUpdateGroup inserts or updates a group (local or LDAP).
func (d *DB) CreateOrUpdateGroup(name string, isLdap bool) error {
ldVal := 0
if isLdap {
ldVal = 1
}
_, err := d.sql.Exec(`
INSERT INTO groups (name, is_ldap)
VALUES (?, ?)
ON CONFLICT(name) DO UPDATE SET is_ldap=excluded.is_ldap;
`, name, ldVal)
return err
}
// --- ACL Methods ---
type ACLEntry struct {
ID int64
Path string
SubjectType string
SubjectID int64
CanSearch bool
CanView bool
CanRead bool
CanEdit bool
CanCreate bool
CanDelete bool
CanMove bool
}
// SetACL inserts or replaces an ACL entry.
func (d *DB) SetACL(entry ACLEntry) error {
_, err := d.sql.Exec(`
INSERT INTO acl (path, subject_type, subject_id, can_search, can_view, can_read, can_edit, can_create, can_delete, can_move)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(path, subject_type, subject_id) DO UPDATE SET
can_search = excluded.can_search,
can_view = excluded.can_view,
can_read = excluded.can_read,
can_edit = excluded.can_edit,
can_create = excluded.can_create,
can_delete = excluded.can_delete,
can_move = excluded.can_move;
`, entry.Path, entry.SubjectType, entry.SubjectID,
entry.CanSearch, entry.CanView, entry.CanRead, entry.CanEdit,
entry.CanCreate, entry.CanDelete, entry.CanMove)
return err
}
// RemoveACL deletes an ACL entry by ID.
func (d *DB) RemoveACL(id int64) error {
_, err := d.sql.Exec(`DELETE FROM acl WHERE id = ?`, id)
return err
}
// GetACLsForPath retrieves all ACL definitions for a specific document or folder.
func (d *DB) GetACLsForPath(path string) ([]ACLEntry, error) {
rows, err := d.sql.Query(`
SELECT id, path, subject_type, subject_id, can_search, can_view, can_read, can_edit, can_create, can_delete, can_move
FROM acl WHERE path = ?
`, path)
if err != nil {
return nil, err
}
defer rows.Close()
var entries []ACLEntry
for rows.Next() {
var e ACLEntry
if err := rows.Scan(
&e.ID, &e.Path, &e.SubjectType, &e.SubjectID,
&e.CanSearch, &e.CanView, &e.CanRead, &e.CanEdit,
&e.CanCreate, &e.CanDelete, &e.CanMove,
); err != nil {
return nil, err
}
entries = append(entries, e)
}
return entries, nil
}

View File

@@ -31,14 +31,20 @@ type Query {
# List images next to a document.
images(slug: String!): [ImageFile!]!
# Browse LDAP tree (admin only)
ldapBrowse(url: String, adminUser: String, adminPass: String): LDAPTree!
# Fetch permissions for a path
acl(path: String!): [ACLEntry!]!
}
type Mutation {
# First-run setup.
setup(input: SetupInput!): Boolean!
# Test an LDAP configuration before saving.
testLdapConnection(input: LDAPInput!): LDAPTestResult!
# Test an LDAP configuration.
testLdapConnection(url: String!, adminUser: String, adminPass: String): LDAPTestResult!
# Authenticate and receive a bearer token.
login(username: String!, password: String!): String!
@@ -55,6 +61,13 @@ type Mutation {
# Modify LDAP Configuration (admin only).
updateLdapConfig(input: LDAPInput): Boolean!
# Sync users/groups from LDAP
importLdapSubject(type: String!, name: String!): Boolean!
# ACL Mutations
setAcl(input: ACLInput!): Boolean!
removeAcl(id: Int!): Boolean!
# Modify Storage Path (admin only).
updateStoragePath(path: String!): Boolean!
@@ -87,10 +100,27 @@ type AppConfig {
}
type LDAPConfig {
host: String!
port: Int!
baseDN: String!
bindDN: String!
url: String!
adminUser: String!
}
type LDAPTree {
users: [String!]!
groups: [String!]!
}
type ACLEntry {
id: Int!
path: String!
subjectType: String!
subjectId: Int!
canSearch: Boolean!
canView: Boolean!
canRead: Boolean!
canEdit: Boolean!
canCreate: Boolean!
canDelete: Boolean!
canMove: Boolean!
}
type ServerDirectory {
@@ -148,11 +178,22 @@ input SetupInput {
}
input LDAPInput {
host: String!
port: Int!
baseDN: String!
bindDN: String!
bindPassword: String!
url: String!
adminUser: String!
adminPass: String!
}
input ACLInput {
path: String!
subjectType: String!
subjectId: Int!
canSearch: Boolean!
canView: Boolean!
canRead: Boolean!
canEdit: Boolean!
canCreate: Boolean!
canDelete: Boolean!
canMove: Boolean!
}
input SaveDocumentInput {

View File

@@ -232,6 +232,21 @@ func (s *Server) dispatchAuthenticated(
case strings.Contains(q, "moveDocument"):
s.handleMoveDocument(w, req, sess)
case strings.Contains(q, "ldapBrowse"):
s.handleLdapBrowse(w, req, sess)
case strings.Contains(q, "importLdapSubject"):
s.handleImportLdapSubject(w, req, sess)
case strings.Contains(q, "acl(") || strings.Contains(q, "acl "):
s.handleAcl(w, req, sess)
case strings.Contains(q, "setAcl"):
s.handleSetAcl(w, req, sess)
case strings.Contains(q, "removeAcl"):
s.handleRemoveAcl(w, req, sess)
case strings.Contains(q, "folders"):
s.handleFolders(w, sess)
@@ -813,16 +828,10 @@ func (s *Server) handleUpdateLdapConfig(w http.ResponseWriter, req gqlRequest, s
newCfg := *cfg // copy
if hasInput && input != nil {
port := 389
if p, ok := input["port"].(float64); ok {
port = int(p)
}
newCfg.LDAP = config.LDAPConfig{
Host: strVal(input, "host"),
Port: port,
BaseDN: strVal(input, "baseDN"),
BindDN: strVal(input, "bindDN"),
BindPassword: strVal(input, "bindPassword"),
Url: strVal(input, "url"),
AdminUser: strVal(input, "adminUser"),
AdminPass: strVal(input, "adminPass"),
}
} else {
// Disable LDAP
@@ -863,12 +872,10 @@ func (s *Server) handleConfig(w http.ResponseWriter, sess *auth.Session) {
}
var ldap map[string]interface{}
if cfg.LDAP.Host != "" {
if cfg.LDAP.Url != "" {
ldap = map[string]interface{}{
"host": cfg.LDAP.Host,
"port": cfg.LDAP.Port,
"baseDN": cfg.LDAP.BaseDN,
"bindDN": cfg.LDAP.BindDN,
"url": cfg.LDAP.Url,
"adminUser": cfg.LDAP.AdminUser,
}
}
@@ -1005,16 +1012,10 @@ func (s *Server) handleSetup(w http.ResponseWriter, req gqlRequest) {
}
if ldapRaw, ok := input["ldap"].(map[string]interface{}); ok && ldapRaw != nil {
port := 389
if p, ok := ldapRaw["port"].(float64); ok {
port = int(p)
}
cfg.LDAP = config.LDAPConfig{
Host: strVal(ldapRaw, "host"),
Port: port,
BaseDN: strVal(ldapRaw, "baseDN"),
BindDN: strVal(ldapRaw, "bindDN"),
BindPassword: strVal(ldapRaw, "bindPassword"),
Url: strVal(ldapRaw, "url"),
AdminUser: strVal(ldapRaw, "adminUser"),
AdminPass: strVal(ldapRaw, "adminPass"),
}
}
@@ -1081,34 +1082,24 @@ func (s *Server) handleSetup(w http.ResponseWriter, req gqlRequest) {
// ── LDAP test handler ─────────────────────────────────────────────────────────
func (s *Server) handleTestLdapConnection(w http.ResponseWriter, req gqlRequest) {
input, _ := req.Variables["i"].(map[string]interface{})
if input == nil {
writeGQLError(w, "missing LDAP input")
return
}
url_ := strVal(req.Variables, "url")
adminUser := strVal(req.Variables, "adminUser")
adminPass := strVal(req.Variables, "adminPass")
host := strVal(input, "host")
port := 389
if p, ok := input["port"].(float64); ok {
port = int(p)
}
bindDN := strVal(input, "bindDN")
bindPassword := strVal(input, "bindPassword")
if host == "" {
if url_ == "" {
writeJSONObj(w, map[string]interface{}{
"data": map[string]interface{}{
"testLdapConnection": map[string]interface{}{
"success": false, "message": "host is required",
"success": false, "message": "url is required",
},
},
})
return
}
err := auth.TestLDAP(host, port, bindDN, bindPassword)
err := auth.TestLDAP(url_, adminUser, adminPass)
if err != nil {
log.Printf("[ldap] test failed (%s:%d): %v", host, port, err)
log.Printf("[ldap] test failed (%s): %v", url_, err)
writeJSONObj(w, map[string]interface{}{
"data": map[string]interface{}{
"testLdapConnection": map[string]interface{}{
@@ -1119,7 +1110,7 @@ func (s *Server) handleTestLdapConnection(w http.ResponseWriter, req gqlRequest)
return
}
log.Printf("[ldap] test succeeded (%s:%d)", host, port)
log.Printf("[ldap] test succeeded (%s)", url_)
writeJSONObj(w, map[string]interface{}{
"data": map[string]interface{}{
"testLdapConnection": map[string]interface{}{
@@ -1246,10 +1237,203 @@ func firstWord(s string) string {
}
func strVal(m map[string]interface{}, key string) string {
if m == nil {
return ""
}
v, _ := m[key].(string)
return v
}
// ── LDAP & ACL Additions ──────────────────────────────────────────────────────
func (s *Server) handleLdapBrowse(w http.ResponseWriter, req gqlRequest, sess *auth.Session) {
if sess == nil || sess.Role != "admin" {
writeGQLError(w, "UNAUTHORIZED")
return
}
url_ := strVal(req.Variables, "url")
adminUser := strVal(req.Variables, "adminUser")
adminPass := strVal(req.Variables, "adminPass")
if url_ == "" {
s.mu.RLock()
if s.cfg != nil && s.cfg.LDAP.Url != "" {
url_ = s.cfg.LDAP.Url
adminUser = s.cfg.LDAP.AdminUser
adminPass = s.cfg.LDAP.AdminPass
}
s.mu.RUnlock()
}
users, groups, err := auth.BrowseLDAP(url_, adminUser, adminPass)
if err != nil {
writeGQLError(w, fmt.Sprintf("LDAP traverse error: %v", err))
return
}
if users == nil {
users = []string{}
}
if groups == nil {
groups = []string{}
}
writeJSONObj(w, map[string]interface{}{
"data": map[string]interface{}{
"ldapBrowse": map[string]interface{}{
"users": users,
"groups": groups,
},
},
})
}
func (s *Server) handleImportLdapSubject(w http.ResponseWriter, req gqlRequest, sess *auth.Session) {
if sess == nil || sess.Role != "admin" {
writeGQLError(w, "UNAUTHORIZED")
return
}
typ := strVal(req.Variables, "type")
name := strVal(req.Variables, "name")
s.mu.RLock()
database := s.database
s.mu.RUnlock()
var err error
if typ == "user" {
err = database.CreateOrUpdateLDAPUser(name)
} else if typ == "group" {
err = database.CreateOrUpdateGroup(name, true)
} else {
writeGQLError(w, "Invalid subject type")
return
}
if err != nil {
writeGQLError(w, fmt.Sprintf("DB Error: %v", err))
return
}
writeJSON(w, `{"data":{"importLdapSubject":true}}`)
}
func (s *Server) handleSetAcl(w http.ResponseWriter, req gqlRequest, sess *auth.Session) {
if sess == nil || sess.Role != "admin" {
writeGQLError(w, "UNAUTHORIZED")
return
}
input, _ := req.Variables["input"].(map[string]interface{})
if input == nil {
writeGQLError(w, "Missing input")
return
}
s.mu.RLock()
database := s.database
s.mu.RUnlock()
var entry db.ACLEntry
entry.Path = strVal(input, "path")
entry.SubjectType = strVal(input, "subjectType")
if idF, ok := input["subjectId"].(float64); ok {
entry.SubjectID = int64(idF)
} else {
writeGQLError(w, "Missing subjectId")
return
}
entry.CanSearch, _ = input["canSearch"].(bool)
entry.CanView, _ = input["canView"].(bool)
entry.CanRead, _ = input["canRead"].(bool)
entry.CanEdit, _ = input["canEdit"].(bool)
entry.CanCreate, _ = input["canCreate"].(bool)
entry.CanDelete, _ = input["canDelete"].(bool)
entry.CanMove, _ = input["canMove"].(bool)
if err := database.SetACL(entry); err != nil {
writeGQLError(w, err.Error())
return
}
writeJSON(w, `{"data":{"setAcl":true}}`)
}
func (s *Server) handleRemoveAcl(w http.ResponseWriter, req gqlRequest, sess *auth.Session) {
if sess == nil || sess.Role != "admin" {
writeGQLError(w, "UNAUTHORIZED")
return
}
idF, ok := req.Variables["id"].(float64)
if !ok {
writeGQLError(w, "Missing id")
return
}
s.mu.RLock()
database := s.database
s.mu.RUnlock()
if err := database.RemoveACL(int64(idF)); err != nil {
writeGQLError(w, err.Error())
return
}
writeJSON(w, `{"data":{"removeAcl":true}}`)
}
func (s *Server) handleAcl(w http.ResponseWriter, req gqlRequest, sess *auth.Session) {
if sess == nil {
writeGQLError(w, "UNAUTHORIZED")
return
}
path := strVal(req.Variables, "path")
if path == "" {
writeGQLError(w, "Missing path")
return
}
// Example: Allow everyone to see ACLs for now, or restrict to admin
s.mu.RLock()
database := s.database
s.mu.RUnlock()
acls, err := database.GetACLsForPath(path)
if err != nil {
writeGQLError(w, err.Error())
return
}
var aclData []map[string]interface{}
for _, a := range acls {
aclData = append(aclData, map[string]interface{}{
"id": a.ID,
"path": a.Path,
"subjectType": a.SubjectType,
"subjectId": a.SubjectID,
"canSearch": a.CanSearch,
"canView": a.CanView,
"canRead": a.CanRead,
"canEdit": a.CanEdit,
"canCreate": a.CanCreate,
"canDelete": a.CanDelete,
"canMove": a.CanMove,
})
}
if aclData == nil {
aclData = []map[string]interface{}{}
}
writeJSONObj(w, map[string]interface{}{
"data": map[string]interface{}{
"acl": aclData,
},
})
}
func dirOf(path string) string {
idx := strings.LastIndexAny(path, "/\\")
if idx < 0 {
@@ -1564,64 +1748,62 @@ func (s *Server) handleFolders(w http.ResponseWriter, sess *auth.Session) {
}
func (s *Server) handleImages(w http.ResponseWriter, req gqlRequest, sess *auth.Session) {
if sess == nil {
writeGQLError(w, "UNAUTHORIZED")
return
}
slug, _ := req.Variables["slug"].(string)
if slug == "" {
writeGQLError(w, "slug is required")
return
}
if sess == nil {
writeGQLError(w, "UNAUTHORIZED")
return
}
slug, _ := req.Variables["slug"].(string)
if slug == "" {
writeGQLError(w, "slug is required")
return
}
s.mu.RLock()
store := s.store
s.mu.RUnlock()
s.mu.RLock()
store := s.store
s.mu.RUnlock()
if store == nil {
writeGQLError(w, "storage not initialized")
return
}
if store == nil {
writeGQLError(w, "storage not initialized")
return
}
images, err := store.ListImages(slug)
if err != nil {
writeGQLError(w, fmt.Sprintf("failed to list images: %v", err))
return
}
images, err := store.ListImages(slug)
if err != nil {
writeGQLError(w, fmt.Sprintf("failed to list images: %v", err))
return
}
writeJSONObj(w, map[string]interface{}{"data": map[string]interface{}{"images": images}})
writeJSONObj(w, map[string]interface{}{"data": map[string]interface{}{"images": images}})
}
func (s *Server) handleDeleteImage(w http.ResponseWriter, req gqlRequest, sess *auth.Session) {
if sess == nil {
writeGQLError(w, "UNAUTHORIZED")
return
if sess == nil {
writeGQLError(w, "UNAUTHORIZED")
return
}
slug, _ := req.Variables["slug"].(string)
filename, _ := req.Variables["filename"].(string)
if slug == "" || filename == "" {
writeGQLError(w, "slug and filename are required")
return
}
s.mu.RLock()
store := s.store
s.mu.RUnlock()
if store == nil {
writeGQLError(w, "storage not initialized")
return
}
if err := store.DeleteImage(slug, filename); err != nil {
writeGQLError(w, fmt.Sprintf("failed to delete image: %v", err))
return
}
log.Printf("[storage] deleted image %q from %q by %s", filename, slug, sess.Username)
writeJSON(w, `{"data":{"deleteImage":true}}`)
}
slug, _ := req.Variables["slug"].(string)
filename, _ := req.Variables["filename"].(string)
if slug == "" || filename == "" {
writeGQLError(w, "slug and filename are required")
return
}
s.mu.RLock()
store := s.store
s.mu.RUnlock()
if store == nil {
writeGQLError(w, "storage not initialized")
return
}
if err := store.DeleteImage(slug, filename); err != nil {
writeGQLError(w, fmt.Sprintf("failed to delete image: %v", err))
return
}
log.Printf("[storage] deleted image %q from %q by %s", filename, slug, sess.Username)
writeJSON(w, `{"data":{"deleteImage":true}}`)
}

View File

@@ -5,11 +5,9 @@ import FolderPicker from '@/components/FolderPicker.vue'
const storagePath = ref('')
const ldapEnabled = ref(false)
const ldapHost = ref('')
const ldapPort = ref(389)
const ldapBaseDN = ref('')
const ldapBindDN = ref('')
const ldapBindPassword = ref('')
const ldapUrl = ref('')
const ldapAdminUser = ref('')
const ldapAdminPassword = ref('')
const oldPass = ref('')
const newPass = ref('')
@@ -22,17 +20,20 @@ const showGitPrompt = ref(false)
const gitCommitMessage = ref('Initial commit')
const gitCommitting = ref(false)
// LDAP Browser State
const ldapUsers = ref<string[]>([])
const ldapGroups = ref<string[]>([])
const ldapBrowsing = ref(false)
onMounted(async () => {
try {
const data = await gql<{ config: any }>(`{ config { storagePath ldap { host port baseDN bindDN } } }`)
const data = await gql<{ config: any }>(`{ config { storagePath ldap { url adminUser } } }`)
storagePath.value = data.config.storagePath || ''
if (data.config.ldap) {
ldapEnabled.value = true
ldapHost.value = data.config.ldap.host
ldapPort.value = data.config.ldap.port
ldapBaseDN.value = data.config.ldap.baseDN
ldapBindDN.value = data.config.ldap.bindDN
ldapUrl.value = data.config.ldap.url
ldapAdminUser.value = data.config.ldap.adminUser
}
} catch (err: any) {
status.value = { msg: err.message, isError: true }
@@ -83,11 +84,9 @@ async function saveLdap() {
let input = null
if (ldapEnabled.value) {
input = {
host: ldapHost.value,
port: ldapPort.value,
baseDN: ldapBaseDN.value,
bindDN: ldapBindDN.value,
bindPassword: ldapBindPassword.value
url: ldapUrl.value,
adminUser: ldapAdminUser.value,
adminPass: ldapAdminPassword.value
}
}
@@ -98,6 +97,52 @@ async function saveLdap() {
}
}
async function testLdap() {
try {
const data = await gql<{ testLdapConnection: { success: boolean, message: string } }>(
`mutation TestLdap($url: String!, $adminUser: String, $adminPass: String) {
testLdapConnection(url: $url, adminUser: $adminUser, adminPass: $adminPass) { success message }
}`,
{ url: ldapUrl.value, adminUser: ldapAdminUser.value, adminPass: ldapAdminPassword.value }
)
if (data.testLdapConnection.success) {
showStatus('LDAP connection successful.', false)
} else {
showStatus('LDAP connection failed: ' + data.testLdapConnection.message, true)
}
} catch (err: any) {
showStatus(err.message, true)
}
}
async function browseLdap() {
try {
ldapBrowsing.value = true
const data = await gql<{ ldapBrowse: { users: string[], groups: string[] } }>(
`query LdapBrowse($url: String, $adminUser: String, $adminPass: String) {
ldapBrowse(url: $url, adminUser: $adminUser, adminPass: $adminPass) { users groups }
}`,
{ url: ldapUrl.value || undefined, adminUser: ldapAdminUser.value || undefined, adminPass: ldapAdminPassword.value || undefined }
)
ldapUsers.value = data.ldapBrowse.users
ldapGroups.value = data.ldapBrowse.groups
showStatus('LDAP structure loaded.')
} catch (err: any) {
showStatus(err.message, true)
} finally {
ldapBrowsing.value = false
}
}
async function importSubject(type: 'user' | 'group', name: string) {
try {
await gql(`mutation ImportSubj($type: String!, $name: String!) { importLdapSubject(type: $type, name: $name) }`, { type, name })
showStatus(`Imported ${type} ${name} successfully.`)
} catch (err: any) {
showStatus(err.message, true)
}
}
async function savePassword() {
if (newPass.value.length < 8) {
showStatus('New password must be at least 8 characters.', true)
@@ -217,33 +262,58 @@ async function savePassword() {
</div>
<div v-if="ldapEnabled" class="grid gap-4 sm:grid-cols-2 mb-4">
<div class="sm:col-span-2 md:col-span-1">
<label class="label">Host</label>
<input v-model="ldapHost" class="input" placeholder="ldap.example.com" />
</div>
<div class="sm:col-span-2 md:col-span-1">
<label class="label">Port</label>
<input v-model.number="ldapPort" type="number" class="input" placeholder="389" />
</div>
<div class="sm:col-span-2">
<label class="label">Base DN <span class="text-slate-400 text-xs font-normal">(e.g. dc=example,dc=com)</span></label>
<input v-model="ldapBaseDN" class="input" placeholder="dc=example,dc=com" />
<label class="label">LDAP URL</label>
<input v-model="ldapUrl" class="input" placeholder="ldap://ldap.example.com:389" />
</div>
<div class="sm:col-span-2 md:col-span-1">
<label class="label">Bind DN (Service Account)</label>
<input v-model="ldapBindDN" class="input" placeholder="cn=reader,dc=example,dc=com" />
<label class="label">Admin User (Optional Service Account)</label>
<input v-model="ldapAdminUser" class="input" placeholder="cn=reader,dc=example,dc=com" />
</div>
<div class="sm:col-span-2 md:col-span-1">
<label class="label">Bind Password</label>
<input v-model="ldapBindPassword" type="password" class="input" />
<label class="label">Admin Password</label>
<input v-model="ldapAdminPassword" type="password" class="input" />
</div>
</div>
<div class="flex justify-end">
<div class="flex justify-end gap-2 mb-6">
<button class="btn-secondary" v-if="ldapEnabled" @click="testLdap">Test Connection</button>
<button class="btn-primary" @click="saveLdap">
{{ ldapEnabled ? 'Save LDAP Config' : 'Save & Disable LDAP' }}
</button>
</div>
<!-- LDAP Browser -->
<div v-if="ldapEnabled" class="border-t border-slate-200 dark:border-slate-700 pt-6 mt-6">
<div class="flex justify-between items-center mb-4">
<h3 class="text-md font-semibold">LDAP Browser</h3>
<button class="btn-secondary text-sm py-1 px-3" @click="browseLdap" :disabled="ldapBrowsing">
{{ ldapBrowsing ? 'Loading...' : 'Browse Users & Groups' }}
</button>
</div>
<div v-if="ldapUsers.length || ldapGroups.length" class="grid sm:grid-cols-2 gap-6">
<div class="card p-4">
<h4 class="font-medium text-slate-800 dark:text-slate-200 mb-2 border-b pb-1">Users</h4>
<ul class="max-h-48 overflow-y-auto space-y-1">
<li v-for="u in ldapUsers" :key="u" class="flex justify-between items-center text-sm p-1 hover:bg-slate-50 dark:hover:bg-slate-800 rounded">
<span class="truncate">{{ u }}</span>
<button class="text-accent-600 hover:text-accent-700 text-xs" @click="importSubject('user', u)">Import</button>
</li>
</ul>
</div>
<div class="card p-4">
<h4 class="font-medium text-slate-800 dark:text-slate-200 mb-2 border-b pb-1">Groups</h4>
<ul class="max-h-48 overflow-y-auto space-y-1">
<li v-for="g in ldapGroups" :key="g" class="flex justify-between items-center text-sm p-1 hover:bg-slate-50 dark:hover:bg-slate-800 rounded">
<span class="truncate">{{ g }}</span>
<button class="text-accent-600 hover:text-accent-700 text-xs" @click="importSubject('group', g)">Import</button>
</li>
</ul>
</div>
</div>
<p v-else-if="!ldapBrowsing" class="text-sm text-slate-500 italic">Click browse to load LDAP hierarchy.</p>
</div>
</section>
</div>