commit 12063ba8068e186719ddf48b64337d0a9859d46c Author: luxick Date: Thu Apr 9 21:51:12 2026 +0200 Initial version diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4c5f206 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.claude/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..6f0d8ef --- /dev/null +++ b/README.md @@ -0,0 +1,30 @@ +# datascape + +Minimal self-hosted personal wiki. Folders are pages. + +## Run + +```bash +go run . -dir ./wiki -addr :8080 +go run . -dir ./wiki -addr :8080 -user me -pass secret +``` + +## Build + +```bash +# local +go build -o datascape . + +# QNAP NAS (linux/arm64) +GOOS=linux GOARCH=arm64 go build -o datascape . +``` + +## Usage + +| Action | How | +|--------|-----| +| Browse | Navigate folders at `/` | +| Read | Any folder with `index.md` renders it as HTML | +| Edit | Append `?edit` to any folder URL, or click **Edit** | +| Save | POST from the edit form writes `index.md` to disk | +| Files | Drop PDFs, images, etc. next to `index.md` โ€” they appear in the listing | diff --git a/assets/style.css b/assets/style.css new file mode 100644 index 0000000..c29601b --- /dev/null +++ b/assets/style.css @@ -0,0 +1,182 @@ +:root { + --bg: #1e1e2e; + --surface: #313244; + --border: #45475a; + --text: #cdd6f4; + --muted: #6c7086; + --accent: #89b4fa; + --accent-dim: #1d2a3f; + --green: #a6e3a1; + --red: #f38ba8; + --font-mono: 'JetBrains Mono', 'Fira Code', monospace; + --font-sans: system-ui, -apple-system, sans-serif; +} + +* { box-sizing: border-box; margin: 0; padding: 0; } + +body { + background: var(--bg); + color: var(--text); + font-family: var(--font-sans); + font-size: 16px; + line-height: 1.6; + min-height: 100vh; +} + +a { color: var(--accent); text-decoration: none; } +a:hover { text-decoration: underline; } + +header { + background: var(--surface); + border-bottom: 1px solid var(--border); + padding: 0.75rem 1rem; + display: flex; + align-items: center; + gap: 0.5rem; + flex-wrap: wrap; +} + +header a { color: var(--text); font-weight: 600; } +header .sep { color: var(--muted); } + +.breadcrumb { display: flex; align-items: center; gap: 0.25rem; flex-wrap: wrap; flex: 1; } +.breadcrumb a { color: var(--accent); } + +.edit-btn { + background: var(--accent-dim); + border: 1px solid var(--accent); + color: var(--accent); + padding: 0.25rem 0.75rem; + border-radius: 4px; + font-size: 0.875rem; + cursor: pointer; + text-decoration: none; + white-space: nowrap; +} +.edit-btn:hover { background: var(--accent); color: var(--bg); text-decoration: none; } + +main { max-width: 860px; margin: 0 auto; padding: 1.5rem 1rem; } + +.content { margin-bottom: 2rem; } + +/* Markdown rendered content */ +.content h1, .content h2, .content h3, +.content h4, .content h5, .content h6 { + margin: 1.25rem 0 0.5rem; + line-height: 1.3; +} +.content h1 { font-size: 1.75rem; border-bottom: 1px solid var(--border); padding-bottom: 0.25rem; } +.content h2 { font-size: 1.4rem; } +.content h3 { font-size: 1.15rem; } +.content p { margin: 0.75rem 0; } +.content ul, .content ol { margin: 0.75rem 0 0.75rem 1.5rem; } +.content li { margin: 0.25rem 0; } +.content blockquote { + border-left: 3px solid var(--accent); + padding: 0.25rem 1rem; + color: var(--muted); + margin: 0.75rem 0; +} +.content code { + font-family: var(--font-mono); + font-size: 0.875em; + background: var(--surface); + padding: 0.1em 0.35em; + border-radius: 3px; +} +.content pre { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 6px; + padding: 1rem; + overflow-x: auto; + margin: 0.75rem 0; +} +.content pre code { background: none; padding: 0; } +.content table { + width: 100%; + border-collapse: collapse; + margin: 0.75rem 0; + font-size: 0.9rem; +} +.content th, .content td { + border: 1px solid var(--border); + padding: 0.4rem 0.75rem; + text-align: left; +} +.content th { background: var(--surface); } +.content hr { border: none; border-top: 1px solid var(--border); margin: 1.5rem 0; } +.content img { max-width: 100%; border-radius: 4px; } + +/* File/folder listing */ +.listing { border: 1px solid var(--border); border-radius: 6px; overflow: hidden; } +.listing-header { + background: var(--surface); + padding: 0.5rem 1rem; + font-size: 0.8rem; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--muted); +} +.listing-item { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.6rem 1rem; + border-top: 1px solid var(--border); + font-size: 0.95rem; +} +.listing-item:hover { background: var(--surface); } +.listing-item .icon { font-size: 1rem; width: 1.25rem; text-align: center; flex-shrink: 0; } +.listing-item a { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.listing-item .meta { color: var(--muted); font-size: 0.8rem; white-space: nowrap; } + +/* Edit form */ +.edit-form { display: flex; flex-direction: column; gap: 1rem; } +.edit-form textarea { + width: 100%; + min-height: 60vh; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 6px; + color: var(--text); + font-family: var(--font-mono); + font-size: 0.9rem; + line-height: 1.6; + padding: 1rem; + resize: vertical; + outline: none; +} +.edit-form textarea:focus { border-color: var(--accent); } +.form-actions { display: flex; gap: 0.75rem; } +.btn-save { + background: var(--accent); + color: var(--bg); + border: none; + padding: 0.6rem 1.5rem; + border-radius: 4px; + font-size: 1rem; + font-weight: 600; + cursor: pointer; +} +.btn-save:hover { opacity: 0.9; } +.btn-cancel { + background: transparent; + border: 1px solid var(--border); + color: var(--muted); + padding: 0.6rem 1.25rem; + border-radius: 4px; + font-size: 1rem; + cursor: pointer; + text-decoration: none; + display: inline-block; +} +.btn-cancel:hover { border-color: var(--text); color: var(--text); text-decoration: none; } + +.empty { color: var(--muted); font-style: italic; padding: 1rem; text-align: center; } + +@media (max-width: 600px) { + header { padding: 0.5rem 0.75rem; } + main { padding: 1rem 0.75rem; } + .edit-form textarea { min-height: 50vh; } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..7cd26e8 --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module datascape + +go 1.26.1 + +require github.com/yuin/goldmark v1.8.2 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..6a37955 --- /dev/null +++ b/go.sum @@ -0,0 +1,2 @@ +github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE= +github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= diff --git a/main.go b/main.go new file mode 100644 index 0000000..c30ec6e --- /dev/null +++ b/main.go @@ -0,0 +1,309 @@ +package main + +import ( + "bytes" + "embed" + "flag" + "fmt" + "html/template" + "io/fs" + "log" + "net/http" + "os" + "path" + "path/filepath" + "sort" + "strings" + + "github.com/yuin/goldmark" + "github.com/yuin/goldmark/extension" + "github.com/yuin/goldmark/parser" + "github.com/yuin/goldmark/renderer/html" +) + +//go:embed assets/* +var assets embed.FS + +var md = goldmark.New( + goldmark.WithExtensions(extension.GFM, extension.Table), + goldmark.WithParserOptions(parser.WithAutoHeadingID()), + goldmark.WithRendererOptions(html.WithUnsafe()), +) + +var tmpl = template.Must(template.New("").Parse(` + + + + +{{.Title}} โ€” datascape + + + +
+ + {{if .CanEdit}}Edit{{end}} +
+
+{{if .EditMode}} +
+ +
+ + Cancel +
+
+{{else}} +{{if .Content}}
{{.Content}}
{{end}} +{{if .Entries}} +
+
Contents
+ {{range .Entries}} +
+ {{.Icon}} + {{.Name}} + {{.Meta}} +
+ {{end}} +
+{{else if not .Content}}

Empty folder โ€” create index.md

{{end}} +{{end}} +
+ +`)) + +type crumb struct{ Name, URL string } +type entry struct{ Icon, Name, URL, Meta string } + +type pageData struct { + Title string + Crumbs []crumb + CanEdit bool + EditMode bool + PostURL string + RawContent string + Content template.HTML + Entries []entry +} + +func main() { + addr := flag.String("addr", ":8080", "listen address") + wikiDir := flag.String("dir", "./wiki", "wiki root directory") + user := flag.String("user", "", "basic auth username (empty = no auth)") + pass := flag.String("pass", "", "basic auth password") + flag.Parse() + + root, err := filepath.Abs(*wikiDir) + if err != nil { + log.Fatal(err) + } + if err := os.MkdirAll(root, 0755); err != nil { + log.Fatal(err) + } + + h := &handler{root: root, user: *user, pass: *pass} + + staticFS, _ := fs.Sub(assets, "assets") + http.Handle("/_/", http.StripPrefix("/_/", http.FileServer(http.FS(staticFS)))) + http.Handle("/", h) + + log.Printf("datascape listening on %s, wiki at %s", *addr, root) + log.Fatal(http.ListenAndServe(*addr, nil)) +} + +type handler struct { + root, user, pass string +} + +func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + if h.user != "" { + u, p, ok := r.BasicAuth() + if !ok || u != h.user || p != h.pass { + w.Header().Set("WWW-Authenticate", `Basic realm="datascape"`) + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + } + + urlPath := path.Clean("/" + r.URL.Path) + fsPath := filepath.Join(h.root, filepath.FromSlash(urlPath)) + + // Security: ensure the resolved path stays within root. + rel, err := filepath.Rel(h.root, fsPath) + if err != nil || strings.HasPrefix(rel, "..") { + http.Error(w, "Forbidden", http.StatusForbidden) + return + } + + info, err := os.Stat(fsPath) + if err != nil { + http.NotFound(w, r) + return + } + + if info.IsDir() { + h.serveDir(w, r, urlPath, fsPath) + } else { + http.ServeFile(w, r, fsPath) + } +} + +func (h *handler) serveDir(w http.ResponseWriter, r *http.Request, urlPath, fsPath string) { + _, editMode := r.URL.Query()["edit"] + + if r.Method == http.MethodPost { + h.handlePost(w, r, urlPath, fsPath) + return + } + + indexPath := filepath.Join(fsPath, "index.md") + rawMD, _ := os.ReadFile(indexPath) + + var rendered template.HTML + if len(rawMD) > 0 && !editMode { + var buf bytes.Buffer + if err := md.Convert(rawMD, &buf); err == nil { + rendered = template.HTML(buf.String()) + } + } + + var entries []entry + if !editMode { + entries = listEntries(fsPath, urlPath) + } + + postURL := urlPath + if urlPath == "/" { + postURL = "/" + } + + data := pageData{ + Title: pageTitle(urlPath), + Crumbs: buildCrumbs(urlPath), + CanEdit: true, + EditMode: editMode, + PostURL: postURL, + RawContent: string(rawMD), + Content: rendered, + Entries: entries, + } + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if err := tmpl.Execute(w, data); err != nil { + log.Printf("template error: %v", err) + } +} + +func (h *handler) handlePost(w http.ResponseWriter, r *http.Request, urlPath, fsPath string) { + if err := r.ParseForm(); err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + content := r.FormValue("content") + indexPath := filepath.Join(fsPath, "index.md") + if err := os.WriteFile(indexPath, []byte(content), 0644); err != nil { + http.Error(w, "write failed: "+err.Error(), http.StatusInternalServerError) + return + } + http.Redirect(w, r, urlPath, http.StatusSeeOther) +} + +func listEntries(fsPath, urlPath string) []entry { + entries, err := os.ReadDir(fsPath) + if err != nil { + return nil + } + + var folders, files []entry + for _, e := range entries { + name := e.Name() + if strings.HasPrefix(name, ".") { + continue + } + info, err := e.Info() + if err != nil { + continue + } + entryURL := path.Join(urlPath, name) + if e.IsDir() { + folders = append(folders, entry{ + Icon: "๐Ÿ“", + Name: name, + URL: entryURL + "/", + Meta: info.ModTime().Format("2006-01-02"), + }) + } else { + if name == "index.md" { + continue // rendered above, don't list it + } + files = append(files, entry{ + Icon: fileIcon(name), + Name: name, + URL: entryURL, + Meta: formatSize(info.Size()) + " ยท " + info.ModTime().Format("2006-01-02"), + }) + } + } + + sort.Slice(folders, func(i, j int) bool { return folders[i].Name < folders[j].Name }) + sort.Slice(files, func(i, j int) bool { return files[i].Name < files[j].Name }) + + return append(folders, files...) +} + +func fileIcon(name string) string { + ext := strings.ToLower(path.Ext(name)) + switch ext { + case ".md": + return "๐Ÿ“„" + case ".pdf": + return "๐Ÿ“•" + case ".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg": + return "๐Ÿ–ผ" + case ".mp4", ".mkv", ".avi", ".mov": + return "๐ŸŽฌ" + case ".mp3", ".flac", ".ogg", ".wav": + return "๐ŸŽต" + case ".zip", ".tar", ".gz", ".7z": + return "๐Ÿ“ฆ" + default: + return "๐Ÿ“Ž" + } +} + +func formatSize(b int64) string { + switch { + case b < 1024: + return fmt.Sprintf("%d B", b) + case b < 1024*1024: + return fmt.Sprintf("%.1f KB", float64(b)/1024) + default: + return fmt.Sprintf("%.1f MB", float64(b)/1024/1024) + } +} + +func buildCrumbs(urlPath string) []crumb { + if urlPath == "/" { + return nil + } + parts := strings.Split(strings.Trim(urlPath, "/"), "/") + crumbs := make([]crumb, len(parts)) + for i, p := range parts { + crumbs[i] = crumb{ + Name: p, + URL: "/" + strings.Join(parts[:i+1], "/") + "/", + } + } + return crumbs +} + +func pageTitle(urlPath string) string { + if urlPath == "/" { + return "home" + } + parts := strings.Split(strings.Trim(urlPath, "/"), "/") + return parts[len(parts)-1] +} +