215 lines
5.4 KiB
Go
215 lines
5.4 KiB
Go
package main
|
|
|
|
import (
|
|
"embed"
|
|
"flag"
|
|
"html/template"
|
|
"io/fs"
|
|
"log"
|
|
"net/http"
|
|
"os"
|
|
"path"
|
|
"path/filepath"
|
|
"strings"
|
|
)
|
|
|
|
//go:embed assets/*
|
|
var assets embed.FS
|
|
|
|
var tmpl = template.Must(template.New("page.html").ParseFS(assets, "assets/page.html"))
|
|
|
|
// specialPage is the result returned by a pageTypeHandler.
|
|
// Content is injected into the page after the standard markdown content.
|
|
// SuppressListing hides the default file/folder listing.
|
|
type specialPage struct {
|
|
Content template.HTML
|
|
SuppressListing bool
|
|
}
|
|
|
|
// pageTypeHandler is implemented by each special folder type (diary, gallery, …).
|
|
// handle returns nil when the handler does not apply to the given path.
|
|
type pageTypeHandler interface {
|
|
handle(root, fsPath, urlPath string) *specialPage
|
|
}
|
|
|
|
// pageTypeHandlers is the registry. Each type registers itself via init().
|
|
var pageTypeHandlers []pageTypeHandler
|
|
|
|
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 {
|
|
if os.IsNotExist(err) && strings.HasSuffix(r.URL.Path, "/") {
|
|
h.serveDir(w, r, urlPath, fsPath)
|
|
return
|
|
}
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
|
|
if info.IsDir() {
|
|
if urlPath != "/" {
|
|
urlPath += "/"
|
|
}
|
|
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 {
|
|
rendered = renderMarkdown(rawMD)
|
|
}
|
|
|
|
var special *specialPage
|
|
if !editMode {
|
|
for _, ph := range pageTypeHandlers {
|
|
if special = ph.handle(h.root, fsPath, urlPath); special != nil {
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
var entries []entry
|
|
if !editMode && (special == nil || !special.SuppressListing) {
|
|
entries = listEntries(fsPath, urlPath)
|
|
}
|
|
|
|
title := pageTitle(urlPath)
|
|
if heading := extractFirstHeading(rawMD); heading != "" {
|
|
title = heading
|
|
}
|
|
|
|
var specialContent template.HTML
|
|
if special != nil {
|
|
specialContent = special.Content
|
|
}
|
|
|
|
data := pageData{
|
|
Title: title,
|
|
Crumbs: buildCrumbs(urlPath),
|
|
CanEdit: true,
|
|
EditMode: editMode,
|
|
PostURL: urlPath,
|
|
RawContent: string(rawMD),
|
|
Content: rendered,
|
|
Entries: entries,
|
|
SpecialContent: specialContent,
|
|
}
|
|
|
|
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 strings.TrimSpace(content) == "" {
|
|
if err := os.Remove(indexPath); err != nil && !os.IsNotExist(err) {
|
|
http.Error(w, "delete failed: "+err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
} else {
|
|
if err := os.MkdirAll(fsPath, 0755); err != nil {
|
|
http.Error(w, "mkdir failed: "+err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
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)
|
|
}
|
|
|
|
// readPageSettings parses a .page-settings file in dir.
|
|
// Returns nil if the file does not exist.
|
|
// Format: one "key = value" pair per line; lines starting with # are comments.
|
|
func readPageSettings(dir string) *pageSettings {
|
|
data, err := os.ReadFile(filepath.Join(dir, ".page-settings"))
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
s := &pageSettings{}
|
|
for _, line := range strings.Split(string(data), "\n") {
|
|
line = strings.TrimSpace(line)
|
|
if line == "" || strings.HasPrefix(line, "#") {
|
|
continue
|
|
}
|
|
parts := strings.SplitN(line, "=", 2)
|
|
if len(parts) != 2 {
|
|
continue
|
|
}
|
|
switch strings.TrimSpace(parts[0]) {
|
|
case "type":
|
|
s.Type = strings.TrimSpace(parts[1])
|
|
}
|
|
}
|
|
return s
|
|
}
|