Compare commits

...

6 Commits

Author SHA1 Message Date
nahakubuilde
563062368f fix css not imported when prefix used 2025-08-28 08:07:30 +01:00
nahakubuilde
735d48953a remove the email smtp config and logs location moved to admin section 2025-08-28 07:43:37 +01:00
nahakubuilde
f364a4b6db add local TailwindCSS, fix side bar folder view, add more function to Markdown editor and allow .markdown files 2025-08-28 07:29:51 +01:00
nahakubuilde
090d491dd6 fix view 2025-08-26 21:43:47 +01:00
nahakubuilde
e8658f5aab add optional prefix to url 2025-08-26 20:55:08 +01:00
nahakubuilde
6fb6054803 embed web files for build 2025-08-26 19:55:28 +01:00
35 changed files with 1184 additions and 338 deletions

4
.gitignore vendored
View File

@@ -1,5 +1,4 @@
notes/**/**
.github
mynotes_*
_python_example/
notes/
@@ -13,3 +12,6 @@ __pycache__/
*.sqlite3
*.log
*.bak
tailwindcss-linux-x64
gobsidian

27
Makefile Normal file
View File

@@ -0,0 +1,27 @@
# Build the project ensuring Tailwind CSS is embedded.
# Usage:
# make build - builds gobsidian with embedded assets
# make build-linux - builds linux/amd64 binary (for server deploy)
# make clean
TAILWIND=./tailwindcss-linux-x64
TAILWIND_IN=./web/static/tailwind.input.css
TAILWIND_OUT=./web/static/tailwind.css
BINARY=gobsidian
.PHONY: build build-linux css clean
css:
@echo "Building Tailwind CSS -> $(TAILWIND_OUT)"
$(TAILWIND) -i $(TAILWIND_IN) -o $(TAILWIND_OUT) --minify
build: css
@echo "Building $(BINARY) with embedded assets"
go build -o $(BINARY) ./cmd
build-linux: css
@echo "Building $(BINARY) for linux/amd64 with embedded assets"
GOOS=linux GOARCH=amd64 go build -o $(BINARY) ./cmd
clean:
rm -f $(BINARY)

View File

@@ -120,9 +120,15 @@ gobsidian/
### Building
To build a standalone binary:
```bash
go build -o gobsidian cmd/main.go
# in case you change CSS or add some, recompile tailwind.css
wget https://github.com/tailwindlabs/tailwindcss/releases/download/v4.1.12/tailwindcss-linux-x64
chmod +x tailwindcss-linux-x64
./tailwindcss-linux-x64 -i ./web/static/tailwind.input.css -o ./web/static/tailwind.css --minify
```
```bash
go mod tidy
GOOS=linux GOARCH=amd64 go build -o gobsidian ./cmd
```
## Image storing trying to follow Obsidian settings
Image storing modes:

3
go.mod
View File

@@ -9,7 +9,8 @@ require (
github.com/gin-gonic/gin v1.9.1
github.com/gorilla/sessions v1.2.1
github.com/h2non/filetype v1.1.3
github.com/yuin/goldmark v1.6.0
github.com/yuin/goldmark v1.7.10
github.com/yuin/goldmark-emoji v1.0.6
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc
golang.org/x/crypto v0.9.0
gopkg.in/ini.v1 v1.67.0

6
go.sum
View File

@@ -92,8 +92,10 @@ github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2
github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU=
github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
github.com/yuin/goldmark v1.4.15/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yuin/goldmark v1.6.0 h1:boZcn2GTjpsynOsC0iJHnBWa4Bi0qzfJjthwauItG68=
github.com/yuin/goldmark v1.6.0/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yuin/goldmark v1.7.10 h1:S+LrtBjRmqMac2UdtB6yyCEJm+UILZ2fefI4p7o0QpI=
github.com/yuin/goldmark v1.7.10/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
github.com/yuin/goldmark-emoji v1.0.6 h1:QWfF2FYaXwL74tfGOW5izeiZepUDroDJfWubQI9HTHs=
github.com/yuin/goldmark-emoji v1.0.6/go.mod h1:ukxJDKFpdFb5x0a5HqbdlcKtebh086iJpI31LTKmWuA=
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc h1:+IAOyRda+RLrxa1WC7umKOZRsGq4QrFFMYApOeHzQwQ=
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc/go.mod h1:ovIvrum6DQJA4QsJSovrkC4saKHQVs7TvcaeO8AIl5I=
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=

View File

@@ -17,6 +17,7 @@ type Config struct {
SecretKey string
Debug bool
MaxContentLength int64 // in bytes
URLPrefix string
// MD Notes App settings
AppName string
@@ -41,24 +42,16 @@ type Config struct {
DBPath string
// Auth settings
RequireAdminActivation bool
RequireAdminActivation bool
RequireEmailConfirmation bool
MFAEnabledByDefault bool
// Email (SMTP) settings
SMTPHost string
SMTPPort int
SMTPUsername string
SMTPPassword string
SMTPSender string
SMTPUseTLS bool
MFAEnabledByDefault bool
// Security settings (failed-login thresholds and auto-ban config)
PwdFailuresThreshold int
MFAFailuresThreshold int
FailuresWindowMinutes int
AutoBanDurationHours int
AutoBanPermanent bool
PwdFailuresThreshold int
MFAFailuresThreshold int
FailuresWindowMinutes int
AutoBanDurationHours int
AutoBanPermanent bool
}
var defaultConfig = map[string]map[string]string{
@@ -68,6 +61,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",
@@ -91,17 +85,9 @@ var defaultConfig = map[string]map[string]string{
"PATH": "data/gobsidian.db",
},
"AUTH": {
"REQUIRE_ADMIN_ACTIVATION": "true",
"REQUIRE_EMAIL_CONFIRMATION": "true",
"MFA_ENABLED_BY_DEFAULT": "false",
},
"EMAIL": {
"SMTP_HOST": "",
"SMTP_PORT": "587",
"SMTP_USERNAME": "",
"SMTP_PASSWORD": "",
"SMTP_SENDER": "",
"SMTP_USE_TLS": "true",
"REQUIRE_ADMIN_ACTIVATION": "true",
"REQUIRE_EMAIL_CONFIRMATION": "false",
"MFA_ENABLED_BY_DEFAULT": "false",
},
"SECURITY": {
"PWD_FAILURES_THRESHOLD": "5",
@@ -112,8 +98,20 @@ var defaultConfig = map[string]map[string]string{
},
}
// exeDir returns the directory containing the running executable.
func exeDir() string {
exe, err := os.Executable()
if err != nil {
wd, _ := os.Getwd()
return wd
}
return filepath.Dir(exe)
}
func Load() (*Config, error) {
configPath := "settings.ini"
baseDir := exeDir()
// settings.ini lives next to the executable
configPath := filepath.Join(baseDir, "settings.ini")
// Ensure config file exists
if err := ensureConfigFile(configPath); err != nil {
@@ -136,6 +134,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")
@@ -167,15 +173,23 @@ func Load() (*Config, error) {
config.ImageStoragePath = notesSection.Key("IMAGE_STORAGE_PATH").String()
config.ImageSubfolderName = notesSection.Key("IMAGE_SUBFOLDER_NAME").String()
// Convert relative paths to absolute
// Convert relative paths to be next to the executable
if !filepath.IsAbs(config.NotesDir) {
wd, _ := os.Getwd()
config.NotesDir = filepath.Join(wd, config.NotesDir)
config.NotesDir = filepath.Join(baseDir, config.NotesDir)
}
if !filepath.IsAbs(config.ImageStoragePath) && config.ImageStorageMode == 2 {
wd, _ := os.Getwd()
config.ImageStoragePath = filepath.Join(wd, config.ImageStoragePath)
config.ImageStoragePath = filepath.Join(baseDir, config.ImageStoragePath)
}
// Ensure these directories exist
if err := os.MkdirAll(config.NotesDir, 0o755); err != nil {
return nil, fmt.Errorf("failed to create notes directory: %w", err)
}
if config.ImageStorageMode == 2 {
if err := os.MkdirAll(config.ImageStoragePath, 0o755); err != nil {
return nil, fmt.Errorf("failed to create image storage directory: %w", err)
}
}
// Load DATABASE section
@@ -184,8 +198,7 @@ func Load() (*Config, error) {
config.DBPath = dbSection.Key("PATH").String()
if config.DBType == "sqlite" {
if !filepath.IsAbs(config.DBPath) {
wd, _ := os.Getwd()
config.DBPath = filepath.Join(wd, config.DBPath)
config.DBPath = filepath.Join(baseDir, config.DBPath)
}
// ensure parent dir exists
if err := os.MkdirAll(filepath.Dir(config.DBPath), 0o755); err != nil {
@@ -199,15 +212,6 @@ func Load() (*Config, error) {
config.RequireEmailConfirmation, _ = authSection.Key("REQUIRE_EMAIL_CONFIRMATION").Bool()
config.MFAEnabledByDefault, _ = authSection.Key("MFA_ENABLED_BY_DEFAULT").Bool()
// Load EMAIL (SMTP) section
emailSection := cfg.Section("EMAIL")
config.SMTPHost = emailSection.Key("SMTP_HOST").String()
config.SMTPPort, _ = emailSection.Key("SMTP_PORT").Int()
config.SMTPUsername = emailSection.Key("SMTP_USERNAME").String()
config.SMTPPassword = emailSection.Key("SMTP_PASSWORD").String()
config.SMTPSender = emailSection.Key("SMTP_SENDER").String()
config.SMTPUseTLS, _ = emailSection.Key("SMTP_USE_TLS").Bool()
// Load SECURITY section
secSection := cfg.Section("SECURITY")
config.PwdFailuresThreshold, _ = secSection.Key("PWD_FAILURES_THRESHOLD").Int()
@@ -222,6 +226,10 @@ func Load() (*Config, error) {
func ensureConfigFile(configPath string) error {
// Check if file exists
if _, err := os.Stat(configPath); os.IsNotExist(err) {
// ensure parent dir exists
if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil {
return err
}
return createDefaultConfigFile(configPath)
}
@@ -290,7 +298,7 @@ func parseCommaSeparated(value string) []string {
}
func (c *Config) SaveSetting(section, key, value string) error {
configPath := "settings.ini"
configPath := filepath.Join(exeDir(), "settings.ini")
cfg, err := ini.Load(configPath)
if err != nil {
return err
@@ -317,6 +325,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 {
@@ -367,23 +383,6 @@ func (c *Config) SaveSetting(section, key, value string) error {
case "MFA_ENABLED_BY_DEFAULT":
c.MFAEnabledByDefault = value == "true"
}
case "EMAIL":
switch key {
case "SMTP_HOST":
c.SMTPHost = value
case "SMTP_PORT":
if v, err := strconv.Atoi(value); err == nil {
c.SMTPPort = v
}
case "SMTP_USERNAME":
c.SMTPUsername = value
case "SMTP_PASSWORD":
c.SMTPPassword = value
case "SMTP_SENDER":
c.SMTPSender = value
case "SMTP_USE_TLS":
c.SMTPUseTLS = value == "true"
}
case "SECURITY":
switch key {
case "PWD_FAILURES_THRESHOLD":

View File

@@ -14,16 +14,25 @@ import (
"time"
"github.com/gin-gonic/gin"
"gobsidian/internal/utils"
)
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")
// propagate return_to if provided
returnTo := c.Query("return_to")
c.HTML(http.StatusOK, "login", gin.H{
"app_name": h.config.AppName,
"csrf_token": token,
"return_to": returnTo,
"ContentTemplate": "login_content",
"ScriptsTemplate": "login_scripts",
"Page": "login",
@@ -128,7 +137,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 +170,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)
@@ -187,15 +196,24 @@ func (h *Handlers) MFALoginVerify(c *gin.Context) {
// success: set user_id and clear mfa_user_id
delete(session.Values, "mfa_user_id")
session.Values["user_id"] = uid
// use return_to if set in session
var dest string
if v, ok := session.Values["return_to"].(string); ok {
dest = sanitizeReturnTo(h.config.URLPrefix, v)
delete(session.Values, "return_to")
}
_ = session.Save(c.Request, c.Writer)
c.Redirect(http.StatusFound, "/")
if dest == "" {
dest = h.config.URLPrefix + "/"
}
c.Redirect(http.StatusFound, dest)
}
// 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
@@ -218,9 +236,16 @@ func (h *Handlers) ProfileMFASetupPage(c *gin.Context) {
label := url.PathEscape(fmt.Sprintf("%s:%s", issuer, username))
otpauth := fmt.Sprintf("otpauth://totp/%s?secret=%s&issuer=%s&digits=6&period=30&algorithm=SHA1", label, secret, url.QueryEscape(issuer))
// Render simple page (uses base.html shell)
// Build sidebar tree for consistent UI and pass auth flags
notesTree, _ := utils.BuildTreeStructure(h.config.NotesDir, h.config.NotesDirHideSidepane, h.config)
c.HTML(http.StatusOK, "mfa_setup", gin.H{
"app_name": h.config.AppName,
"notes_tree": notesTree,
"active_path": []string{},
"current_note": nil,
"breadcrumbs": utils.GenerateBreadcrumbs(""),
"Authenticated": true,
"IsAdmin": isAdmin(c),
"Secret": secret,
"OTPAuthURI": otpauth,
"ContentTemplate": "mfa_setup_content",
@@ -308,6 +333,7 @@ func verifyTOTP(base32Secret, code string, t time.Time) bool {
func (h *Handlers) LoginPost(c *gin.Context) {
username := c.PostForm("username")
password := c.PostForm("password")
returnTo := strings.TrimSpace(c.PostForm("return_to"))
user, err := h.authSvc.Authenticate(username, password)
if err != nil {
@@ -318,6 +344,7 @@ func (h *Handlers) LoginPost(c *gin.Context) {
"app_name": h.config.AppName,
"csrf_token": token,
"error": err.Error(),
"return_to": returnTo,
"ContentTemplate": "login_content",
"ScriptsTemplate": "login_scripts",
"Page": "login",
@@ -328,28 +355,30 @@ func (h *Handlers) LoginPost(c *gin.Context) {
if user.MFASecret.Valid && user.MFASecret.String != "" {
session, _ := h.store.Get(c.Request, sessionCookieName)
session.Values["mfa_user_id"] = user.ID
if rt := sanitizeReturnTo(h.config.URLPrefix, returnTo); rt != "" {
session.Values["return_to"] = rt
}
_ = session.Save(c.Request, c.Writer)
c.Redirect(http.StatusFound, "/editor/mfa")
c.Redirect(http.StatusFound, h.config.URLPrefix+"/editor/mfa")
return
}
// If admin created an enrollment for this user, force MFA setup after login
var pending int
if err := h.authSvc.DB.QueryRow(`SELECT 1 FROM mfa_enrollments WHERE user_id = ?`, user.ID).Scan(&pending); err == nil {
// normal login, then redirect to setup
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")
return
}
// Do NOT automatically force MFA setup just because an enrollment row exists.
// Some deployments may leave stale enrollment rows; we only require MFA when
// the user actually has MFA enabled (mfa_secret set) or when they explicitly
// navigate to setup from profile.
// Create normal session
session, _ := h.store.Get(c.Request, sessionCookieName)
session.Values["user_id"] = user.ID
_ = session.Save(c.Request, c.Writer)
c.Redirect(http.StatusFound, "/")
// Redirect to requested page if provided and safe; otherwise home
if rt := sanitizeReturnTo(h.config.URLPrefix, returnTo); rt != "" {
c.Redirect(http.StatusFound, rt)
} else {
c.Redirect(http.StatusFound, h.config.URLPrefix+"/")
}
}
// LogoutPost clears the session
@@ -357,5 +386,36 @@ 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")
}
// sanitizeReturnTo ensures the provided return_to is a safe in-app path.
// It rejects absolute URLs and protocol-relative URLs. When URLPrefix is set,
// it enforces that the destination stays within that prefix; if a bare
// "/..." path is provided, it will be rewritten to include the prefix.
func sanitizeReturnTo(prefix, v string) string {
v = strings.TrimSpace(v)
if v == "" {
return ""
}
// Disallow absolute and protocol-relative URLs
if strings.HasPrefix(v, "//") {
return ""
}
if u, err := url.Parse(v); err != nil || (u != nil && u.IsAbs()) {
return ""
}
// Must be a path
if !strings.HasPrefix(v, "/") {
v = "/" + v
}
// Enforce prefix containment when configured
if prefix != "" {
if strings.HasPrefix(v, prefix+"/") || v == prefix || v == prefix+"/" {
return v
}
// If it's a root-relative path without prefix, rewrite into prefix
return prefix + v
}
return v
}

View File

@@ -96,8 +96,8 @@ func (h *Handlers) CreateNoteHandler(c *gin.Context) {
title += ".md"
ext = "md"
} else {
// Has extension: allow if md or in allowed file extensions
allowed := ext == "md"
// Has extension: allow if md/markdown or in allowed file extensions
allowed := (ext == "md" || ext == "markdown")
if !allowed {
for _, a := range h.config.AllowedFileExtensions {
if strings.EqualFold(a, ext) {
@@ -142,9 +142,9 @@ func (h *Handlers) CreateNoteHandler(c *gin.Context) {
}
// Redirect based on extension
redirect := "/note/" + notePath
if strings.ToLower(ext) != "md" {
redirect = "/view_text/" + notePath
redirect := h.config.URLPrefix + "/note/" + notePath
if e := strings.ToLower(ext); e != "md" && e != "markdown" {
redirect = h.config.URLPrefix + "/view_text/" + notePath
}
c.JSON(http.StatusOK, gin.H{
@@ -158,11 +158,12 @@ func (h *Handlers) CreateNoteHandler(c *gin.Context) {
func (h *Handlers) EditNotePageHandler(c *gin.Context) {
notePath := strings.TrimPrefix(c.Param("path"), "/")
if !strings.HasSuffix(notePath, ".md") {
lp := strings.ToLower(notePath)
if !(strings.HasSuffix(lp, ".md") || strings.HasSuffix(lp, ".markdown")) {
c.HTML(http.StatusBadRequest, "error", gin.H{
"error": "Invalid note path",
"app_name": h.config.AppName,
"message": "Note path must end with .md",
"message": "Note path must end with .md or .markdown",
"ContentTemplate": "error_content",
"ScriptsTemplate": "error_scripts",
"Page": "error",
@@ -236,7 +237,13 @@ func (h *Handlers) EditNotePageHandler(c *gin.Context) {
return
}
title := strings.TrimSuffix(filepath.Base(notePath), ".md")
base := filepath.Base(notePath)
var title string
if strings.HasSuffix(strings.ToLower(base), ".markdown") {
title = strings.TrimSuffix(base, ".markdown")
} else {
title = strings.TrimSuffix(base, ".md")
}
folderPath := filepath.Dir(notePath)
if folderPath == "." {
folderPath = ""
@@ -264,7 +271,8 @@ func (h *Handlers) EditNoteHandler(c *gin.Context) {
notePath := strings.TrimPrefix(c.Param("path"), "/")
content := c.PostForm("content")
if !strings.HasSuffix(notePath, ".md") {
lp := strings.ToLower(notePath)
if !(strings.HasSuffix(lp, ".md") || strings.HasSuffix(lp, ".markdown")) {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid note path"})
return
}
@@ -297,7 +305,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,
})
}

View File

@@ -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 {
@@ -1077,11 +1079,12 @@ func (h *Handlers) FolderHandler(c *gin.Context) {
func (h *Handlers) NoteHandler(c *gin.Context) {
notePath := strings.TrimPrefix(c.Param("path"), "/")
if !strings.HasSuffix(notePath, ".md") {
// Allow both .md and .markdown files
if !(strings.HasSuffix(strings.ToLower(notePath), ".md") || strings.HasSuffix(strings.ToLower(notePath), ".markdown")) {
c.HTML(http.StatusBadRequest, "error", gin.H{
"error": "Invalid note path",
"app_name": h.config.AppName,
"message": "Note path must end with .md",
"message": "Note path must end with .md or .markdown",
"ContentTemplate": "error_content",
"ScriptsTemplate": "error_scripts",
"Page": "error",
@@ -1141,15 +1144,48 @@ func (h *Handlers) NoteHandler(c *gin.Context) {
fullPath := filepath.Join(h.config.NotesDir, notePath)
if _, err := os.Stat(fullPath); os.IsNotExist(err) {
c.HTML(http.StatusNotFound, "error", gin.H{
"error": "Note not found",
"app_name": h.config.AppName,
"message": "The requested note does not exist",
"ContentTemplate": "error_content",
"ScriptsTemplate": "error_scripts",
"Page": "error",
})
return
// Fallback: try the alternate markdown extension
base := filepath.Base(notePath)
dir := filepath.Dir(notePath)
lower := strings.ToLower(base)
var alt string
if strings.HasSuffix(lower, ".md") {
alt = base[:len(base)-len(".md")] + ".markdown"
} else if strings.HasSuffix(lower, ".markdown") {
alt = base[:len(base)-len(".markdown")] + ".md"
}
if alt != "" {
altRel := alt
if dir != "." && dir != "" {
altRel = filepath.Join(dir, alt)
}
altFull := filepath.Join(h.config.NotesDir, altRel)
if _, err2 := os.Stat(altFull); err2 == nil {
// Use the alternate path
notePath = altRel
fullPath = altFull
} else {
c.HTML(http.StatusNotFound, "error", gin.H{
"error": "Note not found",
"app_name": h.config.AppName,
"message": "The requested note does not exist",
"ContentTemplate": "error_content",
"ScriptsTemplate": "error_scripts",
"Page": "error",
})
return
}
} else {
c.HTML(http.StatusNotFound, "error", gin.H{
"error": "Note not found",
"app_name": h.config.AppName,
"message": "The requested note does not exist",
"ContentTemplate": "error_content",
"ScriptsTemplate": "error_scripts",
"Page": "error",
})
return
}
}
content, err := os.ReadFile(fullPath)
@@ -1191,7 +1227,16 @@ func (h *Handlers) NoteHandler(c *gin.Context) {
return
}
title := strings.TrimSuffix(filepath.Base(notePath), ".md")
base := filepath.Base(notePath)
lower := strings.ToLower(base)
var title string
if strings.HasSuffix(lower, ".markdown") {
title = base[:len(base)-len(".markdown")]
} else if strings.HasSuffix(lower, ".md") {
title = base[:len(base)-len(".md")]
} else {
title = strings.TrimSuffix(base, filepath.Ext(base))
}
folderPath := filepath.Dir(notePath)
if folderPath == "." {
folderPath = ""
@@ -1677,7 +1722,7 @@ func (h *Handlers) SearchHandler(c *gin.Context) {
// Skip disallowed files
ext := strings.ToLower(filepath.Ext(relPath))
isMD := ext == ".md"
isMD := ext == ".md" || ext == ".markdown"
if !isMD && !models.IsAllowedFile(relPath, h.config.AllowedFileExtensions) {
return nil
}

View File

@@ -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",

View File

@@ -13,6 +13,7 @@ import (
"github.com/yuin/goldmark/extension"
"github.com/yuin/goldmark/parser"
"github.com/yuin/goldmark/renderer/html"
emoji "github.com/yuin/goldmark-emoji"
"gobsidian/internal/config"
)
@@ -22,6 +23,38 @@ type Renderer struct {
config *config.Config
}
// processMermaidFences wraps fenced code blocks marked as "mermaid" into <div class="mermaid">...</div>
func (r *Renderer) processMermaidFences(content string) string {
// ```mermaid\n...\n```
re := regexp.MustCompile("(?s)```mermaid\\s*(.*?)\\s*```")
return re.ReplaceAllString(content, "<div class=\"mermaid\">$1</div>")
}
// processMediaEmbeds turns a bare URL on its own line into an embed element
// Supports: audio (mp3|wav|ogg), video (mp4|webm|ogg), pdf
func (r *Renderer) processMediaEmbeds(content string) string {
lines := strings.Split(content, "\n")
mediaRe := regexp.MustCompile(`^(https?://\S+\.(?:mp3|wav|ogg|mp4|webm|ogg|pdf))$`)
for i, ln := range lines {
trimmed := strings.TrimSpace(ln)
m := mediaRe.FindStringSubmatch(trimmed)
if len(m) == 0 {
continue
}
url := m[1]
switch {
case strings.HasSuffix(strings.ToLower(url), ".mp3") || strings.HasSuffix(strings.ToLower(url), ".wav") || strings.HasSuffix(strings.ToLower(url), ".ogg"):
lines[i] = fmt.Sprintf("<audio controls preload=\"metadata\" src=\"%s\"></audio>", url)
case strings.HasSuffix(strings.ToLower(url), ".mp4") || strings.HasSuffix(strings.ToLower(url), ".webm") || strings.HasSuffix(strings.ToLower(url), ".ogg"):
lines[i] = fmt.Sprintf("<video controls preload=\"metadata\" style=\"max-width:100%%\" src=\"%s\"></video>", url)
case strings.HasSuffix(strings.ToLower(url), ".pdf"):
lines[i] = fmt.Sprintf("<iframe src=\"%s\" style=\"width:100%%;height:70vh;border:1px solid #374151;border-radius:8px\"></iframe>", url)
}
}
return strings.Join(lines, "\n")
}
func NewRenderer(cfg *config.Config) *Renderer {
md := goldmark.New(
goldmark.WithExtensions(
@@ -29,6 +62,11 @@ func NewRenderer(cfg *config.Config) *Renderer {
extension.Table,
extension.Strikethrough,
extension.TaskList,
extension.Footnote,
extension.DefinitionList,
extension.Linkify,
extension.Typographer,
emoji.Emoji,
highlighting.NewHighlighting(
highlighting.WithStyle("github-dark"),
highlighting.WithFormatOptions(
@@ -61,6 +99,12 @@ func (r *Renderer) RenderMarkdown(content string, notePath string) (string, erro
// Process Obsidian links
content = r.processObsidianLinks(content)
// Convert Mermaid fenced code blocks to <div class="mermaid"> for client rendering
content = r.processMermaidFences(content)
// Convert bare media links on their own line to embedded players (audio/video/pdf)
content = r.processMediaEmbeds(content)
var buf bytes.Buffer
if err := r.md.Convert([]byte(content), &buf); err != nil {
return "", err
@@ -100,6 +144,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("![%s](%s)", alt, imageURL)
@@ -127,6 +176,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)

View File

@@ -45,7 +45,7 @@ type ImageStorageInfo struct {
func GetFileType(extension string, allowedImageExts, allowedFileExts []string) FileType {
ext := strings.ToLower(strings.TrimPrefix(extension, "."))
if ext == "md" {
if ext == "md" || ext == "markdown" {
return FileTypeMarkdown
}

View File

@@ -5,6 +5,7 @@ import (
"database/sql"
"encoding/base64"
"net/http"
"net/url"
"time"
"github.com/gin-gonic/gin"
@@ -84,7 +85,17 @@ 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")
// Attach return_to so user can be redirected back after login
requested := c.Request.URL.RequestURI()
q := url.Values{}
if requested != "" {
q.Set("return_to", requested)
}
loginURL := s.config.URLPrefix + "/editor/login"
if qs := q.Encode(); qs != "" {
loginURL = loginURL + "?" + qs
}
c.Redirect(http.StatusFound, loginURL)
c.Abort()
return
}
@@ -96,7 +107,16 @@ 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")
requested := c.Request.URL.RequestURI()
q := url.Values{}
if requested != "" {
q.Set("return_to", requested)
}
loginURL := s.config.URLPrefix + "/editor/login"
if qs := q.Encode(); qs != "" {
loginURL = loginURL + "?" + qs
}
c.Redirect(http.StatusFound, loginURL)
c.Abort()
return
}

View File

@@ -3,12 +3,15 @@ package server
import (
"fmt"
"html/template"
"io/fs"
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/gorilla/sessions"
webassets "gobsidian/web"
"gobsidian/internal/auth"
"gobsidian/internal/config"
"gobsidian/internal/handlers"
@@ -82,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)
@@ -168,13 +173,19 @@ 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() {
s.router.Static("/static", "./web/static")
s.router.StaticFile("/favicon.ico", "./web/static/favicon.ico")
// Serve /static from embedded web/static
sub, err := fs.Sub(webassets.StaticFS, "static")
if err != nil {
panic(err)
}
s.router.StaticFS(s.config.URLPrefix+"/static", http.FS(sub))
// Favicon from same sub FS
s.router.StaticFileFS(s.config.URLPrefix+"/favicon.ico", "favicon.ico", http.FS(sub))
}
func (s *Server) setupTemplates() {
@@ -183,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 {
@@ -244,8 +273,12 @@ func (s *Server) setupTemplates() {
},
}
// Load templates - make sure base.html is loaded with all the other templates
templates := template.Must(template.New("").Funcs(funcMap).ParseGlob("web/templates/*.html"))
// Load templates from embedded FS
tplFS, err := fs.Sub(webassets.TemplatesFS, "templates")
if err != nil {
panic(err)
}
templates := template.Must(template.New("").Funcs(funcMap).ParseFS(tplFS, "*.html"))
s.router.SetHTMLTemplate(templates)
fmt.Printf("DEBUG: Templates loaded successfully\n")
@@ -253,10 +286,10 @@ func (s *Server) setupTemplates() {
// startAccessLogCleanup deletes access logs older than 7 days once at startup and then daily.
func (s *Server) startAccessLogCleanup() {
// initial cleanup
_, _ = s.auth.DB.Exec(`DELETE FROM access_logs WHERE created_at < DATETIME('now', '-7 days')`)
ticker := time.NewTicker(24 * time.Hour)
for range ticker.C {
_, _ = s.auth.DB.Exec(`DELETE FROM access_logs WHERE created_at < DATETIME('now', '-7 days')`)
}
// initial cleanup
_, _ = s.auth.DB.Exec(`DELETE FROM access_logs WHERE created_at < DATETIME('now', '-7 days')`)
ticker := time.NewTicker(24 * time.Hour)
for range ticker.C {
_, _ = s.auth.DB.Exec(`DELETE FROM access_logs WHERE created_at < DATETIME('now', '-7 days')`)
}
}

View File

@@ -136,7 +136,15 @@ func GetFolderContents(folderPath string, cfg *config.Config) ([]models.FileInfo
// Set display name based on file type
if fileInfo.Type == models.FileTypeMarkdown {
fileInfo.DisplayName = strings.TrimSuffix(entry.Name(), ".md")
name := entry.Name()
lower := strings.ToLower(name)
if strings.HasSuffix(lower, ".markdown") {
fileInfo.DisplayName = name[:len(name)-len(".markdown")]
} else if strings.HasSuffix(lower, ".md") {
fileInfo.DisplayName = name[:len(name)-len(".md")]
} else {
fileInfo.DisplayName = strings.TrimSuffix(name, filepath.Ext(name))
}
} else {
fileInfo.DisplayName = entry.Name()
}

34
tailwind.config.js Normal file
View File

@@ -0,0 +1,34 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
darkMode: 'class',
content: [
"./web/templates/**/*.html",
"./web/static/styles.css",
"./web/static/*.js",
"./**/*.go"
],
theme: {
extend: {
colors: {
obsidian: {
50: '#f8fafc',
100: '#f1f5f9',
200: '#e2e8f0',
300: '#cbd5e1',
400: '#94a3b8',
500: '#64748b',
600: '#475569',
700: '#334155',
800: '#1e293b',
900: '#0f172a',
},
},
},
},
safelist: [],
plugins: [],
}
// wget https://github.com/tailwindlabs/tailwindcss/releases/download/v4.1.12/tailwindcss-linux-x64
// chmod +x tailwindcss-linux-x64
// ./tailwindcss-linux-x64 -i ./web/static/tailwind.input.css -o ./web/static/tailwind.css --minify

11
web/assets_embed.go Normal file
View File

@@ -0,0 +1,11 @@
package webassets
import "embed"
// TemplatesFS embeds all HTML templates under web/templates.
//go:embed templates/*.html
var TemplatesFS embed.FS
// StaticFS embeds all static assets under web/static.
//go:embed static/*
var StaticFS embed.FS

View File

@@ -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;
}

2
web/static/tailwind.css Normal file

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
@import "tailwindcss";

View File

@@ -11,6 +11,22 @@
</div>
<div class="space-y-8">
<!-- Quick Actions -->
<div class="bg-gray-800 rounded-lg p-6">
<div class="flex items-center justify-between">
<div>
<h2 class="text-xl font-semibold text-white mb-1">
<i class="fas fa-tools mr-2"></i>Admin Tools
</h2>
<p class="text-gray-400">Access logs and security controls</p>
</div>
<div class="flex items-center gap-3">
<a href="{{url "/editor/admin/logs"}}" class="btn-secondary inline-flex items-center">
<i class="fas fa-list mr-2"></i>View Logs
</a>
</div>
</div>
</div>
<!-- Users -->
<div class="bg-gray-800 rounded-lg p-6">
<h2 class="text-xl font-semibold text-white mb-4">
@@ -184,7 +200,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 +216,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 +232,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 +242,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 +257,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 +273,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 +286,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 +299,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'); }

View File

@@ -9,11 +9,11 @@
<h1 class="text-3xl font-bold text-white">Admin Logs</h1>
<p class="text-gray-400">Recent access, errors, failed logins, and IP bans</p>
</div>
<a href="/editor/admin" class="btn-secondary">Back to Admin</a>
<a href="{{url "/editor/admin"}}" class="btn-secondary">Back to Admin</a>
</div>
<!-- Filters -->
<form method="GET" action="/editor/admin/logs" class="mb-6 bg-slate-800 border border-slate-700 rounded-lg p-4">
<form method="GET" action="{{url "/editor/admin/logs"}}" class="mb-6 bg-slate-800 border border-slate-700 rounded-lg p-4">
<div class="grid grid-cols-1 md:grid-cols-5 gap-3 items-end">
<div>
<label class="block text-sm text-gray-300 mb-1">IP contains</label>
@@ -38,7 +38,7 @@
</div>
<div class="flex space-x-2">
<button type="submit" class="btn-primary">Apply</button>
<a href="/editor/admin/logs" class="btn-secondary">Reset</a>
<a href="{{url "/editor/admin/logs"}}" class="btn-secondary">Reset</a>
</div>
</div>
</form>
@@ -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);

View File

@@ -5,30 +5,8 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{.app_name}}</title>
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
darkMode: 'class',
theme: {
extend: {
colors: {
'obsidian': {
'50': '#f8fafc',
'100': '#f1f5f9',
'200': '#e2e8f0',
'300': '#cbd5e1',
'400': '#94a3b8',
'500': '#64748b',
'600': '#475569',
'700': '#334155',
'800': '#1e293b',
'900': '#0f172a',
}
}
}
}
}
</script>
<link rel="stylesheet" href="{{url "/static/tailwind.css"}}">
<link rel="stylesheet" href="{{url "/static/styles.css"}}">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github-dark.min.css">
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/languages/go.min.js"></script>
@@ -41,6 +19,8 @@
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/languages/sql.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/languages/bash.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/languages/css.min.js"></script>
<!-- Mermaid for diagrams -->
<script src="https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/js/all.min.js"></script>
<style>
/* Custom scrollbar for dark theme */
@@ -66,6 +46,21 @@
.prose-dark h1, .prose-dark h2, .prose-dark h3, .prose-dark h4, .prose-dark h5, .prose-dark h6 {
color: white;
}
/* Headings sizing and spacing */
.prose-dark h1 { font-size: 1.875rem; line-height: 2.25rem; margin-top: 1.5rem; margin-bottom: 0.75rem; }
.prose-dark h2 { font-size: 1.5rem; line-height: 2rem; margin-top: 1.25rem; margin-bottom: 0.5rem; }
.prose-dark h3 { font-size: 1.25rem; line-height: 1.75rem; margin-top: 1rem; margin-bottom: 0.5rem; }
.prose-dark h4 { font-size: 1.125rem; line-height: 1.5rem; margin-top: 0.75rem; margin-bottom: 0.5rem; }
.prose-dark p { margin: 0.75rem 0; }
.prose-dark hr { border-color: #374151; margin: 1.25rem 0; }
/* Lists */
.prose-dark ul { list-style-type: disc; padding-left: 1.5rem; margin: 0.75rem 0; }
.prose-dark ol { list-style-type: decimal; padding-left: 1.5rem; margin: 0.75rem 0; }
.prose-dark li { margin: 0.25rem 0; }
.prose-dark li > ul { list-style-type: circle; }
.prose-dark li > ol { list-style-type: lower-alpha; }
.prose-dark a {
color: #60a5fa;
}
@@ -81,7 +76,9 @@
.prose-dark pre {
background-color: #111827;
border: 1px solid #374151;
overflow: auto;
}
.prose-dark pre code { background: transparent; color: inherit; padding: 0; }
.prose-dark blockquote {
border-left: 4px solid #3b82f6;
background-color: #1f2937;
@@ -255,6 +252,20 @@
transform: rotate(90deg);
}
</style>
<script>
// Initialize Mermaid (dark theme) and run on page load
document.addEventListener('DOMContentLoaded', function() {
if (window.mermaid) {
try {
window.mermaid.initialize({ startOnLoad: false, theme: 'dark' });
const containers = document.querySelectorAll('.mermaid');
if (containers.length) {
window.mermaid.run({ querySelector: '.mermaid' });
}
} catch (e) {}
}
});
</script>
</head>
<body class="bg-slate-900 text-gray-300 min-h-screen">
<div class="flex h-screen">
@@ -264,24 +275,31 @@
<!-- Header -->
<div class="p-4 border-b border-gray-700">
<div class="flex items-center justify-between">
<a href="/" class="sidebar-title text-xl font-bold text-white hover:text-blue-300" title="Home">
{{.app_name}}
<a href="{{url "/"}}" class="sidebar-title text-xl font-bold text-white hover:text-blue-300" title="Home" aria-label="Home">
<i class="fas fa-house"></i>
<span class="sr-only">{{.app_name}}</span>
</a>
<div class="flex items-center space-x-2 items-center">
<div class="sidebar-actions flex items-center space-x-3">
<button id="open-search" class="text-gray-400 hover:text-white transition-colors" title="Search" aria-label="Search">
<i class="fas fa-magnifying-glass"></i>
</button>
<button id="expand-all" class="text-gray-400 hover:text-white transition-colors" title="Expand all" aria-label="Expand all">
<i class="fas fa-folder-open"></i>
</button>
<button id="collapse-all" class="text-gray-400 hover:text-white transition-colors" title="Collapse all" aria-label="Collapse all">
<i class="fas fa-folder"></i>
</button>
{{if .Authenticated}}
{{if .IsAdmin}}
<a href="/editor/admin" class="text-gray-400 hover:text-white transition-colors" title="Admin">
<a href="{{url "/editor/admin"}}" class="text-gray-400 hover:text-white transition-colors" title="Admin">
<i class="fas fa-user-shield"></i>
</a>
{{end}}
<a href="/editor/profile" class="text-gray-400 hover:text-white transition-colors" title="Profile">
<a href="{{url "/editor/profile"}}" class="text-gray-400 hover:text-white transition-colors" title="Profile">
<i class="fas fa-user"></i>
</a>
<a href="/editor/settings" class="text-gray-400 hover:text-white transition-colors" title="Settings">
<a href="{{url "/editor/settings"}}" class="text-gray-400 hover:text-white transition-colors" title="Settings">
<i class="fas fa-gear"></i>
</a>
{{end}}
@@ -290,7 +308,7 @@
<i class="fas fa-right-from-bracket"></i>
</button>
{{else}}
<a href="/editor/login" class="text-gray-400 hover:text-white transition-colors" title="Login">
<a href="{{url "/editor/login"}}" class="text-gray-400 hover:text-white transition-colors" title="Login">
<i class="fas fa-right-to-bracket"></i>
</a>
{{end}}
@@ -305,7 +323,7 @@
<!-- Navigation -->
<div class="sidebar-content px-4 py-4">
{{if .Authenticated}}
<a href="/editor/create" class="btn-primary text-sm w-full text-center">
<a href="{{url "/editor/create"}}" class="btn-primary text-sm w-full text-center">
<i class="fas fa-plus mr-2"></i>New Note
</a>
{{end}}
@@ -328,13 +346,18 @@
<!-- Breadcrumbs -->
{{if .breadcrumbs}}
<div class="bg-slate-800 border-b border-gray-700 px-6 py-3">
<nav class="flex items-center space-x-2 text-sm">
<nav class="flex items-center flex-wrap gap-1.5 text-sm">
{{range $i, $crumb := .breadcrumbs}}
{{if $i}}<i class="fas fa-chevron-right text-gray-500 text-xs"></i>{{end}}
{{if $i}}<i class="fas fa-chevron-right text-gray-500 text-xs mx-1"></i>{{end}}
{{if $crumb.URL}}
<a href="{{$crumb.URL}}" class="text-blue-400 hover:text-blue-300 transition-colors">{{$crumb.Name}}</a>
<a href="{{url $crumb.URL}}" class="inline-flex items-center px-2.5 py-1 rounded-md border border-slate-600 bg-slate-700/40 text-blue-300 hover:bg-slate-700 hover:text-blue-200 transition-colors" aria-label="Breadcrumb: {{$crumb.Name}}">
{{if and (eq $i 0) (eq $crumb.Name "/")}}<i class="fas fa-folder-tree mr-1.5"></i>{{end}}
<span class="leading-none">{{$crumb.Name}}</span>
</a>
{{else}}
<span class="text-gray-300">{{$crumb.Name}}</span>
<span class="inline-flex items-center px-2.5 py-1 rounded-md border border-slate-600 bg-slate-700/60 text-gray-200">
<span class="leading-none">{{$crumb.Name}}</span>
</span>
{{end}}
{{end}}
</nav>
@@ -399,20 +422,74 @@
<!-- Scripts -->
<script>
// Initialize syntax highlighting
hljs.highlightAll();
// Avoid warnings and double-highlighting: skip elements already highlighted by Chroma
if (window.hljs) {
try {
// Suppress unescaped HTML warnings from hljs
window.hljs.configure({ ignoreUnescapedHTML: true });
// Only highlight code blocks that are not inside a .chroma container
document.querySelectorAll('pre code').forEach(function (el) {
if (!el.closest('.chroma')) {
window.hljs.highlightElement(el);
}
});
} catch (e) { /* ignore */ }
}
// Tree functionality
// Base URL prefix from server
window.BASE = '{{base}}';
window.prefix = function(p) {
var b = window.BASE || '';
if (!b) {
if (!p) return '';
return p[0] === '/' ? p : '/' + p;
}
if (!p || p === '/') return b + '/';
return p[0] === '/' ? (b + p) : (b + '/' + p);
};
// Tree functionality with persisted expanded state
const LS_KEY_EXPANDED = 'tree:expanded';
function getExpandedSet() {
try {
const raw = localStorage.getItem(LS_KEY_EXPANDED);
const arr = raw ? JSON.parse(raw) : [];
return new Set(Array.isArray(arr) ? arr : []);
} catch { return new Set(); }
}
function saveExpandedSet(set) {
try { localStorage.setItem(LS_KEY_EXPANDED, JSON.stringify(Array.from(set))); } catch {}
}
function setExpanded(toggleEl, expand) {
const children = toggleEl.nextElementSibling;
const chevron = toggleEl.querySelector('.tree-chevron');
if (!children || !children.classList.contains('tree-children')) return;
const isHidden = children.classList.contains('hidden');
if (expand && isHidden) children.classList.remove('hidden');
if (!expand && !isHidden) children.classList.add('hidden');
if (chevron) chevron.classList.toggle('rotate-90', expand);
}
function applyExpandedState() {
const expanded = getExpandedSet();
document.querySelectorAll('.tree-toggle').forEach(t => {
const path = t.getAttribute('data-path') || '';
const shouldExpand = expanded.has(path);
setExpanded(t, shouldExpand);
});
}
document.addEventListener('click', function(e) {
if (e.target.closest('.tree-toggle')) {
const toggle = e.target.closest('.tree-toggle');
const children = toggle.nextElementSibling;
const chevron = toggle.querySelector('.tree-chevron');
if (children && children.classList.contains('tree-children')) {
const expanded = getExpandedSet();
const path = toggle.getAttribute('data-path') || '';
const willExpand = children.classList.contains('hidden');
children.classList.toggle('hidden');
if (chevron) {
chevron.classList.toggle('rotate-90');
}
if (chevron) chevron.classList.toggle('rotate-90');
if (willExpand) expanded.add(path); else expanded.delete(path);
saveExpandedSet(expanded);
}
}
});
@@ -423,7 +500,7 @@
if (!toggle) return;
e.preventDefault();
const path = toggle.getAttribute('data-path') || '';
const url = '/folder/' + path;
const url = window.prefix('/folder/' + path);
window.location.href = url;
});
@@ -441,6 +518,11 @@
if (chevron) {
chevron.classList.add('rotate-90');
}
// persist this expanded state
const expanded = getExpandedSet();
const path = toggle.getAttribute('data-path') || '';
expanded.add(path);
saveExpandedSet(expanded);
}
}
parent = parent.parentElement;
@@ -511,9 +593,28 @@
});
}
// Expand active path in tree
// Apply persisted expanded folders, then ensure active path is expanded
applyExpandedState();
expandActivePath();
// Wire expand/collapse all
const expandAllBtn = document.getElementById('expand-all');
const collapseAllBtn = document.getElementById('collapse-all');
if (expandAllBtn) expandAllBtn.addEventListener('click', function() {
const expanded = getExpandedSet();
document.querySelectorAll('.tree-toggle').forEach(t => {
const path = t.getAttribute('data-path') || '';
setExpanded(t, true);
expanded.add(path);
});
saveExpandedSet(expanded);
});
if (collapseAllBtn) collapseAllBtn.addEventListener('click', function() {
const expanded = new Set();
document.querySelectorAll('.tree-toggle').forEach(t => setExpanded(t, false));
saveExpandedSet(expanded);
});
// Sidebar tree fallback: if server didn't render any tree nodes (e.g., error pages), fetch and render via API
(function ensureSidebarTree() {
const container = document.getElementById('sidebar-tree');
@@ -522,7 +623,7 @@
if (hasTree) return; // already populated
// Fetch tree
fetch('/api/tree')
fetch(window.prefix('/api/tree'))
.then(r => r.json())
.then(data => {
if (!data || !Array.isArray(data.children)) return;
@@ -532,6 +633,7 @@
container.appendChild(renderTreeNode(child));
});
// Re-apply expanded state and active path if any
applyExpandedState();
expandActivePath();
})
.catch(() => {/* ignore */});
@@ -554,13 +656,13 @@
wrapper.appendChild(toggle);
wrapper.appendChild(children);
} else {
let href = '/view_text/' + (node.path || '');
let href = window.prefix('/view_text/' + (node.path || ''));
let icon = '📄';
if ((node.type || '').toLowerCase() === 'md') {
href = '/note/' + (node.path || '');
href = window.prefix('/note/' + (node.path || ''));
icon = '📝';
} else if ((node.type || '').toLowerCase() === 'image') {
href = '/serve_attached_image/' + (node.path || '');
href = window.prefix('/serve_attached_image/' + (node.path || ''));
icon = '🖼️';
}
const a = document.createElement('a');
@@ -636,7 +738,7 @@
title.className = 'flex items-center justify-between text-sm text-blue-300 hover:text-blue-200 cursor-pointer';
title.innerHTML = `<span><i class="fas ${r.type === 'md' ? 'fa-file-lines' : 'fa-file'} mr-2"></i>${escapeHTML(r.path)}</span>`;
title.addEventListener('click', () => {
const url = r.path.endsWith('.md') ? `/note/${r.path}` : `/view_text/${r.path}`;
const url = r.path.endsWith('.md') ? window.prefix(`/note/${r.path}`) : window.prefix(`/view_text/${r.path}`);
// remember query
if (searchInput) localStorage.setItem(LS_KEY_QUERY, searchInput.value.trim());
window.location.href = url;
@@ -661,7 +763,7 @@
}
try {
searchStatus.textContent = 'Searching...';
const res = await fetch(`/api/search?q=${encodeURIComponent(query)}`);
const res = await fetch(window.prefix(`/api/search?q=${encodeURIComponent(query)}`));
const data = await res.json();
if (res.ok) {
localStorage.setItem(LS_KEY_QUERY, query);
@@ -696,12 +798,12 @@
try {
const m = document.cookie.match(/(?:^|; )csrf_token=([^;]+)/);
const csrf = m && m[1] ? decodeURIComponent(m[1]) : '';
const res = await fetch('/editor/logout', {
const res = await fetch(window.prefix('/editor/logout'), {
method: 'POST',
headers: csrf ? { 'X-CSRF-Token': csrf } : {},
});
if (res.ok) {
window.location.href = '/editor/login';
window.location.href = window.prefix('/editor/login');
} else {
const data = await res.json().catch(() => ({}));
showNotification('Logout failed: ' + (data.error || res.statusText), 'error');
@@ -762,17 +864,17 @@
</div>
{{else}}
{{if eq .node.Type "md"}}
<a href="/note/{{.node.Path}}" class="sidebar-item {{if eq .current_note .node.Path}}active{{end}}">
<a href="{{url (print "/note/" .node.Path)}}" class="sidebar-item {{if eq .current_note .node.Path}}active{{end}}">
<span class="mr-2">📝</span>
<span>{{.node.Name}}</span>
</a>
{{else if eq .node.Type "image"}}
<a href="/serve_attached_image/{{.node.Path}}" target="_blank" class="sidebar-item" title="View image in new tab">
<a href="{{url (print "/serve_attached_image/" .node.Path)}}" target="_blank" class="sidebar-item" title="View image in new tab">
<span class="mr-2">🖼️</span>
<span>{{.node.Name}}</span>
</a>
{{else}}
<a href="/view_text/{{.node.Path}}" class="sidebar-item">
<a href="{{url (print "/view_text/" .node.Path)}}" class="sidebar-item">
<span class="mr-2">📄</span>
<span>{{.node.Name}}</span>
</a>

View File

@@ -77,6 +77,69 @@ console.log('Hello, World!');
</div>
</div>
<!-- Editor Toolbar -->
<div class="flex items-center justify-between border-t border-gray-700 pt-4">
<div class="flex items-center flex-wrap gap-2">
<button type="button" class="btn-secondary text-sm" onclick="insertMarkdown('**', '**')" title="Bold">
<i class="fas fa-bold"></i>
</button>
<button type="button" class="btn-secondary text-sm" onclick="insertMarkdown('*', '*')" title="Italic">
<i class="fas fa-italic"></i>
</button>
<button type="button" class="btn-secondary text-sm" onclick="insertMarkdown('~~', '~~')" title="Strikethrough">
<i class="fas fa-strikethrough"></i>
</button>
<button type="button" class="btn-secondary text-sm" onclick="insertMarkdown('`', '`')" title="Inline code">
<i class="fas fa-code"></i>
</button>
<button type="button" class="btn-secondary text-sm" onclick="insertCodeBlock()" title="Code block">
<i class="fas fa-file-code"></i>
</button>
<button type="button" class="btn-secondary text-sm" onclick="insertMermaid()" title="Mermaid diagram">
<i class="fas fa-project-diagram"></i>
</button>
<button type="button" class="btn-secondary text-sm" onclick="insertMarkdown('[', '](url)')" title="Link">
<i class="fas fa-link"></i>
</button>
<button type="button" class="btn-secondary text-sm" onclick="insertMarkdown('![', '](image.png)')" title="Image">
<i class="fas fa-image"></i>
</button>
<!-- Headings dropdown -->
<div class="relative">
<button type="button" class="btn-secondary text-sm" onclick="toggleHeadingMenu(event)" title="Headings">
<i class="fas fa-heading"></i>
</button>
<div id="heading-menu" class="absolute z-10 hidden bg-slate-800 border border-gray-700 rounded shadow-lg right-0 bottom-full mb-2">
<button type="button" class="block w-full text-left px-3 py-2 hover:bg-slate-700" onclick="insertHeadingLevel(1)">H1</button>
<button type="button" class="block w-full text-left px-3 py-2 hover:bg-slate-700" onclick="insertHeadingLevel(2)">H2</button>
<button type="button" class="block w-full text-left px-3 py-2 hover:bg-slate-700" onclick="insertHeadingLevel(3)">H3</button>
<button type="button" class="block w-full text-left px-3 py-2 hover:bg-slate-700" onclick="insertHeadingLevel(4)">H4</button>
<button type="button" class="block w-full text-left px-3 py-2 hover:bg-slate-700" onclick="insertHeadingLevel(5)">H5</button>
<button type="button" class="block w-full text-left px-3 py-2 hover:bg-slate-700" onclick="insertHeadingLevel(6)">H6</button>
</div>
</div>
<button type="button" class="btn-secondary text-sm" onclick="insertList()" title="Bulleted list">
<i class="fas fa-list"></i>
</button>
<button type="button" class="btn-secondary text-sm" onclick="insertNumberedList()" title="Numbered list">
<i class="fas fa-list-ol"></i>
</button>
<button type="button" class="btn-secondary text-sm" onclick="insertChecklist()" title="Task list">
<i class="fas fa-square-check"></i>
</button>
<button type="button" class="btn-secondary text-sm" onclick="insertBlockquote()" title="Blockquote">
<i class="fas fa-quote-left"></i>
</button>
<button type="button" class="btn-secondary text-sm" onclick="insertHr()" title="Horizontal rule">
<i class="fas fa-minus"></i>
</button>
<button type="button" class="btn-secondary text-sm" onclick="insertTable()" title="Table">
<i class="fas fa-table"></i>
</button>
</div>
<div class="text-xs text-gray-500">Press Ctrl+S to create</div>
</div>
<!-- Actions -->
</div>
@@ -111,7 +174,7 @@ console.log('Hello, World!');
function buildImageURL(filename) {
if (imageStorageMode === 2) {
return `/serve_stored_image/${filename}`;
return window.prefix(`/serve_stored_image/${filename}`);
}
let path = filename;
if (imageStorageMode === 3 && currentFolderPath) {
@@ -119,7 +182,7 @@ console.log('Hello, World!');
} else if (imageStorageMode === 4 && currentFolderPath) {
path = `${currentFolderPath}/${imageSubfolderName}/${filename}`;
}
return `/serve_attached_image/${path}`;
return window.prefix(`/serve_attached_image/${path}`);
}
function transformObsidianEmbeds(md) {
@@ -145,16 +208,38 @@ console.log('Hello, World!');
}
}
function transformMermaidFences(md) {
return (md || '').replace(/```mermaid\n([\s\S]*?)```/g, function(_, inner){
return `<div class=\"mermaid\">${inner}</div>`;
});
}
function transformMediaEmbeds(md) {
const lines = (md || '').split(/\n/);
return lines.map(l => {
const t = l.trim();
if (/^https?:\/\/\S+\.(?:mp3|wav|ogg)$/.test(t)) return `<audio controls preload=\"metadata\" src=\"${t}\"></audio>`;
if (/^https?:\/\/\S+\.(?:mp4|webm|ogg)$/.test(t)) return `<video controls preload=\"metadata\" style=\"max-width:100%\" src=\"${t}\"></video>`;
if (/^https?:\/\/\S+\.(?:pdf)$/.test(t)) return `<iframe src=\"${t}\" style=\"width:100%;height:70vh;border:1px solid #374151;border-radius:8px\"></iframe>`;
return l;
}).join('\n');
}
// Render preview
function renderPreview() {
if (!previewEnabled) return;
if (!livePreview || !window.marked) return;
const transformed = transformObsidianEmbeds(contentTextarea.value || '');
let transformed = transformObsidianEmbeds(contentTextarea.value || '');
transformed = transformMermaidFences(transformed);
transformed = transformMediaEmbeds(transformed);
livePreview.innerHTML = marked.parse(transformed);
// Highlight code blocks
livePreview.querySelectorAll('pre code').forEach(block => {
try { hljs.highlightElement(block); } catch (e) {}
});
if (window.mermaid) {
try { window.mermaid.run({ querySelector: '#live-preview .mermaid' }); } catch (e) {}
}
}
createForm.addEventListener('submit', function(e) {
@@ -177,7 +262,7 @@ console.log('Hello, World!');
// CSRF token from cookie
const csrf = (document.cookie.match(/(?:^|; )csrf_token=([^;]+)/)||[])[1] ? decodeURIComponent((document.cookie.match(/(?:^|; )csrf_token=([^;]+)/)||[])[1]) : '';
fetch('/editor/create', {
fetch(window.prefix('/editor/create'), {
method: 'POST',
headers: csrf ? { 'X-CSRF-Token': csrf } : {},
body: formData
@@ -238,6 +323,154 @@ console.log('Hello, World!');
}
});
// Markdown insertion functions (parity with edit.html)
function insertMarkdown(before, after) {
const start = contentTextarea.selectionStart;
const end = contentTextarea.selectionEnd;
const text = contentTextarea.value;
const selectedText = text.substring(start, end);
const newText = text.substring(0, start) + before + selectedText + after + text.substring(end);
contentTextarea.value = newText;
const newPos = start + before.length + selectedText.length;
contentTextarea.focus();
contentTextarea.setSelectionRange(newPos, newPos);
renderPreview();
}
function toggleHeadingMenu(e) {
const menu = document.getElementById('heading-menu');
if (!menu) return;
const wasHidden = menu.classList.contains('hidden');
menu.classList.remove('hidden');
menu.classList.remove('top-full','mt-2','bottom-full','mb-2');
const btn = e.currentTarget;
const rect = btn.getBoundingClientRect();
const spaceBelow = window.innerHeight - rect.bottom;
const spaceAbove = rect.top;
const needed = Math.max(menu.offsetHeight || 192, 192) + 12;
if (spaceBelow < needed && spaceAbove >= needed) {
menu.classList.add('bottom-full','mb-2','right-0');
} else {
menu.classList.add('top-full','mt-2','right-0');
}
if (wasHidden) {
const close = (ev) => {
if (!menu.contains(ev.target) && ev.target !== btn) {
menu.classList.add('hidden');
document.removeEventListener('click', close);
}
};
setTimeout(() => document.addEventListener('click', close), 0);
}
}
function insertHeadingLevel(level) {
const start = contentTextarea.selectionStart;
const text = contentTextarea.value;
const lineStart = text.lastIndexOf('\n', start - 1) + 1;
const hashes = '#'.repeat(Math.min(Math.max(level,1),6)) + ' ';
const newText = text.substring(0, lineStart) + hashes + text.substring(lineStart);
contentTextarea.value = newText;
const newPos = start + hashes.length;
contentTextarea.focus();
contentTextarea.setSelectionRange(newPos, newPos);
const menu = document.getElementById('heading-menu');
if (menu) menu.classList.add('hidden');
renderPreview();
}
function insertList() {
const start = contentTextarea.selectionStart;
const text = contentTextarea.value;
const lineStart = text.lastIndexOf('\n', start - 1) + 1;
const newText = text.substring(0, lineStart) + '- ' + text.substring(lineStart);
contentTextarea.value = newText;
contentTextarea.focus();
contentTextarea.setSelectionRange(start + 2, start + 2);
renderPreview();
}
function insertNumberedList() {
const start = contentTextarea.selectionStart;
const text = contentTextarea.value;
const lineStart = text.lastIndexOf('\n', start - 1) + 1;
const newText = text.substring(0, lineStart) + '1. ' + text.substring(lineStart);
contentTextarea.value = newText;
contentTextarea.focus();
contentTextarea.setSelectionRange(start + 3, start + 3);
renderPreview();
}
function insertChecklist() {
const start = contentTextarea.selectionStart;
const text = contentTextarea.value;
const lineStart = text.lastIndexOf('\n', start - 1) + 1;
const newText = text.substring(0, lineStart) + '- [ ] ' + text.substring(lineStart);
contentTextarea.value = newText;
contentTextarea.focus();
contentTextarea.setSelectionRange(start + 6, start + 6);
renderPreview();
}
function insertBlockquote() {
const start = contentTextarea.selectionStart;
const text = contentTextarea.value;
const lineStart = text.lastIndexOf('\n', start - 1) + 1;
const newText = text.substring(0, lineStart) + '> ' + text.substring(lineStart);
contentTextarea.value = newText;
contentTextarea.focus();
contentTextarea.setSelectionRange(start + 2, start + 2);
renderPreview();
}
function insertHr() {
const start = contentTextarea.selectionStart;
const text = contentTextarea.value;
const insert = '\n\n---\n\n';
const newText = text.substring(0, start) + insert + text.substring(start);
contentTextarea.value = newText;
const pos = start + insert.length;
contentTextarea.focus();
contentTextarea.setSelectionRange(pos, pos);
renderPreview();
}
function insertCodeBlock() {
const start = contentTextarea.selectionStart;
const text = contentTextarea.value;
const insert = '\n```language\n// code\n```\n';
const newText = text.substring(0, start) + insert + text.substring(start);
contentTextarea.value = newText;
const pos = start + 4;
contentTextarea.focus();
contentTextarea.setSelectionRange(pos, pos);
renderPreview();
}
function insertMermaid() {
const start = contentTextarea.selectionStart;
const text = contentTextarea.value;
const tpl = '\n```mermaid\nflowchart TD\n A[Start] --> B{Decision}\n B -- Yes --> C[Do thing]\n B -- No --> D[Something else]\n```\n';
const newText = text.substring(0, start) + tpl + text.substring(start);
contentTextarea.value = newText;
const pos = start + 4;
contentTextarea.focus();
contentTextarea.setSelectionRange(pos, pos);
renderPreview();
}
function insertTable() {
const start = contentTextarea.selectionStart;
const text = contentTextarea.value;
const tpl = '\n| Col 1 | Col 2 | Col 3 |\n|-------|-------|-------|\n| A1 | B1 | C1 |\n| A2 | B2 | C2 |\n';
const newText = text.substring(0, start) + tpl + text.substring(start);
contentTextarea.value = newText;
const pos = start + 3;
contentTextarea.focus();
contentTextarea.setSelectionRange(pos, pos);
renderPreview();
}
// Paste-to-upload image
contentTextarea.addEventListener('paste', async function(e) {
const items = (e.clipboardData || window.clipboardData).items || [];
@@ -263,7 +496,12 @@ console.log('Hello, World!');
formData.append('path', uploadPath);
try {
const resp = await fetch('/upload', { method: 'POST', body: formData });
const csrf = (document.cookie.match(/(?:^|; )csrf_token=([^;]+)/)||[])[1] ? decodeURIComponent((document.cookie.match(/(?:^|; )csrf_token=([^;]+)/)||[])[1]) : '';
const resp = await fetch(window.prefix('/editor/upload'), {
method: 'POST',
headers: csrf ? { 'X-CSRF-Token': csrf } : {},
body: formData
});
const data = await resp.json();
if (!resp.ok || !data.success) throw new Error(data.error || 'Upload failed');

View File

@@ -21,7 +21,7 @@
{{if .folder_path}}
<p class="text-gray-400">
<i class="fas fa-folder mr-2"></i>
<a href="/folder/{{.folder_path}}" class="text-blue-400 hover:text-blue-300">{{.folder_path}}</a>
<a href="{{url "/folder/"}}{{.folder_path}}" class="text-blue-400 hover:text-blue-300">{{.folder_path}}</a>
</p>
{{end}}
</div>
@@ -43,28 +43,63 @@
<!-- Editor Toolbar -->
<div class="flex items-center justify-between border-t border-gray-700 pt-4">
<div class="flex items-center space-x-2">
<div class="flex items-center flex-wrap gap-2">
<button type="button" class="btn-secondary text-sm" onclick="insertMarkdown('**', '**')" title="Bold">
<i class="fas fa-bold"></i>
</button>
<button type="button" class="btn-secondary text-sm" onclick="insertMarkdown('*', '*')" title="Italic">
<i class="fas fa-italic"></i>
</button>
<button type="button" class="btn-secondary text-sm" onclick="insertMarkdown('`', '`')" title="Code">
<button type="button" class="btn-secondary text-sm" onclick="insertMarkdown('~~', '~~')" title="Strikethrough">
<i class="fas fa-strikethrough"></i>
</button>
<button type="button" class="btn-secondary text-sm" onclick="insertMarkdown('`', '`')" title="Inline code">
<i class="fas fa-code"></i>
</button>
<button type="button" class="btn-secondary text-sm" onclick="insertCodeBlock()" title="Code block">
<i class="fas fa-file-code"></i>
</button>
<button type="button" class="btn-secondary text-sm" onclick="insertMermaid()" title="Mermaid diagram">
<i class="fas fa-project-diagram"></i>
</button>
<button type="button" class="btn-secondary text-sm" onclick="insertMarkdown('[', '](url)')" title="Link">
<i class="fas fa-link"></i>
</button>
<button type="button" class="btn-secondary text-sm" onclick="insertMarkdown('![', '](image.png)')" title="Image">
<i class="fas fa-image"></i>
</button>
<button type="button" class="btn-secondary text-sm" onclick="insertHeading()" title="Heading">
<i class="fas fa-heading"></i>
</button>
<button type="button" class="btn-secondary text-sm" onclick="insertList()" title="List">
<!-- Headings dropdown -->
<div class="relative">
<button type="button" class="btn-secondary text-sm" onclick="toggleHeadingMenu(event)" title="Headings">
<i class="fas fa-heading"></i>
</button>
<div id="heading-menu" class="absolute z-10 hidden bg-slate-800 border border-gray-700 rounded shadow-lg right-0 bottom-full mb-2">
<button type="button" class="block w-full text-left px-3 py-2 hover:bg-slate-700" onclick="insertHeadingLevel(1)">H1</button>
<button type="button" class="block w-full text-left px-3 py-2 hover:bg-slate-700" onclick="insertHeadingLevel(2)">H2</button>
<button type="button" class="block w-full text-left px-3 py-2 hover:bg-slate-700" onclick="insertHeadingLevel(3)">H3</button>
<button type="button" class="block w-full text-left px-3 py-2 hover:bg-slate-700" onclick="insertHeadingLevel(4)">H4</button>
<button type="button" class="block w-full text-left px-3 py-2 hover:bg-slate-700" onclick="insertHeadingLevel(5)">H5</button>
<button type="button" class="block w-full text-left px-3 py-2 hover:bg-slate-700" onclick="insertHeadingLevel(6)">H6</button>
</div>
</div>
<button type="button" class="btn-secondary text-sm" onclick="insertList()" title="Bulleted list">
<i class="fas fa-list"></i>
</button>
<button type="button" class="btn-secondary text-sm" onclick="insertNumberedList()" title="Numbered list">
<i class="fas fa-list-ol"></i>
</button>
<button type="button" class="btn-secondary text-sm" onclick="insertChecklist()" title="Task list">
<i class="fas fa-square-check"></i>
</button>
<button type="button" class="btn-secondary text-sm" onclick="insertBlockquote()" title="Blockquote">
<i class="fas fa-quote-left"></i>
</button>
<button type="button" class="btn-secondary text-sm" onclick="insertHr()" title="Horizontal rule">
<i class="fas fa-minus"></i>
</button>
<button type="button" class="btn-secondary text-sm" onclick="insertTable()" title="Table">
<i class="fas fa-table"></i>
</button>
</div>
<div class="text-xs text-gray-500">
@@ -104,7 +139,7 @@
function buildImageURL(filename) {
// Map Obsidian embed to server URL
if (imageStorageMode === 2) {
return `/serve_stored_image/${filename}`;
return window.prefix(`/serve_stored_image/${filename}`);
}
let path = filename;
if (imageStorageMode === 3 && currentFolderPath) {
@@ -112,7 +147,7 @@
} else if (imageStorageMode === 4 && currentFolderPath) {
path = `${currentFolderPath}/${imageSubfolderName}/${filename}`;
}
return `/serve_attached_image/${path}`;
return window.prefix(`/serve_attached_image/${path}`);
}
function transformObsidianEmbeds(md) {
@@ -139,14 +174,36 @@
}
}
function transformMermaidFences(md) {
return (md || '').replace(/```mermaid\n([\s\S]*?)```/g, function(_, inner){
return `<div class=\"mermaid\">${inner}</div>`;
});
}
function transformMediaEmbeds(md) {
const lines = (md || '').split(/\n/);
return lines.map(l => {
const t = l.trim();
if (/^https?:\/\/\S+\.(?:mp3|wav|ogg)$/.test(t)) return `<audio controls preload=\"metadata\" src=\"${t}\"></audio>`;
if (/^https?:\/\/\S+\.(?:mp4|webm|ogg)$/.test(t)) return `<video controls preload=\"metadata\" style=\"max-width:100%\" src=\"${t}\"></video>`;
if (/^https?:\/\/\S+\.(?:pdf)$/.test(t)) return `<iframe src=\"${t}\" style=\"width:100%;height:70vh;border:1px solid #374151;border-radius:8px\"></iframe>`;
return l;
}).join('\n');
}
function renderPreview() {
if (!previewEnabled) return;
if (!livePreview || !window.marked) return;
const transformed = transformObsidianEmbeds(contentTextarea.value || '');
let transformed = transformObsidianEmbeds(contentTextarea.value || '');
transformed = transformMermaidFences(transformed);
transformed = transformMediaEmbeds(transformed);
livePreview.innerHTML = marked.parse(transformed);
livePreview.querySelectorAll('pre code').forEach(block => {
try { hljs.highlightElement(block); } catch (e) {}
});
if (window.mermaid) {
try { window.mermaid.run({ querySelector: '#live-preview .mermaid' }); } catch (e) {}
}
}
editForm.addEventListener('submit', function(e) {
@@ -161,7 +218,7 @@
// CSRF token from cookie
const csrf = (document.cookie.match(/(?:^|; )csrf_token=([^;]+)/)||[])[1] ? decodeURIComponent((document.cookie.match(/(?:^|; )csrf_token=([^;]+)/)||[])[1]) : '';
fetch('/editor/edit/' + notePath, {
fetch(window.prefix('/editor/edit/' + notePath), {
method: 'POST',
headers: csrf ? { 'X-CSRF-Token': csrf } : {},
body: formData
@@ -247,22 +304,52 @@
autoResize();
}
function insertHeading() {
function toggleHeadingMenu(e) {
const menu = document.getElementById('heading-menu');
if (!menu) return;
// Show to measure, then decide direction
const wasHidden = menu.classList.contains('hidden');
menu.classList.remove('hidden');
// Reset placement classes
menu.classList.remove('top-full','mt-2','bottom-full','mb-2');
// Determine available space
const btn = e.currentTarget;
const rect = btn.getBoundingClientRect();
const spaceBelow = window.innerHeight - rect.bottom;
const spaceAbove = rect.top;
const needed = Math.max(menu.offsetHeight || 192, 192) + 12; // fallback height
if (spaceBelow < needed && spaceAbove >= needed) {
// Open upwards
menu.classList.add('bottom-full','mb-2','right-0');
} else {
// Open downwards
menu.classList.add('top-full','mt-2','right-0');
}
if (wasHidden) {
const close = (ev) => {
if (!menu.contains(ev.target) && ev.target !== btn) {
menu.classList.add('hidden');
document.removeEventListener('click', close);
}
};
setTimeout(() => document.addEventListener('click', close), 0);
}
}
function insertHeadingLevel(level) {
const start = contentTextarea.selectionStart;
const text = contentTextarea.value;
const lineStart = text.lastIndexOf('\n', start - 1) + 1;
const lineText = text.substring(lineStart, start);
if (lineText.startsWith('# ')) {
return; // Already a heading
}
const newText = text.substring(0, lineStart) + '# ' + text.substring(lineStart);
const hashes = '#'.repeat(Math.min(Math.max(level,1),6)) + ' ';
const newText = text.substring(0, lineStart) + hashes + text.substring(lineStart);
contentTextarea.value = newText;
const newPos = start + hashes.length;
contentTextarea.focus();
contentTextarea.setSelectionRange(start + 2, start + 2);
contentTextarea.setSelectionRange(newPos, newPos);
autoResize();
const menu = document.getElementById('heading-menu');
if (menu) menu.classList.add('hidden');
}
function insertList() {
@@ -278,6 +365,88 @@
autoResize();
}
function insertNumberedList() {
const start = contentTextarea.selectionStart;
const text = contentTextarea.value;
const lineStart = text.lastIndexOf('\n', start - 1) + 1;
const newText = text.substring(0, lineStart) + '1. ' + text.substring(lineStart);
contentTextarea.value = newText;
contentTextarea.focus();
contentTextarea.setSelectionRange(start + 3, start + 3);
autoResize();
}
function insertChecklist() {
const start = contentTextarea.selectionStart;
const text = contentTextarea.value;
const lineStart = text.lastIndexOf('\n', start - 1) + 1;
const newText = text.substring(0, lineStart) + '- [ ] ' + text.substring(lineStart);
contentTextarea.value = newText;
contentTextarea.focus();
contentTextarea.setSelectionRange(start + 6, start + 6);
autoResize();
}
function insertBlockquote() {
const start = contentTextarea.selectionStart;
const text = contentTextarea.value;
const lineStart = text.lastIndexOf('\n', start - 1) + 1;
const newText = text.substring(0, lineStart) + '> ' + text.substring(lineStart);
contentTextarea.value = newText;
contentTextarea.focus();
contentTextarea.setSelectionRange(start + 2, start + 2);
autoResize();
}
function insertHr() {
const start = contentTextarea.selectionStart;
const text = contentTextarea.value;
const insert = '\n\n---\n\n';
const newText = text.substring(0, start) + insert + text.substring(start);
contentTextarea.value = newText;
const pos = start + insert.length;
contentTextarea.focus();
contentTextarea.setSelectionRange(pos, pos);
autoResize();
}
function insertCodeBlock() {
const start = contentTextarea.selectionStart;
const text = contentTextarea.value;
const insert = '\n```language\n// code\n```\n';
const newText = text.substring(0, start) + insert + text.substring(start);
contentTextarea.value = newText;
const pos = start + 4; // place cursor after first backticks
contentTextarea.focus();
contentTextarea.setSelectionRange(pos, pos);
autoResize();
}
function insertMermaid() {
const start = contentTextarea.selectionStart;
const text = contentTextarea.value;
const tpl = '\n```mermaid\nflowchart TD\n A[Start] --> B{Decision}\n B -- Yes --> C[Do thing]\n B -- No --> D[Something else]\n```\n';
const newText = text.substring(0, start) + tpl + text.substring(start);
contentTextarea.value = newText;
const pos = start + 4; // cursor after opening backticks
contentTextarea.focus();
contentTextarea.setSelectionRange(pos, pos);
autoResize();
renderPreview();
}
function insertTable() {
const start = contentTextarea.selectionStart;
const text = contentTextarea.value;
const tpl = '\n| Col 1 | Col 2 | Col 3 |\n|-------|-------|-------|\n| A1 | B1 | C1 |\n| A2 | B2 | C2 |\n';
const newText = text.substring(0, start) + tpl + text.substring(start);
contentTextarea.value = newText;
const pos = start + 3; // inside first cell
contentTextarea.focus();
contentTextarea.setSelectionRange(pos, pos);
autoResize();
}
// Paste-to-upload image
contentTextarea.addEventListener('paste', async function(e) {
const items = (e.clipboardData || window.clipboardData).items || [];
@@ -300,7 +469,12 @@
formData.append('path', uploadPath);
try {
const resp = await fetch('/upload', { method: 'POST', body: formData });
const csrf = (document.cookie.match(/(?:^|; )csrf_token=([^;]+)/)||[])[1] ? decodeURIComponent((document.cookie.match(/(?:^|; )csrf_token=([^;]+)/)||[])[1]) : '';
const resp = await fetch(window.prefix('/editor/upload'), {
method: 'POST',
headers: csrf ? { 'X-CSRF-Token': csrf } : {},
body: formData
});
const data = await resp.json();
if (!resp.ok || !data.success) throw new Error(data.error || 'Upload failed');

View File

@@ -24,7 +24,7 @@
{{if .folder_path}}
<p class="text-gray-400">
<i class="fas fa-folder mr-2"></i>
<a href="/folder/{{.folder_path}}" class="text-blue-400 hover:text-blue-300">{{.folder_path}}</a>
<a href="{{url "/folder/"}}{{.folder_path}}" class="text-blue-400 hover:text-blue-300">{{.folder_path}}</a>
</p>
{{end}}
</div>
@@ -171,7 +171,7 @@
formData.append('content', cm ? cm.getValue() : contentEl.value);
const m = document.cookie.match(/(?:^|; )csrf_token=([^;]+)/);
const csrf = m && m[1] ? decodeURIComponent(m[1]) : '';
fetch('/editor/edit_text/' + filePath, { method: 'POST', headers: csrf ? { 'X-CSRF-Token': csrf } : {}, body: formData })
fetch(window.prefix('/editor/edit_text/' + filePath), { method: 'POST', headers: csrf ? { 'X-CSRF-Token': csrf } : {}, body: formData })
.then(r => r.json())
.then(data => {
if (data.success) {

View File

@@ -21,7 +21,7 @@
{{end}}
<div class="space-y-3">
<a href="/" class="block btn-primary">
<a href="{{url "/"}}" class="block btn-primary">
<i class="fas fa-home mr-2"></i>Go Home
</a>
<button onclick="history.back()" class="block btn-secondary w-full">

View File

@@ -22,14 +22,16 @@
{{end}}
</p>
</div>
{{if .Authenticated}}
<div class="flex items-center space-x-3">
<button id="upload-btn" class="btn-primary">
<i class="fas fa-upload mr-2"></i>Upload File
</button>
<a href="/editor/create?folder={{.folder_path}}" class="btn-secondary">
<a href="{{url (print "/editor/create?folder=" .folder_path)}}" class="btn-secondary">
<i class="fas fa-plus mr-2"></i>New Note
</a>
</div>
{{end}}
</div>
<!-- Upload Area (hidden by default) -->
@@ -69,29 +71,33 @@
</div>
</div>
<div class="flex items-center space-x-2">
{{if eq .Type "md"}}
<a href="/editor/edit/{{.Path}}" class="text-blue-400 hover:text-blue-300 p-2" title="Edit">
<i class="fas fa-edit"></i>
</a>
{{end}}
{{if eq .Type "text"}}
<a href="/editor/edit_text/{{.Path}}" class="text-blue-400 hover:text-blue-300 p-2" title="Edit">
<i class="fas fa-edit"></i>
</a>
{{if $.Authenticated}}
{{if eq .Type "md"}}
<a href="{{url (print "/editor/edit/" .Path)}}" class="text-blue-400 hover:text-blue-300 p-2" title="Edit">
<i class="fas fa-edit"></i>
</a>
{{end}}
{{if eq .Type "text"}}
<a href="{{url (print "/editor/edit_text/" .Path)}}" class="text-blue-400 hover:text-blue-300 p-2" title="Edit">
<i class="fas fa-edit"></i>
</a>
{{end}}
{{end}}
{{if eq .Type "image"}}
<a href="/serve_attached_image/{{.Path}}" target="_blank" class="text-yellow-400 hover:text-yellow-300 p-2" title="View">
<a href="{{url (print "/serve_attached_image/" .Path)}}" target="_blank" class="text-yellow-400 hover:text-yellow-300 p-2" title="View">
<i class="fas fa-eye"></i>
</a>
{{end}}
{{if ne .Type "dir"}}
<a href="/download/{{.Path}}" class="text-green-400 hover:text-green-300 p-2" title="Download">
<a href="{{url (print "/download/" .Path)}}" class="text-green-400 hover:text-green-300 p-2" title="Download">
<i class="fas fa-download"></i>
</a>
{{end}}
<button class="text-red-400 hover:text-red-300 p-2 delete-btn" data-path="{{.Path}}" title="Delete">
<i class="fas fa-trash"></i>
</button>
{{if $.Authenticated}}
<button class="text-red-400 hover:text-red-300 p-2 delete-btn" data-path="{{.Path}}" title="Delete">
<i class="fas fa-trash"></i>
</button>
{{end}}
</div>
</div>
</div>
@@ -132,39 +138,47 @@
let deleteTarget = null;
// Toggle upload area
uploadBtn.addEventListener('click', function() {
uploadArea.classList.toggle('hidden');
});
if (uploadBtn) {
uploadBtn.addEventListener('click', function() {
uploadArea && uploadArea.classList.toggle('hidden');
});
}
// File selection
selectFilesBtn.addEventListener('click', function() {
fileInput.click();
});
if (selectFilesBtn && fileInput) {
selectFilesBtn.addEventListener('click', function() {
fileInput.click();
});
}
fileInput.addEventListener('change', function() {
if (this.files.length > 0) {
uploadFiles(this.files);
}
});
if (fileInput) {
fileInput.addEventListener('change', function() {
if (this.files.length > 0) {
uploadFiles(this.files);
}
});
}
// Drag and drop
uploadArea.addEventListener('dragover', function(e) {
e.preventDefault();
this.classList.add('dragover');
});
if (uploadArea) {
uploadArea.addEventListener('dragover', function(e) {
e.preventDefault();
this.classList.add('dragover');
});
uploadArea.addEventListener('dragleave', function(e) {
e.preventDefault();
this.classList.remove('dragover');
});
uploadArea.addEventListener('dragleave', function(e) {
e.preventDefault();
this.classList.remove('dragover');
});
uploadArea.addEventListener('drop', function(e) {
e.preventDefault();
this.classList.remove('dragover');
if (e.dataTransfer.files.length > 0) {
uploadFiles(e.dataTransfer.files);
}
});
uploadArea.addEventListener('drop', function(e) {
e.preventDefault();
this.classList.remove('dragover');
if (e.dataTransfer.files.length > 0) {
uploadFiles(e.dataTransfer.files);
}
});
}
// Upload files function
function uploadFiles(files) {
@@ -181,7 +195,7 @@
const m = document.cookie.match(/(?:^|; )csrf_token=([^;]+)/);
const csrf = m && m[1] ? decodeURIComponent(m[1]) : '';
fetch('/editor/upload', {
fetch(window.prefix('/editor/upload'), {
method: 'POST',
headers: csrf ? { 'X-CSRF-Token': csrf } : {},
body: formData
@@ -213,13 +227,13 @@
const type = itemCard.dataset.type;
if (type === 'dir') {
window.location.href = '/folder/' + path;
window.location.href = window.prefix('/folder/' + path);
} else if (type === 'md') {
window.location.href = '/note/' + path;
window.location.href = window.prefix('/note/' + path);
} else if (type === 'image') {
window.open('/serve_attached_image/' + path, '_blank');
window.open(window.prefix('/serve_attached_image/' + path), '_blank');
} else {
window.location.href = '/view_text/' + path;
window.location.href = window.prefix('/view_text/' + path);
}
}
});
@@ -242,7 +256,7 @@
if (deleteTarget) {
const m = document.cookie.match(/(?:^|; )csrf_token=([^;]+)/);
const csrf = m && m[1] ? decodeURIComponent(m[1]) : '';
fetch('/editor/delete/' + deleteTarget, {
fetch(window.prefix('/editor/delete/' + deleteTarget), {
method: 'DELETE',
headers: csrf ? { 'X-CSRF-Token': csrf } : {}
})

View File

@@ -8,8 +8,11 @@
{{if .error}}
<div class="bg-red-900/50 border border-red-700 text-red-200 rounded p-3 mb-4">{{.error}}</div>
{{end}}
<form method="POST" action="/editor/login" class="space-y-4">
<form method="POST" action="{{url "/editor/login"}}" class="space-y-4">
<input type="hidden" name="csrf_token" value="{{.csrf_token}}" />
{{if .return_to}}
<input type="hidden" name="return_to" value="{{.return_to}}" />
{{end}}
<div>
<label class="block text-sm text-gray-300 mb-1" for="username">Username or Email</label>
<input id="username" name="username" type="text" required class="w-full bg-gray-700 border border-gray-600 rounded px-3 py-2 text-white" />

View File

@@ -11,7 +11,7 @@
<div class="mb-4 p-3 rounded bg-red-700 text-white">{{.error}}</div>
{{end}}
<form id="mfa-form" class="space-y-4" method="POST" action="/editor/mfa">
<form id="mfa-form" class="space-y-4" method="POST" action="{{url "/editor/mfa"}}">
<input type="hidden" name="csrf_token" value="{{.csrf_token}}" />
<div>
<label for="code" class="block text-sm font-medium text-gray-300 mb-2">Authentication code</label>

View File

@@ -45,11 +45,11 @@
const fd = new FormData(form);
const params = new URLSearchParams(Array.from(fd.entries()));
try {
const res = await fetch('/editor/profile/mfa/verify', { method: 'POST', headers: { 'X-CSRF-Token': getCSRF() }, body: params });
const res = await fetch(window.prefix('/editor/profile/mfa/verify'), { method: 'POST', headers: { 'X-CSRF-Token': getCSRF() }, body: params });
const data = await res.json().catch(() => ({}));
if (res.ok && data.success) {
showNotification('MFA enabled', 'success');
window.location.href = '/editor/profile';
window.location.href = window.prefix('/editor/profile');
} else {
throw new Error(data.error || res.statusText);
}

View File

@@ -10,11 +10,11 @@
<h1 class="text-3xl font-bold text-white">{{.title}}</h1>
<div class="flex items-center space-x-3">
{{if .Authenticated}}
<a href="/editor/edit/{{.note_path}}" class="btn-primary">
<a href="{{url (print "/editor/edit/" .note_path)}}" class="btn-primary">
<i class="fas fa-edit mr-2"></i>Edit
</a>
{{end}}
<a href="/download/{{.note_path}}" class="btn-secondary">
<a href="{{url (print "/download/" .note_path)}}" class="btn-secondary">
<i class="fas fa-download mr-2"></i>Download
</a>
{{if .Authenticated}}
@@ -28,7 +28,7 @@
{{if .folder_path}}
<p class="text-gray-400">
<i class="fas fa-folder mr-2"></i>
<a href="/folder/{{.folder_path}}" class="text-blue-400 hover:text-blue-300">{{.folder_path}}</a>
<a href="{{url (print "/folder/" .folder_path)}}" class="text-blue-400 hover:text-blue-300">{{.folder_path}}</a>
</p>
{{end}}
</div>
@@ -79,13 +79,13 @@ document.addEventListener('DOMContentLoaded', function() {
const path = deleteBtn.dataset.path;
const m = document.cookie.match(/(?:^|; )csrf_token=([^;]+)/);
const csrf = m && m[1] ? decodeURIComponent(m[1]) : '';
fetch(`/editor/delete/${path}`, {
fetch(window.prefix(`/editor/delete/${path}`), {
method: 'DELETE',
headers: csrf ? { 'X-CSRF-Token': csrf } : {}
})
.then(response => {
if (response.ok) {
window.location.href = '/';
window.location.href = window.prefix('/');
} else {
alert('Error deleting note');
}

View File

@@ -96,7 +96,7 @@
if (emailForm) emailForm.addEventListener('submit', async (e) => {
e.preventDefault();
try {
const res = await fetch('/editor/profile/email', {
const res = await fetch(window.prefix('/editor/profile/email'), {
method: 'POST',
headers: Object.assign({'X-CSRF-Token': getCSRF()}),
body: formToJSON(emailForm)
@@ -121,7 +121,7 @@
return;
}
try {
const res = await fetch('/editor/profile/password', {
const res = await fetch(window.prefix('/editor/profile/password'), {
method: 'POST',
headers: Object.assign({'X-CSRF-Token': getCSRF()}),
body: formToJSON(passwordForm)
@@ -141,7 +141,7 @@
async function toggleMFA(enable) {
try {
const url = enable ? '/editor/profile/mfa/enable' : '/editor/profile/mfa/disable';
const res = await fetch(url, { method: 'POST', headers: {'X-CSRF-Token': getCSRF()} });
const res = await fetch(window.prefix(url), { method: 'POST', headers: {'X-CSRF-Token': getCSRF()} });
const data = await res.json().catch(() => ({}));
if (res.ok && data.success) {
if (enable) {

View File

@@ -12,22 +12,6 @@
<!-- Settings Sections -->
<div class="space-y-8">
<!-- Quick Actions -->
<div class="bg-gray-800 rounded-lg p-6">
<div class="flex items-center justify-between">
<div>
<h2 class="text-xl font-semibold text-white mb-1">
<i class="fas fa-tools mr-2"></i>Admin Tools
</h2>
<p class="text-gray-400">Access logs and security controls</p>
</div>
<div class="flex items-center gap-3">
<a href="/editor/admin/logs" target="_blank" class="btn-secondary inline-flex items-center">
<i class="fas fa-list mr-2"></i>View Logs
</a>
</div>
</div>
</div>
<!-- Image Storage Settings -->
<div class="bg-gray-800 rounded-lg p-6">
<h2 class="text-xl font-semibold text-white mb-4">
@@ -247,7 +231,7 @@
// Load current settings
function loadSettings() {
// Load image storage settings
fetch('/editor/settings/image_storage')
fetch(window.prefix('/editor/settings/image_storage'))
.then(response => response.json())
.then(data => {
document.querySelector(`input[name="storage_mode"][value="${data.mode}"]`).checked = true;
@@ -258,7 +242,7 @@
.catch(error => console.error('Error loading image storage settings:', error));
// Load notes directory settings
fetch('/editor/settings/notes_dir')
fetch(window.prefix('/editor/settings/notes_dir'))
.then(response => response.json())
.then(data => {
document.getElementById('notes_dir').value = data.notes_dir || '';
@@ -266,7 +250,7 @@
.catch(error => console.error('Error loading notes directory settings:', error));
// Load file extensions settings
fetch('/editor/settings/file_extensions')
fetch(window.prefix('/editor/settings/file_extensions'))
.then(response => response.json())
.then(data => {
document.getElementById('allowed_image_extensions').value = data.allowed_image_extensions || '';
@@ -283,7 +267,7 @@
.catch(error => console.error('Error loading file extensions settings:', error));
// Load security settings
fetch('/editor/settings/security')
fetch(window.prefix('/editor/settings/security'))
.then(response => response.json())
.then(data => {
document.getElementById('pwd_failures_threshold').value = data.pwd_failures_threshold ?? '';
@@ -322,7 +306,7 @@
const csrf = (document.cookie.match(/(?:^|; )csrf_token=([^;]+)/)||[])[1] ? decodeURIComponent((document.cookie.match(/(?:^|; )csrf_token=([^;]+)/)||[])[1]) : '';
fetch('/editor/settings/security', {
fetch(window.prefix('/editor/settings/security'), {
method: 'POST',
headers: csrf ? { 'X-CSRF-Token': csrf } : {},
body: formData
@@ -348,7 +332,7 @@
const csrf = (document.cookie.match(/(?:^|; )csrf_token=([^;]+)/)||[])[1] ? decodeURIComponent((document.cookie.match(/(?:^|; )csrf_token=([^;]+)/)||[])[1]) : '';
fetch('/editor/settings/image_storage', {
fetch(window.prefix('/editor/settings/image_storage'), {
method: 'POST',
headers: csrf ? { 'X-CSRF-Token': csrf } : {},
body: formData
@@ -377,7 +361,7 @@
const csrf = (document.cookie.match(/(?:^|; )csrf_token=([^;]+)/)||[])[1] ? decodeURIComponent((document.cookie.match(/(?:^|; )csrf_token=([^;]+)/)||[])[1]) : '';
fetch('/editor/settings/notes_dir', {
fetch(window.prefix('/editor/settings/notes_dir'), {
method: 'POST',
headers: csrf ? { 'X-CSRF-Token': csrf } : {},
body: formData
@@ -406,7 +390,7 @@
const csrf = (document.cookie.match(/(?:^|; )csrf_token=([^;]+)/)||[])[1] ? decodeURIComponent((document.cookie.match(/(?:^|; )csrf_token=([^;]+)/)||[])[1]) : '';
fetch('/editor/settings/file_extensions', {
fetch(window.prefix('/editor/settings/file_extensions'), {
method: 'POST',
headers: csrf ? { 'X-CSRF-Token': csrf } : {},
body: formData

View File

@@ -10,11 +10,11 @@
<h1 class="text-3xl font-bold text-white">{{.file_name}}</h1>
<div class="flex items-center space-x-3">
{{if and .Authenticated .is_editable}}
<a href="/edit_text/{{.file_path}}" class="btn-primary">
<a href="{{url (print "/editor/edit_text/" .file_path)}}" class="btn-primary">
<i class="fas fa-edit mr-2"></i>Edit
</a>
{{end}}
<a href="/download/{{.file_path}}" class="btn-secondary">
<a href="{{url (print "/download/" .file_path)}}" class="btn-secondary">
<i class="fas fa-download mr-2"></i>Download
</a>
{{if .Authenticated}}
@@ -28,7 +28,7 @@
{{if .folder_path}}
<p class="text-gray-400">
<i class="fas fa-folder mr-2"></i>
<a href="/folder/{{.folder_path}}" class="text-blue-400 hover:text-blue-300">{{.folder_path}}</a>
<a href="{{url (print "/folder/" .folder_path)}}" class="text-blue-400 hover:text-blue-300">{{.folder_path}}</a>
</p>
{{end}}
</div>
@@ -72,8 +72,11 @@
document.getElementById('confirm-delete').addEventListener('click', function() {
if (deleteTarget) {
fetch('/delete/' + deleteTarget, {
method: 'DELETE'
const m = document.cookie.match(/(?:^|; )csrf_token=([^;]+)/);
const csrf = m && m[1] ? decodeURIComponent(m[1]) : '';
fetch(window.prefix('/editor/delete/' + deleteTarget), {
method: 'DELETE',
headers: csrf ? { 'X-CSRF-Token': csrf } : {}
})
.then(response => response.json())
.then(data => {
@@ -82,9 +85,9 @@
// Redirect to folder or root
const folderPath = '{{.folder_path}}';
if (folderPath) {
window.location.href = '/folder/' + folderPath;
window.location.href = window.prefix('/folder/' + folderPath);
} else {
window.location.href = '/';
window.location.href = window.prefix('/');
}
} else {
throw new Error(data.error || 'Delete failed');