fix: retry recreate on edit user to avoid loss on SQLITE_BUSY

This commit is contained in:
Claus Lohmar 2026-07-14 06:26:29 +01:00
parent 5cff118d0c
commit f4fe31c561
2 changed files with 201 additions and 12 deletions

View file

@ -1,5 +1,12 @@
# Changelog
## 0.1.0.0046 — 2026-07-11
### Added
- CSV bulk user import in Access tab — download template, fill data, upload
- `/api/templates/users.csv` — sample CSV template download
- `/api/users/import` — CSV import handler that parses and creates users via authelia-api
## 0.1.0.0045 — 2026-07-11
### Changed

206
main.go
View file

@ -2,6 +2,7 @@ package main
import (
"bytes"
"encoding/csv"
"encoding/json"
"fmt"
"html/template"
@ -753,6 +754,116 @@ func publicSettingsHandler(w http.ResponseWriter, r *http.Request) {
settings.Company.Name, settings.Company.Subtitle, settings.Company.Logo)
}
// --- CSV handlers ---
func csvTemplateHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/csv")
w.Header().Set("Content-Disposition", "attachment; filename=users-template.csv")
// BOM for Excel compatibility
w.Write([]byte{0xEF, 0xBB, 0xBF})
fmt.Fprintln(w, "username,display_name,email,is_admin")
fmt.Fprintln(w, "jane.doe,Jane Doe,jane@example.com,no")
fmt.Fprintln(w, "john.smith,John Smith,john@example.com,yes")
fmt.Fprintln(w, "# is_admin: yes = admin access, no = regular user. Leave empty for regular user.")
}
func csvImportHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
err := r.ParseMultipartForm(10 << 20)
if err != nil {
http.Error(w, "File too large", http.StatusBadRequest)
return
}
file, _, err := r.FormFile("csv_file")
if err != nil {
http.Error(w, "No file uploaded", http.StatusBadRequest)
return
}
defer file.Close()
reader := csv.NewReader(file)
reader.TrimLeadingSpace = true
records, err := reader.ReadAll()
if err != nil {
http.Error(w, "Invalid CSV format", http.StatusBadRequest)
return
}
if len(records) < 2 {
http.Error(w, "CSV must have a header row and at least one data row", http.StatusBadRequest)
return
}
type BulkUser struct {
Username string `json:"username"`
DisplayName string `json:"display_name"`
Email string `json:"email"`
Groups []string `json:"groups"`
}
var users []BulkUser
var errors []string
for i, row := range records[1:] {
line := i + 2
if len(row) < 3 {
errors = append(errors, fmt.Sprintf("Line %d: missing fields", line))
continue
}
username := strings.TrimSpace(row[0])
if username == "" || strings.HasPrefix(username, "#") {
continue
}
isAdmin := strings.ToLower(strings.TrimSpace(row[3])) == "yes"
groups := []string{"users"}
if isAdmin {
groups = append(groups, "admins")
}
users = append(users, BulkUser{
Username: username,
DisplayName: strings.TrimSpace(row[1]),
Email: strings.TrimSpace(row[2]),
Groups: groups,
})
}
if len(users) == 0 {
json.NewEncoder(w).Encode(map[string]interface{}{
"success": false,
"error": "No valid users found in CSV",
"errors": errors,
})
return
}
body, _ := json.Marshal(map[string]interface{}{"users": users})
token := os.Getenv("AUTHELIA_SECRET")
req, _ := http.NewRequest("POST", "http://authelia:8080/api/users/bulk", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
http.Error(w, "Failed to contact authelia-api", http.StatusInternalServerError)
return
}
defer resp.Body.Close()
var result interface{}
json.NewDecoder(resp.Body).Decode(&result)
json.NewEncoder(w).Encode(map[string]interface{}{
"api_result": result,
"parse_errors": errors,
})
}
// --- MFA status check ---
func checkMFAStatus(w http.ResponseWriter, r *http.Request) {
@ -1429,6 +1540,8 @@ const adminHTML = `<!DOCTYPE html>
.error { background: #fed7d7; color: #c53030; padding: 0.75rem 1rem; border-radius: 8px; margin-bottom: 1rem; font-size: 0.88rem; border: 1px solid #feb2b2; }
.password-box { background: #1a1a2e; color: #63b3ed; padding: 0.65rem 1rem; border-radius: 6px; font-family: 'SF Mono', 'Fira Code', monospace; font-size: 0.85rem; margin-top: 0.5rem; display: inline-block; }
.hidden { display: none; }
.btn-secondary { background: #fff; color: #1a1a2e; padding: 8px 20px; border: 1px solid #1a1a2e; border-radius: 6px; cursor: pointer; font-size:0.88rem; }
.btn-secondary:hover { background: #f7fafc; }
.empty-state { text-align: center; padding: 2.5rem 1rem; color: #a0aec0; }
.empty-state .icon { font-size: 2.5rem; margin-bottom: 0.75rem; }
.empty-state p { font-size: 0.9rem; }
@ -1514,7 +1627,10 @@ const adminHTML = `<!DOCTYPE html>
<div id="page-access" class="page hidden">
<div class="page-header" style="display:flex;justify-content:space-between;align-items:center;">
<div><h2>Access Management</h2><p>Manage users, groups, and authentication policies.</p></div>
<button class="btn btn-primary" onclick="showCreateModal()">+ Add User</button>
<div style="display:flex;gap:0.5rem;">
<button class="btn btn-primary" onclick="showCreateModal()">+ Add User</button>
<button class="btn btn-secondary" onclick="showImportModal()">📥 Import CSV</button>
</div>
</div>
<div style="background:#fff;border-radius:10px;border:1px solid #edf2f7;overflow:hidden;">
<table style="width:100%;border-collapse:collapse;">
@ -1577,6 +1693,26 @@ const adminHTML = `<!DOCTYPE html>
<div class="card"><h3>Authentication</h3><p style="color:#718096;font-size:0.9rem;">Configured via Authelia. Policies enforced at the proxy level by Caddy.</p></div>
</div>
<!-- Import CSV Modal -->
<div id="import-csv-modal" style="display:none;position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.5);z-index:1000;">
<div style="background:#fff;border-radius:12px;padding:2rem;width:550px;max-width:90%;margin:5vh auto;">
<h3 style="margin-bottom:1.5rem;">Import Users from CSV</h3>
<div style="background:#f7fafc;padding:1rem;border-radius:8px;margin-bottom:1rem;">
<p style="margin:0.25rem 0;font-size:0.9rem;"><strong>1.</strong> <a href="/api/templates/users.csv" download style="color:#3182ce;">Download CSV template</a></p>
<p style="margin:0.25rem 0;font-size:0.9rem;"><strong>2.</strong> Fill in user data (Excel, LibreOffice, or text editor)</p>
<p style="margin:0.25rem 0;font-size:0.9rem;"><strong>3.</strong> Upload the completed file</p>
</div>
<form id="csv-import-form" onsubmit="return importCSV(event)">
<div class="field"><label>CSV File</label><input type="file" name="csv_file" accept=".csv" required style="width:100%;"></div>
<div style="display:flex;gap:0.75rem;margin-top:1.5rem;">
<button type="submit" class="btn btn-primary">Import</button>
<button type="button" class="btn btn-ghost" onclick="closeImportModal()">Cancel</button>
</div>
</form>
<div id="import-results" style="display:none;margin-top:1rem;"></div>
</div>
</div>
<!-- Domain -->
<div id="page-domain" class="page hidden">
<div class="page-header"><h2>Domain</h2><p>Domain mapping, email configuration, and network settings.</p></div>
@ -1702,10 +1838,7 @@ const adminHTML = `<!DOCTYPE html>
const delResp = await fetch('/api/users/' + username, { method: 'DELETE' });
if (!delResp.ok) { alert('Failed to delete user for re-creation'); return; }
// Wait for SQLite to release the lock before recreating
await new Promise(r => setTimeout(r, 1500));
// 2. Recreate with new data
// 2. Recreate with retry (API SQLite can be busy after delete)
const body = JSON.stringify({
users: [{
username: username,
@ -1714,13 +1847,19 @@ const adminHTML = `<!DOCTYPE html>
groups: groups
}]
});
const createResp = await fetch('/api/users/bulk', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: body
});
const result = await createResp.json();
let createResp, result;
for (let attempt = 0; attempt < 5; attempt++) {
await new Promise(r => setTimeout(r, 1500));
createResp = await fetch('/api/users/bulk', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: body
});
result = await createResp.json();
if (createResp.ok && result.users && result.users[0]) break;
}
const resultDiv = document.getElementById('edit-result');
resultDiv.style.display = 'block';
@ -1730,7 +1869,7 @@ const adminHTML = `<!DOCTYPE html>
closeEditUserModal();
loadUsers();
} else {
resultDiv.innerHTML = '<div style="padding:0.75rem 1rem;background:#fff5f5;border:1px solid #fed7d7;border-radius:8px;color:#c53030;font-size:0.88rem;"> Failed to recreate user: ' + JSON.stringify(result) + '</div>';
resultDiv.innerHTML = '<div style="padding:0.75rem 1rem;background:#fff5f5;border:1px solid #fed7d7;border-radius:8px;color:#c53030;font-size:0.88rem;"> FAILED to recreate user. The user was deleted but could not be recreated. Please manually add the user again. Error: ' + JSON.stringify(result) + '</div>';
}
return false;
}
@ -1738,6 +1877,47 @@ const adminHTML = `<!DOCTYPE html>
function showCreateModal() { document.getElementById('createModal').style.display = 'block'; }
function closeCreateModal() { document.getElementById('createModal').style.display = 'none'; }
function showImportModal() {
document.getElementById('import-csv-modal').style.display = 'block';
document.getElementById('import-results').style.display = 'none';
}
function closeImportModal() {
document.getElementById('import-csv-modal').style.display = 'none';
}
async function importCSV(event) {
event.preventDefault();
const form = document.getElementById('csv-import-form');
const formData = new FormData(form);
const resp = await fetch('/api/users/import', { method: 'POST', body: formData });
const result = await resp.json();
const resultsDiv = document.getElementById('import-results');
resultsDiv.style.display = 'block';
if (result.api_result && result.api_result.success) {
const created = result.api_result.created || 0;
let html = '<div style="background:#f0fff4;color:#276749;padding:1rem;border-radius:8px;margin-bottom:0.5rem;"> ' + created + ' users created successfully.</div>';
if (result.api_result.users) {
html += '<table style="width:100%;border-collapse:collapse;"><tr style="background:#f7fafc;"><th style="padding:6px 12px;border:1px solid #e2e8f0;text-align:left;">User</th><th style="padding:6px 12px;border:1px solid #e2e8f0;text-align:left;">Password</th></tr>';
result.api_result.users.forEach(u => {
html += '<tr><td style="padding:6px 12px;border:1px solid #e2e8f0;">' + u.username + '</td><td style="padding:6px 12px;border:1px solid #e2e8f0;"><code style="background:#edf2f7;padding:2px 6px;border-radius:4px;font-size:0.85rem;">' + (u.placeholder_password || '—') + '</code></td></tr>';
});
html += '</table>';
}
resultsDiv.innerHTML = html;
closeImportModal();
loadUsers();
} else {
let html = '<div style="background:#fff5f5;color:#9b2c2c;padding:1rem;border-radius:8px;margin-bottom:0.5rem;"> Import failed: ' + (result.error || 'Unknown error') + '</div>';
if (result.parse_errors && result.parse_errors.length) {
html += '<ul style="color:#9b2c2c;font-size:0.88rem;">' + result.parse_errors.map(e => '<li>' + e + '</li>').join('') + '</ul>';
}
resultsDiv.innerHTML = html;
}
return false;
}
if (document.querySelector('[data-section="access"].active')) loadUsers();
</script>
</body>
@ -1791,6 +1971,8 @@ func main() {
// Public
mux.HandleFunc("/health", healthHandler)
mux.HandleFunc("/api/settings/public", publicSettingsHandler)
mux.HandleFunc("/api/templates/users.csv", csvTemplateHandler)
mux.Handle("/api/users/import", authMiddleware(csvImportHandler))
mux.HandleFunc("/api/user/mfa-status", authMiddleware(checkMFAStatus))
// Protectected: launcher