diff --git a/gobsidian b/gobsidian new file mode 100755 index 0000000..0d861eb Binary files /dev/null and b/gobsidian differ diff --git a/internal/config/config.go b/internal/config/config.go index 2f24a15..5a27abd 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -17,6 +17,7 @@ type Config struct { SecretKey string Debug bool MaxContentLength int64 // in bytes + URLPrefix string // MD Notes App settings AppName string @@ -68,6 +69,7 @@ var defaultConfig = map[string]map[string]string{ "SECRET_KEY": "change-this-secret-key", "DEBUG": "false", "MAX_CONTENT_LENGTH": "16", // in MB + "URL_PREFIX": "", }, "MD_NOTES_APP": { "APP_NAME": "Gobsidian", @@ -148,6 +150,14 @@ func Load() (*Config, error) { config.Debug, _ = SERVERSection.Key("DEBUG").Bool() maxContentMB, _ := SERVERSection.Key("MAX_CONTENT_LENGTH").Int() config.MaxContentLength = int64(maxContentMB) * 1024 * 1024 // Convert MB to bytes + // Normalize URL prefix: "" or starts with '/' and no trailing '/' + rawPrefix := strings.TrimSpace(SERVERSection.Key("URL_PREFIX").String()) + if rawPrefix == "/" { rawPrefix = "" } + if rawPrefix != "" { + if !strings.HasPrefix(rawPrefix, "/") { rawPrefix = "/" + rawPrefix } + rawPrefix = strings.TrimRight(rawPrefix, "/") + } + config.URLPrefix = rawPrefix // Load MD_NOTES_APP section notesSection := cfg.Section("MD_NOTES_APP") @@ -340,6 +350,14 @@ func (c *Config) SaveSetting(section, key, value string) error { if size, err := strconv.ParseInt(value, 10, 64); err == nil { c.MaxContentLength = size * 1024 * 1024 } + case "URL_PREFIX": + v := strings.TrimSpace(value) + if v == "/" { v = "" } + if v != "" { + if !strings.HasPrefix(v, "/") { v = "/" + v } + v = strings.TrimRight(v, "/") + } + c.URLPrefix = v } case "MD_NOTES_APP": switch key { diff --git a/internal/handlers/auth.go b/internal/handlers/auth.go index 320c792..5c6e026 100644 --- a/internal/handlers/auth.go +++ b/internal/handlers/auth.go @@ -20,6 +20,11 @@ const sessionCookieName = "gobsidian_session" // LoginPage renders the login form func (h *Handlers) LoginPage(c *gin.Context) { + // If already authenticated, redirect to home (respect URL prefix) + if isAuthenticated(c) { + c.Redirect(http.StatusFound, h.config.URLPrefix+"/") + return + } token, _ := c.Get("csrf_token") c.HTML(http.StatusOK, "login", gin.H{ "app_name": h.config.AppName, @@ -128,7 +133,7 @@ func isAllDigits(s string) bool { func (h *Handlers) MFALoginPage(c *gin.Context) { session, _ := h.store.Get(c.Request, sessionCookieName) if _, ok := session.Values["mfa_user_id"]; !ok { - c.Redirect(http.StatusFound, "/editor/login") + c.Redirect(http.StatusFound, h.config.URLPrefix+"/editor/login") return } token, _ := c.Get("csrf_token") @@ -161,7 +166,7 @@ func (h *Handlers) MFALoginVerify(c *gin.Context) { session, _ := h.store.Get(c.Request, sessionCookieName) uidAny, ok := session.Values["mfa_user_id"] if !ok { - c.Redirect(http.StatusFound, "/editor/login") + c.Redirect(http.StatusFound, h.config.URLPrefix+"/editor/login") return } uid, _ := uidAny.(int64) @@ -188,14 +193,14 @@ func (h *Handlers) MFALoginVerify(c *gin.Context) { delete(session.Values, "mfa_user_id") session.Values["user_id"] = uid _ = session.Save(c.Request, c.Writer) - c.Redirect(http.StatusFound, "/") + c.Redirect(http.StatusFound, h.config.URLPrefix+"/") } // ProfileMFASetupPage shows QR and input to verify during enrollment func (h *Handlers) ProfileMFASetupPage(c *gin.Context) { uidPtr := getUserIDPtr(c) if uidPtr == nil { - c.Redirect(http.StatusFound, "/editor/login") + c.Redirect(http.StatusFound, h.config.URLPrefix+"/editor/login") return } // ensure enrollment exists, otherwise create one @@ -329,7 +334,7 @@ func (h *Handlers) LoginPost(c *gin.Context) { session, _ := h.store.Get(c.Request, sessionCookieName) session.Values["mfa_user_id"] = user.ID _ = session.Save(c.Request, c.Writer) - c.Redirect(http.StatusFound, "/editor/mfa") + c.Redirect(http.StatusFound, h.config.URLPrefix+"/editor/mfa") return } @@ -340,7 +345,7 @@ func (h *Handlers) LoginPost(c *gin.Context) { session, _ := h.store.Get(c.Request, sessionCookieName) session.Values["user_id"] = user.ID _ = session.Save(c.Request, c.Writer) - c.Redirect(http.StatusFound, "/editor/profile/mfa/setup") + c.Redirect(http.StatusFound, h.config.URLPrefix+"/editor/profile/mfa/setup") return } @@ -349,7 +354,7 @@ func (h *Handlers) LoginPost(c *gin.Context) { session.Values["user_id"] = user.ID _ = session.Save(c.Request, c.Writer) - c.Redirect(http.StatusFound, "/") + c.Redirect(http.StatusFound, h.config.URLPrefix+"/") } // LogoutPost clears the session @@ -357,5 +362,5 @@ func (h *Handlers) LogoutPost(c *gin.Context) { session, _ := h.store.Get(c.Request, sessionCookieName) session.Options.MaxAge = -1 _ = session.Save(c.Request, c.Writer) - c.Redirect(http.StatusFound, "/editor/login") + c.Redirect(http.StatusFound, h.config.URLPrefix+"/editor/login") } diff --git a/internal/handlers/editor.go b/internal/handlers/editor.go index 60764f6..9c18fff 100644 --- a/internal/handlers/editor.go +++ b/internal/handlers/editor.go @@ -142,9 +142,9 @@ func (h *Handlers) CreateNoteHandler(c *gin.Context) { } // Redirect based on extension - redirect := "/note/" + notePath + redirect := h.config.URLPrefix + "/note/" + notePath if strings.ToLower(ext) != "md" { - redirect = "/view_text/" + notePath + redirect = h.config.URLPrefix + "/view_text/" + notePath } c.JSON(http.StatusOK, gin.H{ @@ -297,7 +297,7 @@ func (h *Handlers) EditNoteHandler(c *gin.Context) { c.JSON(http.StatusOK, gin.H{ "success": true, "message": "Note saved successfully", - "redirect": "/note/" + notePath, + "redirect": h.config.URLPrefix + "/note/" + notePath, }) } diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go index 22dc55e..8bdefce 100644 --- a/internal/handlers/handlers.go +++ b/internal/handlers/handlers.go @@ -344,7 +344,7 @@ func (h *Handlers) ProfilePage(c *gin.Context) { // Must be authenticated; middleware ensures user_id is set uidPtr := getUserIDPtr(c) if uidPtr == nil { - c.Redirect(http.StatusFound, "/editor/login") + c.Redirect(http.StatusFound, h.config.URLPrefix+"/editor/login") return } @@ -477,7 +477,7 @@ func (h *Handlers) PostProfileEnableMFA(c *gin.Context) { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } - c.JSON(http.StatusOK, gin.H{"success": true, "setup": true, "redirect": "/editor/profile/mfa/setup"}) + c.JSON(http.StatusOK, gin.H{"success": true, "setup": true, "redirect": h.config.URLPrefix+"/editor/profile/mfa/setup"}) } // PostProfileDisableMFA clears the user's MFA secret @@ -611,16 +611,18 @@ func (h *Handlers) AdminEnableUserMFA(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid user id"}) return } - // Create or replace an enrollment so user is prompted on next login + // Admin enable: set a new secret directly so MFA is immediately enabled secret, err := generateBase32Secret() if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate secret"}) return } - if _, err := h.authSvc.DB.Exec(`INSERT OR REPLACE INTO mfa_enrollments (user_id, secret) VALUES (?, ?)`, id, secret); err != nil { + if _, err := h.authSvc.DB.Exec(`UPDATE users SET mfa_secret = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`, secret, id); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } + // Remove any pending enrollment rows + _, _ = h.authSvc.DB.Exec(`DELETE FROM mfa_enrollments WHERE user_id = ?`, id) c.JSON(http.StatusOK, gin.H{"success": true}) } @@ -890,7 +892,7 @@ func (h *Handlers) PostEditTextHandler(c *gin.Context) { return } - c.JSON(http.StatusOK, gin.H{"success": true, "redirect": "/view_text/" + filePath}) + c.JSON(http.StatusOK, gin.H{"success": true, "redirect": h.config.URLPrefix + "/view_text/" + filePath}) } func New(cfg *config.Config, store *sessions.CookieStore, authSvc *auth.Service) *Handlers { diff --git a/internal/handlers/settings.go b/internal/handlers/settings.go index c9d2f76..c125e9c 100644 --- a/internal/handlers/settings.go +++ b/internal/handlers/settings.go @@ -30,10 +30,9 @@ func (h *Handlers) SettingsPageHandler(c *gin.Context) { "notes_tree": notesTree, "active_path": []string{}, "current_note": nil, - "breadcrumbs": []gin.H{ - {"name": "/", "url": "/"}, - {"name": "Settings", "url": ""}, - }, + "breadcrumbs": utils.GenerateBreadcrumbs(""), + "Authenticated": isAuthenticated(c), + "IsAdmin": isAdmin(c), "ContentTemplate": "settings_content", "ScriptsTemplate": "settings_scripts", "Page": "settings", diff --git a/internal/markdown/renderer.go b/internal/markdown/renderer.go index 4bf5927..ce0d5fb 100644 --- a/internal/markdown/renderer.go +++ b/internal/markdown/renderer.go @@ -100,6 +100,11 @@ func (r *Renderer) processObsidianImages(content string, notePath string) string imageURL = fmt.Sprintf("/serve_attached_image/%s", cleanPath) } + // Prefix with configured URL base + if bp := r.config.URLPrefix; bp != "" { + imageURL = bp + imageURL + } + // Convert to standard markdown image syntax alt := filepath.Base(imagePath) return fmt.Sprintf("", alt, imageURL) @@ -127,6 +132,9 @@ func (r *Renderer) processObsidianLinks(content string) string { // Convert note name to URL-friendly format noteURL := strings.ReplaceAll(noteName, " ", "%20") noteURL = fmt.Sprintf("/note/%s.md", noteURL) + if bp := r.config.URLPrefix; bp != "" { + noteURL = bp + noteURL + } // Convert to standard markdown link syntax return fmt.Sprintf("[%s](%s)", displayText, noteURL) diff --git a/internal/server/middleware.go b/internal/server/middleware.go index 736c3d6..a4872b7 100644 --- a/internal/server/middleware.go +++ b/internal/server/middleware.go @@ -84,7 +84,7 @@ func (s *Server) CSRFRequire() gin.HandlerFunc { func (s *Server) RequireAuth() gin.HandlerFunc { return func(c *gin.Context) { if _, exists := c.Get("user_id"); !exists { - c.Redirect(http.StatusFound, "/editor/login") + c.Redirect(http.StatusFound, s.config.URLPrefix+"/editor/login") c.Abort() return } @@ -96,7 +96,7 @@ func (s *Server) RequireAuth() gin.HandlerFunc { func (s *Server) RequireAdmin() gin.HandlerFunc { return func(c *gin.Context) { if _, exists := c.Get("user_id"); !exists { - c.Redirect(http.StatusFound, "/editor/login") + c.Redirect(http.StatusFound, s.config.URLPrefix+"/editor/login") c.Abort() return } diff --git a/internal/server/server.go b/internal/server/server.go index fdab476..a360e2b 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -85,28 +85,30 @@ func (s *Server) Start() error { func (s *Server) setupRoutes() { h := handlers.New(s.config, s.store, s.auth) + // Group all routes under optional URL prefix + r := s.router.Group(s.config.URLPrefix) // Main routes - s.router.GET("/", h.IndexHandler) - s.router.GET("/folder/*path", h.FolderHandler) - s.router.GET("/note/*path", h.NoteHandler) + r.GET("/", h.IndexHandler) + r.GET("/folder/*path", h.FolderHandler) + r.GET("/note/*path", h.NoteHandler) // File serving routes - s.router.GET("/serve_attached_image/*path", h.ServeAttachedImageHandler) - s.router.GET("/serve_stored_image/:filename", h.ServeStoredImageHandler) - s.router.GET("/download/*path", h.DownloadHandler) - s.router.GET("/view_text/*path", h.ViewTextHandler) + r.GET("/serve_attached_image/*path", h.ServeAttachedImageHandler) + r.GET("/serve_stored_image/:filename", h.ServeStoredImageHandler) + r.GET("/download/*path", h.DownloadHandler) + r.GET("/view_text/*path", h.ViewTextHandler) // Auth routes - s.router.GET("/editor/login", h.LoginPage) - s.router.POST("/editor/login", s.CSRFRequire(), h.LoginPost) - s.router.POST("/editor/logout", s.RequireAuth(), s.CSRFRequire(), h.LogoutPost) + r.GET("/editor/login", h.LoginPage) + r.POST("/editor/login", s.CSRFRequire(), h.LoginPost) + r.POST("/editor/logout", s.RequireAuth(), s.CSRFRequire(), h.LogoutPost) // MFA challenge routes (no auth yet, but CSRF) - s.router.GET("/editor/mfa", s.CSRFRequire(), h.MFALoginPage) - s.router.POST("/editor/mfa", s.CSRFRequire(), h.MFALoginVerify) + r.GET("/editor/mfa", s.CSRFRequire(), h.MFALoginPage) + r.POST("/editor/mfa", s.CSRFRequire(), h.MFALoginVerify) // New /editor group protected by auth + CSRF - editor := s.router.Group("/editor", s.RequireAuth(), s.CSRFRequire()) + editor := r.Group("/editor", s.RequireAuth(), s.CSRFRequire()) { editor.GET("/create", h.CreateNotePageHandler) editor.POST("/create", h.CreateNoteHandler) @@ -171,8 +173,8 @@ func (s *Server) setupRoutes() { } // API routes - s.router.GET("/api/tree", h.TreeAPIHandler) - s.router.GET("/api/search", h.SearchHandler) + r.GET("/api/tree", h.TreeAPIHandler) + r.GET("/api/search", h.SearchHandler) } func (s *Server) setupStaticFiles() { @@ -181,9 +183,9 @@ func (s *Server) setupStaticFiles() { if err != nil { panic(err) } - s.router.StaticFS("/static", http.FS(sub)) + s.router.StaticFS(s.config.URLPrefix+"/static", http.FS(sub)) // Favicon from same sub FS - s.router.StaticFileFS("/favicon.ico", "favicon.ico", http.FS(sub)) + s.router.StaticFileFS(s.config.URLPrefix+"/favicon.ico", "favicon.ico", http.FS(sub)) } func (s *Server) setupTemplates() { @@ -192,6 +194,24 @@ func (s *Server) setupTemplates() { "formatSize": utils.FormatFileSize, "formatTime": utils.FormatTime, "join": strings.Join, + // base returns the configured URL prefix ("" for root) + "base": func() string { return s.config.URLPrefix }, + // url joins the base with the given path ensuring single slash + "url": func(p string) string { + bp := s.config.URLPrefix + if bp == "" { + if p == "" { return "" } + if strings.HasPrefix(p, "/") { return p } + return "/" + p + } + if p == "" || p == "/" { + return bp + "/" + } + if strings.HasPrefix(p, "/") { + return bp + p + } + return bp + "/" + p + }, "contains": func(slice []string, item string) bool { for _, s := range slice { if s == item { diff --git a/web/static/app.js b/web/static/app.js index abacc9b..50de14f 100644 --- a/web/static/app.js +++ b/web/static/app.js @@ -64,9 +64,11 @@ function initEnhancedUpload() { // Enhanced upload function with progress tracking function uploadFilesWithProgress(files) { - const folderPath = window.location.pathname.includes('/folder/') - ? window.location.pathname.replace('/folder/', '') - : ''; + // Derive folder path accounting for BASE prefix + const base = (window.BASE || '').replace(/\/$/, ''); + let path = window.location.pathname || ''; + if (base && path.startsWith(base)) path = path.slice(base.length); + const folderPath = path.startsWith('/folder/') ? path.replace('/folder/', '') : ''; uploadElements.progress.classList.remove('hidden'); uploadElements.progressBar.style.width = '0%'; @@ -117,7 +119,7 @@ function initEnhancedUpload() { const m = document.cookie.match(/(?:^|; )csrf_token=([^;]+)/); const csrf = m && m[1] ? decodeURIComponent(m[1]) : ''; - xhr.open('POST', '/editor/upload'); + xhr.open('POST', window.prefix('/editor/upload')); if (csrf) { try { xhr.setRequestHeader('X-CSRF-Token', csrf); } catch (_) {} } @@ -210,13 +212,13 @@ function initKeyboardShortcuts() { case 'n': if (e.ctrlKey || e.metaKey) { e.preventDefault(); - window.location.href = '/editor/create'; + window.location.href = window.prefix('/editor/create'); } break; case 's': if (e.ctrlKey || e.metaKey) { e.preventDefault(); - window.location.href = '/editor/settings'; + window.location.href = window.prefix('/editor/settings'); } break; } diff --git a/web/templates/admin.html b/web/templates/admin.html index d5f149e..0a993fd 100644 --- a/web/templates/admin.html +++ b/web/templates/admin.html @@ -184,7 +184,7 @@ formCreateUser.addEventListener('submit', async (e) => { e.preventDefault(); const fd = new FormData(formCreateUser); - const res = await fetch('/editor/admin/users', { method: 'POST', headers: { 'X-CSRF-Token': getCSRF() }, body: fd }); + const res = await fetch(window.prefix('/editor/admin/users'), { method: 'POST', headers: { 'X-CSRF-Token': getCSRF() }, body: fd }); const data = await res.json().catch(() => ({})); if (res.ok && data.success) { showNotification('User created', 'success'); window.location.reload(); } else { showNotification('Create user failed: ' + (data.error || res.statusText), 'error'); } @@ -200,7 +200,7 @@ if (!id) return; if (username === 'admin') { showNotification('Cannot delete default admin user', 'error'); return; } if (!confirm('Delete user ' + username + ' ?')) return; - const res = await fetch('/editor/admin/users/' + encodeURIComponent(id), { method: 'DELETE', headers: { 'X-CSRF-Token': getCSRF() } }); + const res = await fetch(window.prefix('/editor/admin/users/' + encodeURIComponent(id)), { method: 'DELETE', headers: { 'X-CSRF-Token': getCSRF() } }); const data = await res.json().catch(() => ({})); if (res.ok && data.success) { showNotification('User deleted', 'success'); window.location.reload(); } else { showNotification('Delete user failed: ' + (data.error || res.statusText), 'error'); } @@ -216,7 +216,7 @@ const active = action === 'user-activate' ? '1' : '0'; const fd = new FormData(); fd.set('active', active); - const res = await fetch('/editor/admin/users/' + encodeURIComponent(id) + '/active', { method: 'POST', headers: { 'X-CSRF-Token': getCSRF() }, body: fd }); + const res = await fetch(window.prefix('/editor/admin/users/' + encodeURIComponent(id) + '/active'), { method: 'POST', headers: { 'X-CSRF-Token': getCSRF() }, body: fd }); const data = await res.json().catch(() => ({})); if (res.ok && data.success) { showNotification('User status updated', 'success'); window.location.reload(); } else { showNotification('Update status failed: ' + (data.error || res.statusText), 'error'); } @@ -226,7 +226,7 @@ // MFA actions const mfaRequest = async (row, path, okMsg) => { const id = row && row.getAttribute('data-user-id'); - const res = await fetch('/editor/admin/users/' + encodeURIComponent(id) + path, { method: 'POST', headers: { 'X-CSRF-Token': getCSRF() } }); + const res = await fetch(window.prefix('/editor/admin/users/' + encodeURIComponent(id) + path), { method: 'POST', headers: { 'X-CSRF-Token': getCSRF() } }); const data = await res.json().catch(() => ({})); if (res.ok && data.success) { showNotification(okMsg, 'success'); window.location.reload(); } else { showNotification('MFA action failed: ' + (data.error || res.statusText), 'error'); } @@ -241,7 +241,7 @@ formCreateGroup.addEventListener('submit', async (e) => { e.preventDefault(); const fd = new FormData(formCreateGroup); - const res = await fetch('/editor/admin/groups', { method: 'POST', headers: { 'X-CSRF-Token': getCSRF() }, body: fd }); + const res = await fetch(window.prefix('/editor/admin/groups'), { method: 'POST', headers: { 'X-CSRF-Token': getCSRF() }, body: fd }); const data = await res.json().catch(() => ({})); if (res.ok && data.success) { showNotification('Group created', 'success'); window.location.reload(); } else { showNotification('Create group failed: ' + (data.error || res.statusText), 'error'); } @@ -257,7 +257,7 @@ if (!id) return; if (name === 'admin' || name === 'public') { showNotification('Cannot delete core group: ' + name, 'error'); return; } if (!confirm('Delete group ' + name + ' ?')) return; - const res = await fetch('/editor/admin/groups/' + encodeURIComponent(id), { method: 'DELETE', headers: { 'X-CSRF-Token': getCSRF() } }); + const res = await fetch(window.prefix('/editor/admin/groups/' + encodeURIComponent(id)), { method: 'DELETE', headers: { 'X-CSRF-Token': getCSRF() } }); const data = await res.json().catch(() => ({})); if (res.ok && data.success) { showNotification('Group deleted', 'success'); window.location.reload(); } else { showNotification('Delete group failed: ' + (data.error || res.statusText), 'error'); } @@ -270,7 +270,7 @@ formAddMem.addEventListener('submit', async (e) => { e.preventDefault(); const fd = new FormData(formAddMem); - const res = await fetch('/editor/admin/memberships/add', { method: 'POST', headers: { 'X-CSRF-Token': getCSRF() }, body: fd }); + const res = await fetch(window.prefix('/editor/admin/memberships/add'), { method: 'POST', headers: { 'X-CSRF-Token': getCSRF() }, body: fd }); const data = await res.json().catch(() => ({})); if (res.ok && data.success) { showNotification('User added to group', 'success'); } else { showNotification('Add membership failed: ' + (data.error || res.statusText), 'error'); } @@ -283,7 +283,7 @@ formRemMem.addEventListener('submit', async (e) => { e.preventDefault(); const fd = new FormData(formRemMem); - const res = await fetch('/editor/admin/memberships/remove', { method: 'POST', headers: { 'X-CSRF-Token': getCSRF() }, body: fd }); + const res = await fetch(window.prefix('/editor/admin/memberships/remove'), { method: 'POST', headers: { 'X-CSRF-Token': getCSRF() }, body: fd }); const data = await res.json().catch(() => ({})); if (res.ok && data.success) { showNotification('User removed from group', 'success'); } else { showNotification('Remove membership failed: ' + (data.error || res.statusText), 'error'); } diff --git a/web/templates/admin_logs.html b/web/templates/admin_logs.html index 0e1a85b..809ac76 100644 --- a/web/templates/admin_logs.html +++ b/web/templates/admin_logs.html @@ -9,11 +9,11 @@
Recent access, errors, failed logins, and IP bans
- Back to Admin + Back to Admin - @@ -249,7 +249,7 @@ async function postForm(url, data) { const csrf = getCSRF(); const form = new URLSearchParams(); Object.entries(data || {}).forEach(([k, v]) => form.append(k, v)); - const res = await fetch(url, { method: 'POST', headers: Object.assign({'Content-Type': 'application/x-www-form-urlencoded'}, csrf ? {'X-CSRF-Token': csrf} : {}), body: form.toString() }); + const res = await fetch(window.prefix(url), { method: 'POST', headers: Object.assign({'Content-Type': 'application/x-www-form-urlencoded'}, csrf ? {'X-CSRF-Token': csrf} : {}), body: form.toString() }); if (!res.ok) { const j = await res.json().catch(() => ({})); throw new Error(j.error || res.statusText); diff --git a/web/templates/base.html b/web/templates/base.html index 489d14f..aef09e8 100644 --- a/web/templates/base.html +++ b/web/templates/base.html @@ -264,7 +264,7 @@