12 Commits
v1.17 ... v1.18

Author SHA1 Message Date
Alex Schroeder
4eb013a4da Remove link to list from upload.html 2025-08-10 08:59:19 +02:00
Alex Schroeder
e8f6ae0450 Release 1.18 2025-08-10 08:47:04 +02:00
Alex Schroeder
9bf3beb440 Add the new feature to the release docs 2025-08-09 18:21:20 +02:00
Alex Schroeder
cd6809d791 Add -update and -dry-run options to hashtags command 2025-08-09 18:18:11 +02:00
Alex Schroeder
7c5a3860e7 Fix addLinkToPage
It now tries to keep the links sorted.
2025-08-09 18:17:56 +02:00
Alex Schroeder
a7c343decb Be more restrictive about the request methods 2025-08-08 17:16:41 +02:00
Alex Schroeder
18bb5da8c0 Preview requests using GET redirect to view requests 2025-08-08 00:58:50 +02:00
Alex Schroeder
2a0ea791ec Removed unnecessary option description 2025-08-07 22:12:11 +02:00
Alex Schroeder
726586b39d Updated README 2025-08-07 07:11:54 +02:00
Alex Schroeder
8f30704be9 Improve WebDAV documentation 2025-07-16 16:24:36 +02:00
Alex Schroeder
616ae0a1ba Mention how to sudo make install PREFIX= 2025-07-16 11:20:04 +02:00
Alex Schroeder
af86b865bf Delete list, delete and rename actions
In an effort to remove features that can be handled by the web server, the
list, delete and rename actions were removed again.

The sentence linking to the list action from the upload
template ("upload.html") was also deleted.
2025-07-16 11:03:40 +02:00
44 changed files with 641 additions and 719 deletions

View File

@@ -16,6 +16,8 @@ help:
@echo " just build it"
@echo make install
@echo " install the files to ~/.local"
@echo sudo make install PREFIX=/usr/local
@echo " install the files to /usr/local"
@echo make upload
@echo " this is how I upgrade my server"
@echo make dist

View File

@@ -1,28 +1,35 @@
# Oddμ: A minimal wiki
This program helps you run a minimal wiki, blog, digital garden, memex
or Zettelkasten. There is no version history.
Oddμ (or Oddmu) helps you run a minimal wiki, blog, digital garden,
memex or Zettelkasten.
It's well suited as a self-hosted, single-user web application, when
there is no need for collaboration on the site itself. Links and email
connect you to the rest of the net. The wiki can be public or private.
Perhaps it just runs on your local machine, unreachable from the
Internet.
Oddμ can be run as a static site generator, processing a directory
with Markdown files, turning them into HTML files. HTML templates
allow the customisation of headers, footers and styling. There are no
plugins.
It's well suited as a secondary medium for a close-knit group:
Oddμ is well suited as a self-hosted, single-user web application,
when there is no need for collaboration on the site itself. Links and
email connect you to the rest of the net. The wiki can be public or
private.
If the site is public, use a regular web server as a proxy to make
people log in before making changes. As there is no version history,
it is not possible to undo vandalism and spam. Only grant write-access
to people you trust.
If the site is private, running on a local machine and unreachable
from the Internet, no such precautions are necessary.
Oddμ is well suited as a secondary medium for a close-knit group:
collaboration and conversation happens elsewhere, in chat, on social
media. The wiki serves as the text repository that results from these
discussions. As there are no logins and no version histories, it is
not possible to undo vandalism and spam. Only allow people you trust
write-access to the site.
It's well suited as a simple static site generator. There are no
plugins.
discussions.
When Oddμ runs as a web server, it serves all the Markdown files
(ending in `.md`) as web pages. These pages can be edited via the web.
Oddmu adds the following extensions to Markdown: local links `[[like
Oddμ adds the following extensions to Markdown: local links `[[like
this]]`, hashtags `#Like_This` and fediverse account links like
`@alex@alexschroeder.ch`.
@@ -42,15 +49,15 @@ available:
[oddmu(1)](https://alexschroeder.ch/view/oddmu/oddmu.1): This man page
has a short introduction to Oddmu, its configuration via templates and
environment variables, plus points to the other man pages.
environment variables, plus pointers to the other man pages.
[oddmu(5)](https://alexschroeder.ch/view/oddmu/oddmu.5): This man page
talks about the Markdown and includes some examples for the
non-standard features such as table markup. It also talks about the
Oddmu extensions to Markdown: wiki links, hashtags and fediverse
account links. Local links must use percent encoding for page names so
there is a section about percent encoding. The man page also explains
how feeds are generated.
talks about Markdown and includes some examples for the non-standard
features such as table markup. It also talks about the Oddmu
extensions to Markdown: wiki links, hashtags and fediverse account
links. Local links must use percent encoding for page names so there
is a section about percent encoding. The man page also explains how
feeds are generated.
[oddmu-releases(7)](https://alexschroeder.ch/view/oddmu/oddmu-releases.7):
This man page lists all the Oddmu versions and their user-visible

View File

@@ -2,6 +2,7 @@ package main
import (
"github.com/stretchr/testify/assert"
"net/http"
"net/url"
"os"
"regexp"
@@ -34,15 +35,15 @@ It's not `)}
data.Set("body", "barbecue")
assert.Regexp(t, regexp.MustCompile("a distant fire"),
assert.HTTPBody(makeHandler(viewHandler, false),
assert.HTTPBody(makeHandler(viewHandler, false, http.MethodGet),
"GET", "/view/testdata/add/fire", nil))
assert.NotRegexp(t, regexp.MustCompile("a distant fire"),
assert.HTTPBody(makeHandler(addHandler, true),
assert.HTTPBody(makeHandler(addHandler, true, http.MethodGet),
"GET", "/add/testdata/add/fire", nil))
HTTPRedirectTo(t, makeHandler(appendHandler, true),
HTTPRedirectTo(t, makeHandler(appendHandler, true, http.MethodPost),
"POST", "/append/testdata/add/fire", data, "/view/testdata/add/fire")
assert.Regexp(t, regexp.MustCompile(`not</p>\s*<p>barbecue`),
assert.HTTPBody(makeHandler(viewHandler, false),
assert.HTTPBody(makeHandler(viewHandler, false, http.MethodGet),
"GET", "/view/testdata/add/fire", nil))
}
@@ -57,7 +58,7 @@ Blue and green and pebbles gray
data := url.Values{}
data.Set("body", "Stand in cold water")
data.Add("notify", "on")
HTTPRedirectTo(t, makeHandler(appendHandler, true),
HTTPRedirectTo(t, makeHandler(appendHandler, true, http.MethodPost),
"POST", "/append/testdata/append/"+today+"-water",
data, "/view/testdata/append/"+today+"-water")
// The changes.md file was created

View File

@@ -3,6 +3,7 @@ package main
import (
"archive/zip"
"github.com/stretchr/testify/assert"
"net/http"
"os"
"strings"
"testing"
@@ -15,7 +16,7 @@ func TestArchive(t *testing.T) {
assert.NoError(t, os.WriteFile("testdata/archive/public/index.md", []byte("# Public\nChurch tower bells ringing\nA cold wind biting my ears\nWalk across the square"), 0644))
assert.NoError(t, os.WriteFile("testdata/archive/secret/index.md", []byte("# Secret\nMany years ago I danced\nSpending nights in clubs and bars\nIt is my secret"), 0644))
os.Setenv("ODDMU_FILTER", "^testdata/archive/secret/")
body := assert.HTTPBody(makeHandler(archiveHandler, true), "GET", "/archive/testdata/data.zip", nil)
body := assert.HTTPBody(makeHandler(archiveHandler, true, http.MethodGet), "GET", "/archive/testdata/data.zip", nil)
r, err := zip.NewReader(strings.NewReader(body), int64(len(body)))
assert.NoError(t, err, "Unzip")
names := []string{}

View File

@@ -154,21 +154,56 @@ func addLink(name string, mandatory bool, link string, re *regexp.Regexp) error
}
}
org := string(p.Body)
addLinkToPage(p, link, re)
// only save if something changed
if string(p.Body) != org {
return p.save()
}
return nil
}
func addLinkToPage(p *Page, link string, re *regexp.Regexp) {
// if a link exists, that's the place to insert the new link (in which case loc[0] and loc[1] differ)
loc := re.FindIndex(p.Body)
// if no link exists, find a good place to insert it
if loc == nil {
// locate the beginning of the list to insert the line
re = regexp.MustCompile(`(?m)^\* \[[^\]]+\]\([^\)]+\)\n`)
loc = re.FindIndex(p.Body)
if loc == nil {
// if no list was found, use the end of the page
m := len(p.Body)
loc = []int{m, m}
} else {
// if a list item was found, use just the beginning as insertion point
loc[1] = loc[0]
// locate the list items
re = regexp.MustCompile(`(?m)^\* \[[^\]]+\]\([^\)]+\)\n?`)
items := re.FindAllIndex(p.Body, -1)
first := false
pos := -1
// skip newer items
for i, it := range items {
// break if the current line is older (earlier in sort order)
stop := string(p.Body[it[0]:it[1]]) < link
// before the first match is always a good insert point
if i == 0 {
pos = it[0]
first = true
}
// if we're not stopping, then after the current item is a good insert point
if !stop {
pos = it[1]
first = false
} else {
break
}
}
// otherwise it's at the end of the list, after the last item
if pos == -1 && len(items) > 0 {
pos = items[len(items)-1][1]
first = false
}
// if no list was found, use the end of the page
if pos == -1 {
pos = len(p.Body)
first = true
}
if first {
p.Body, pos = ensureTwoNewlines(p.Body, pos)
}
// mimic a zero-width match at the insert point
loc = []int{pos, pos}
}
// start with new page content
r := []byte("")
@@ -179,9 +214,27 @@ func addLink(name string, mandatory bool, link string, re *regexp.Regexp) error
// append the rest
r = append(r, p.Body[loc[1]:]...)
p.Body = r
// only save if something changed
if string(p.Body) != org {
return p.save()
}
return nil
}
// ensureTwoNewlines makes sure that the two bytes before pos in buf are newlines. If the are not, newlines are inserted
// and pos is increased. The new buf and pos is returned.
func ensureTwoNewlines(buf []byte, pos int) ([]byte, int) {
var insert []byte
if pos >= 1 && buf[pos-1] != '\n' {
// add two newlines if buf doesn't end with a newline
insert = []byte("\n\n")
} else if pos >= 2 && buf[pos-2] != '\n' {
// add one newline if Body ends with just one newline
insert = []byte("\n")
}
if insert != nil {
r := []byte("")
r = append(r, buf[:pos]...)
r = append(r, insert...)
r = append(r, buf[pos:]...)
buf = r
pos += len(insert)
}
return buf, pos
}

View File

@@ -3,12 +3,67 @@ package main
import (
"github.com/stretchr/testify/assert"
"os"
"regexp"
"testing"
"time"
)
// Note TestEditSaveChanges and TestAddAppendChanges.
func TestAddLinkToPageWithNoList(t *testing.T) {
// no newlines
title := "# Test"
p := &Page{Body: []byte(title)}
re := regexp.MustCompile(`(?m)^\* \[[^\]]+\]\(2025-08-08\)\n`)
link := "* [2025-08-08](2025-08-08)\n"
addLinkToPage(p, link, re)
assert.Equal(t, title + "\n\n" + link, string(p.Body))
}
func TestAddLinkToPageWithOlderLink(t *testing.T) {
// one newline
title := "# Test\n"
old := "* [2025-08-08](2025-08-08)\n"
p := &Page{Body: []byte(title + old)}
re := regexp.MustCompile(`(?m)^\* \[[^\]]+\]\(2025-08-10\)\n`)
link := "* [2025-08-10](2025-08-10)\n"
addLinkToPage(p, link, re)
assert.Equal(t, title + "\n" + link + old, string(p.Body))
}
func TestAddLinkToPageBetweenToExistingLinks(t *testing.T) {
title := "# Test\n\n"
new := "* [2025-08-10](2025-08-10)\n"
old := "* [2025-08-08](2025-08-08)\n"
p := &Page{Body: []byte(title + new + old)}
re := regexp.MustCompile(`(?m)^\* \[[^\]]+\]\(2025-08-09\)\n`)
link := "* [2025-08-09](2025-08-09)\n"
addLinkToPage(p, link, re)
assert.Equal(t, title + new + link + old, string(p.Body))
}
func TestAddLinkToPageBetweenToExistingLinks2(t *testing.T) {
title := "# Test\n\n"
new := "* [2025-08-10](2025-08-10)\n* [2025-08-09](2025-08-09)\n"
old := "* [2025-08-07](2025-08-07)\n"
p := &Page{Body: []byte(title + new + old)}
re := regexp.MustCompile(`(?m)^\* \[[^\]]+\]\(2025-08-08\)\n`)
link := "* [2025-08-08](2025-08-08)\n"
addLinkToPage(p, link, re)
assert.Equal(t, title + new + link + old, string(p.Body))
}
func TestAddLinkToPageAtTheEnd(t *testing.T) {
title := "# Test\n\n"
new := "* [2025-08-10](2025-08-10)\n"
old := "* [2025-08-08](2025-08-08)\n"
p := &Page{Body: []byte(title + new + old)}
re := regexp.MustCompile(`(?m)^\* \[[^\]]+\]\(2025-08-07\)\n`)
link := "* [2025-08-07](2025-08-07)\n"
addLinkToPage(p, link, re)
assert.Equal(t, title + new + old + link, string(p.Body))
}
func TestChanges(t *testing.T) {
cleanup(t, "testdata/washing")
today := time.Now().Format(time.DateOnly)
@@ -48,7 +103,8 @@ Home away from home
assert.Contains(t, string(s), line)
s, err = os.ReadFile("testdata/changes/Haiku.md")
assert.NoError(t, err)
assert.Equal(t, intro+line, string(s))
// ensure an empty line when adding at the end of the page
assert.Equal(t, intro+"\n"+line, string(s))
assert.NoFileExists(t, "testdata/changes/Poetry.md")
}

View File

@@ -2,6 +2,7 @@ package main
import (
"github.com/stretchr/testify/assert"
"net/http"
"os"
"testing"
"time"
@@ -24,7 +25,7 @@ Oh so fresh, so warm.`
p.save()
p.Body = []byte(r)
p.save()
body := assert.HTTPBody(makeHandler(diffHandler, true),
body := assert.HTTPBody(makeHandler(diffHandler, true, http.MethodGet),
"GET", "/diff/testdata/diff/bread", nil)
assert.Contains(t, body, `<del>breathe</del>`)
assert.Contains(t, body, `<ins>whisper</ins>`)
@@ -47,7 +48,7 @@ Mispronouncing words`
p.save()
p.Body = []byte(r)
p.save()
body := assert.HTTPBody(makeHandler(diffHandler, true),
body := assert.HTTPBody(makeHandler(diffHandler, true, http.MethodGet),
"GET", "/diff/testdata/diff/coup%20de%20grace", nil)
assert.Contains(t, body, `<del>s</del>`)
assert.Contains(t, body, `<ins>ce</ins>`)

View File

@@ -16,24 +16,24 @@ func TestEditSave(t *testing.T) {
data.Set("body", "Hallo!")
// View of the non-existing page redirects to the edit page
HTTPRedirectTo(t, makeHandler(viewHandler, false),
HTTPRedirectTo(t, makeHandler(viewHandler, false, http.MethodGet),
"GET", "/view/testdata/save/alex", nil, "/edit/testdata/save/alex")
// Edit page can be fetched
assert.HTTPStatusCode(t, makeHandler(editHandler, true),
assert.HTTPStatusCode(t, makeHandler(editHandler, true, http.MethodGet),
"GET", "/edit/testdata/save/alex", nil, 200)
// Posting to the save URL saves a page
HTTPRedirectTo(t, makeHandler(saveHandler, true),
HTTPRedirectTo(t, makeHandler(saveHandler, true, http.MethodPost),
"POST", "/save/testdata/save/alex", data, "/view/testdata/save/alex")
// Page now contains the text
assert.Contains(t, assert.HTTPBody(makeHandler(viewHandler, false),
assert.Contains(t, assert.HTTPBody(makeHandler(viewHandler, false, http.MethodGet),
"GET", "/view/testdata/save/alex", nil),
"Hallo!")
// Delete the page and you're sent to the empty page
data.Set("body", "")
HTTPRedirectTo(t, makeHandler(saveHandler, true),
HTTPRedirectTo(t, makeHandler(saveHandler, true, http.MethodPost),
"POST", "/save/testdata/save/alex", data, "/view/testdata/save/alex")
// Viewing the non-existing page redirects to the edit page (like in the beginning)
HTTPRedirectTo(t, makeHandler(viewHandler, false),
HTTPRedirectTo(t, makeHandler(viewHandler, false, http.MethodGet),
"GET", "/view/testdata/save/alex", nil, "/edit/testdata/save/alex")
}
@@ -44,7 +44,7 @@ func TestEditSaveChanges(t *testing.T) {
data.Add("notify", "on")
today := time.Now().Format("2006-01-02")
// Posting to the save URL saves a page
HTTPRedirectTo(t, makeHandler(saveHandler, true),
HTTPRedirectTo(t, makeHandler(saveHandler, true, http.MethodPost),
"POST", "/save/testdata/notification/"+today,
data, "/view/testdata/notification/"+today)
// The changes.md file was created
@@ -73,15 +73,15 @@ func TestEditId(t *testing.T) {
cleanup(t, "testdata/id")
data := url.Values{}
data.Set("id", "testdata/id/alex")
assert.HTTPStatusCode(t, makeHandler(editHandler, true),
assert.HTTPStatusCode(t, makeHandler(editHandler, true, http.MethodGet),
"GET", "/edit/", data, http.StatusBadRequest,
"No slashes in id")
data.Set("id", ".alex")
assert.HTTPStatusCode(t, makeHandler(editHandler, true),
assert.HTTPStatusCode(t, makeHandler(editHandler, true, http.MethodGet),
"GET", "/edit/", data, http.StatusForbidden,
"No hidden files")
data.Set("id", "alex")
assert.Contains(t, assert.HTTPBody(makeHandler(editHandler, true),
assert.Contains(t, assert.HTTPBody(makeHandler(editHandler, true, http.MethodGet),
"GET", "/edit/testdata/id/", data),
"Editing testdata/id/alex")
}

View File

@@ -34,10 +34,6 @@ func (*exportCmd) Usage() string {
it:
oddmu export > /tmp/export.rss
Options:
-template "filename" specifies the template to use (default: feed.html)
`
}

View File

@@ -8,13 +8,13 @@ import (
func TestFeed(t *testing.T) {
assert.Contains(t,
assert.HTTPBody(makeHandler(viewHandler, false), "GET", "/view/index.rss", nil),
assert.HTTPBody(makeHandler(viewHandler, false, http.MethodGet), "GET", "/view/index.rss", nil),
"Welcome to Oddμ")
}
func TestNoFeed(t *testing.T) {
assert.HTTPStatusCode(t,
makeHandler(viewHandler, false), "GET", "/view/no-feed.rss", nil, http.StatusNotFound)
makeHandler(viewHandler, false, http.MethodGet), "GET", "/view/no-feed.rss", nil, http.StatusNotFound)
}
func TestFeedItems(t *testing.T) {
@@ -44,7 +44,7 @@ Writing poems about plants.
* [My Dragon Tree](dragon)`)}
p3.save()
body := assert.HTTPBody(makeHandler(viewHandler, false), "GET", "/view/testdata/feed/plants.rss", nil)
body := assert.HTTPBody(makeHandler(viewHandler, false, http.MethodGet), "GET", "/view/testdata/feed/plants.rss", nil)
assert.Contains(t, body, "<title>Plants</title>")
assert.Contains(t, body, "<title>Cactus</title>")
assert.Contains(t, body, "<title>Dragon</title>")

View File

@@ -5,15 +5,26 @@ import (
"flag"
"fmt"
"github.com/google/subcommands"
"github.com/gomarkdown/markdown"
"github.com/gomarkdown/markdown/ast"
"github.com/hexops/gotextdiff"
"github.com/hexops/gotextdiff/myers"
"github.com/hexops/gotextdiff/span"
"io"
"os"
"regexp"
"sort"
"strings"
)
type hashtagsCmd struct {
update bool
dryRun bool
}
func (cmd *hashtagsCmd) SetFlags(f *flag.FlagSet) {
f.BoolVar(&cmd.update, "update", false, "create and update hashtag pages")
f.BoolVar(&cmd.dryRun, "dry-run", false, "only report the changes it would make")
}
func (*hashtagsCmd) Name() string { return "hashtags" }
@@ -25,6 +36,9 @@ func (*hashtagsCmd) Usage() string {
}
func (cmd *hashtagsCmd) Execute(_ context.Context, f *flag.FlagSet, _ ...interface{}) subcommands.ExitStatus {
if cmd.update {
return hashtagsUpdateCli(os.Stdout, cmd.dryRun)
}
return hashtagsCli(os.Stdout)
}
@@ -57,3 +71,138 @@ func hashtagsCli(w io.Writer) subcommands.ExitStatus {
return subcommands.ExitSuccess
}
// hashtagsUpdateCli runs the hashtags command on the command line and creates and updates the hashtag pages in the
// current directory. That is, pages in subdirectories are skipped! It is used here with an io.Writer for easy testing.
func hashtagsUpdateCli(w io.Writer, dryRun bool) subcommands.ExitStatus {
index.load()
// no locking necessary since this is for the command-line
namesMap := make(map[string]string)
for hashtag, docids := range index.token {
if len(docids) <= 5 {
if dryRun {
fmt.Fprintf(w, "Skipping #%s because there are not enough entries (%d)\n", hashtag, len(docids))
}
continue
}
title, ok := namesMap[hashtag]
if (!ok) {
title = hashtagName(namesMap, hashtag, docids)
namesMap[hashtag] = title
}
pageName := strings.ReplaceAll(title, " ", "_")
h, err := loadPage(pageName)
original := ""
new := false
if err != nil {
new = true
h = &Page{Name: pageName, Body: []byte("# " + title + "\n\n#" + pageName + "\n\nBlog posts:\n\n")}
} else {
original = string(h.Body)
}
for _, docid := range docids {
name := index.documents[docid]
if strings.Contains(name, "/") {
continue
}
p, err := loadPage(name)
if err != nil {
fmt.Fprintf(w, "Loading %s: %s\n", name, err)
return subcommands.ExitFailure
}
if !p.IsBlog() {
continue
}
p.handleTitle(false)
if p.Title == "" {
p.Title = p.Name
}
esc := nameEscape(p.Base())
link := "* [" + p.Title + "](" + esc + ")\n"
// I guess & used to get escaped and now no longer does
re := regexp.MustCompile(`(?m)^\* \[[^\]]+\]\(` + strings.ReplaceAll(esc, "&", "(&|%26)") + `\)\n`)
addLinkToPage(h, link, re)
}
// only save if something changed
if string(h.Body) != original {
if dryRun {
if new {
fmt.Fprintf(w, "Creating %s.md\n", title)
} else {
fmt.Fprintf(w, "Updating %s.md\n", title)
}
fn := h.Name + ".md"
edits := myers.ComputeEdits(span.URIFromPath(fn), original, string(h.Body))
diff := fmt.Sprint(gotextdiff.ToUnified(fn + "~", fn, original, edits))
fmt.Fprint(w, diff)
} else {
err = h.save()
if err != nil {
fmt.Fprintf(w, "Saving hashtag %s failed: %s", hashtag, err)
return subcommands.ExitFailure
}
}
}
}
return subcommands.ExitSuccess
}
// Go through all the documents in the same directory and look for hashtag matches in the rendered HTML in order to
// determine the most likely capitalization.
func hashtagName (namesMap map[string]string, hashtag string, docids []docid) string {
candidate := make(map[string]int)
var mostPopular string
for _, docid := range docids {
name := index.documents[docid]
if strings.Contains(name, "/") {
continue
}
p, err := loadPage(name)
if err != nil {
continue
}
// parsing finds all the hashtags
parser, _ := wikiParser()
doc := markdown.Parse(p.Body, parser)
ast.WalkFunc(doc, func(node ast.Node, entering bool) ast.WalkStatus {
if entering {
switch v := node.(type) {
case *ast.Link:
for _, attr := range v.AdditionalAttributes {
if attr == `class="tag"` {
tagName := []byte("")
ast.WalkFunc(v, func(node ast.Node, entering bool) ast.WalkStatus {
if entering && node.AsLeaf() != nil {
tagName = append(tagName, node.AsLeaf().Literal...)
}
return ast.GoToNext
})
tag := string(tagName[1:])
if strings.EqualFold(hashtag, strings.ReplaceAll(tag, " ", "_")) {
_, ok := candidate[tag]
if ok {
candidate[tag] += 1
} else {
candidate[tag] = 1
}
}
}
}
}
}
return ast.GoToNext
})
count := 0
for key, val := range candidate {
if val > count {
mostPopular = key
count = val
}
}
// shortcut
if count >= 5 {
return mostPopular
}
}
return mostPopular
}

121
list.go
View File

@@ -1,121 +0,0 @@
package main
import (
"io/fs"
"log"
"net/http"
"os"
"path"
"path/filepath"
"strings"
"time"
)
// ListItem is used to display the list of files.
type File struct {
Name, Title string
IsDir, IsUp bool
// Date is the last modification date of the file storing the page. As the pages used by Oddmu are plain
// Markdown files, they don't contain any metadata. Instead, the last modification date of the file is used.
// This makes it work well with changes made to the files outside of Oddmu.
Date string
}
type List struct {
Dir string
Files []File
}
// listHandler uses the "list.html" template to enable file management in a particular directory.
func listHandler(w http.ResponseWriter, r *http.Request, name string) {
files := []File{}
d := filepath.FromSlash(name)
if d == "" {
d = "."
} else if !strings.HasSuffix(d, "/") {
http.Redirect(w, r, "/list/" + nameEscape(name) + "/", http.StatusFound)
return
} else {
it := File{Name: "..", IsUp: true, IsDir: true }
files = append(files, it)
}
err := filepath.Walk(d, func (fp string, fi fs.FileInfo, err error) error {
if err != nil {
return err
}
isDir := false
if fi.IsDir() {
if d == fp {
return nil
}
isDir = true
}
name := filepath.ToSlash(fp)
base := filepath.Base(fp)
title := ""
if strings.HasPrefix(base, ".") {
// skip dot directories and dot files
if isDir {
return filepath.SkipDir
}
return nil
} else if !isDir && strings.HasSuffix(name, ".md") {
index.RLock()
defer index.RUnlock()
title = index.titles[name[:len(name)-3]]
} else if isDir {
// even on Windows, this looks like a Unix directory
base += "/"
}
it := File{Name: base, Title: title, Date: fi.ModTime().Format(time.DateTime), IsDir: isDir }
files = append(files, it)
if isDir {
// never descend into directories
return filepath.SkipDir
}
return nil
})
if err != nil {
log.Println(err)
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
renderTemplate(w, d, "list", &List{Dir: pathEncode(name), Files: files})
}
// deleteHandler deletes the named file and then redirects back to the list
func deleteHandler(w http.ResponseWriter, r *http.Request, name string) {
fn := filepath.FromSlash(name)
err := os.RemoveAll(fn) // and all its children!
if err != nil {
log.Println(err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/list/" + nameEscape(path.Dir(name)) + "/", http.StatusFound)
}
// renameHandler renames the named file and then redirects back to the list
func renameHandler(w http.ResponseWriter, r *http.Request, name string) {
fn := filepath.FromSlash(name)
dir := path.Dir(name)
target := path.Join(dir, r.FormValue("name"))
if (isHiddenName(target)) {
http.Error(w, "the target file would be hidden", http.StatusForbidden)
return
}
err := os.Rename(fn, filepath.FromSlash(target))
if err != nil {
log.Println(err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/list/" + nameEscape(path.Dir(filepath.ToSlash(target))) + "/", http.StatusFound)
}
// Path returns the File.Name with some characters escaped because html/template doesn't escape those. This is suitable
// for use in HTML templates.
func (f *File) Path() string {
return pathEncode(f.Name)
}

View File

@@ -1,59 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="format-detection" content="telephone=no">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<title>Manage Files</title>
<style>
html { max-width: 70ch; padding: 2ch; margin: auto; color: #111; background-color: #ffe }
body { hyphens: auto }
form { width: 100% }
table { border-collapse: collapse }
th:nth-child(3) { max-width: 3ex; overflow: visible }
td form { display: inline }
td { padding-right: 1ch }
td:last-child { padding-right: 0 }
td:first-child { max-width: 30ch; overflow: hidden }
tr:nth-child(odd) { background-color: #eed }
td:first-child, td:last-child { white-space: nowrap }
</style>
</head>
<body lang="en">
<header>
<a href="#main">Skip</a>
<a href="/view/index">Home</a>
<a href="/archive/{{.Dir}}data.zip" accesskey="z">Zip</a>
<a href="/upload/{{.Dir}}?filename=image-1.jpg" accesskey="u">Upload</a>
<form role="search" action="/search/{{.Dir}}" method="GET">
<label for="search">Search:</label>
<input id="search" type="text" spellcheck="false" name="q" accesskey="f" placeholder="term #tag title:term blog:true" required>
<button>Go</button>
</form>
</header>
<main>
<h1>Manage Files</h1>
<form id="manage">
<p><mark>Deletions and renamings take effect immediately and there is no undo!</mark></p>
</form>
<table>
<tr>
<th>Name</th>
<th>Title</th>
<th>Delete</th>
<th>Rename</th>
</tr>{{range .Files}}
<tr>
<td>{{if .IsDir}}<a href="/list/{{$.Dir}}{{.Path}}">{{.Name}}</a>{{else}}<a href="/view/{{$.Dir}}{{.Path}}">{{.Name}}</a>{{end}}</td>
<td>{{.Title}}</td>
<td>{{if .IsUp}}{{else}}<button form="manage" formaction="/delete/{{$.Dir}}{{.Path}}" title="Delete {{.Name}}">🗑</button>{{end}}</td>
<td>{{if .IsUp}}{{else}}
<form action="/rename/{{$.Dir}}{{.Path}}">
<input name="name" placeholder="New name"/>
<button title="Rename {{.Name}}"></button>
</form>{{end}}</td>
</tr>{{end}}
</table>
</main>
</body>
</html>

View File

@@ -1,76 +0,0 @@
package main
import (
"github.com/stretchr/testify/assert"
"os"
"testing"
)
// relies on index.md in the current directory!
func TestListHandler(t *testing.T) {
assert.Contains(t,
assert.HTTPBody(makeHandler(listHandler, false), "GET", "/list/", nil),
"index.md")
}
func TestListDot(t *testing.T) {
cleanup(t, "testdata/list-dot")
p := &Page{Name: "testdata/list-dot/haiku", Body: []byte(`# Pressure
fingers tap and dance
round and round they go at night
before we go to bed
`)}
p.save()
_, err := os.Create("testdata/list-dot/.secret")
assert.NoError(t, err)
body := assert.HTTPBody(makeHandler(listHandler, false), "GET", "/list/testdata/list-dot/", nil)
assert.NotContains(t, body, "secret", "secret file was not found")
assert.Contains(t, body, "haiku", "regular page was found")
}
func TestDeleteHandler(t *testing.T) {
cleanup(t, "testdata/delete")
assert.NoError(t, os.Mkdir("testdata/delete", 0755))
p := &Page{Name: "testdata/delete/haiku", Body: []byte(`# Sunset
Walk the fields outside
See the forest loom above
And an orange sky
`)}
p.save()
body := assert.HTTPBody(makeHandler(listHandler, false), "GET", "/list/testdata/delete/", nil)
assert.Contains(t, body, `<a href="/view/testdata/delete/haiku.md">haiku.md</a>`)
assert.Contains(t, body, `<td>Sunset</td>`)
assert.Contains(t, body, `<button form="manage" formaction="/delete/testdata/delete/haiku.md" title="Delete haiku.md">`)
// ensure that it exists
assert.FileExists(t, "testdata/delete/haiku.md")
// delete file
HTTPRedirectTo(t, makeHandler(deleteHandler, false), "GET", "/delete/testdata/delete/haiku.md", nil, "/list/testdata/delete/")
// verify that it is gone
body = assert.HTTPBody(makeHandler(listHandler, false), "GET", "/list/testdata/delete/", nil)
assert.NotContains(t, body, `<a href="/view/testdata/delete/haiku.md">haiku.md</a>`)
assert.NoFileExists(t, "testdata/delete/haiku.md")
}
func TestListUmlautHandler(t *testing.T) {
cleanup(t, "testdata/list-umlaut")
p := &Page{Name: "testdata/list-umlaut/hägar", Body: []byte(`# Hägar
Hägar was a man
Loud and strong and quick to act
he did not like it
`)}
p.save()
body := assert.HTTPBody(makeHandler(listHandler, false), "GET", "/list/testdata/list-umlaut/", nil)
assert.Contains(t, body, `<button form="manage" formaction="/delete/testdata/list-umlaut/h%c3%a4gar.md" title="Delete hägar.md">`)
}
func TestListHash(t *testing.T) {
cleanup(t, "testdata/list-#hash")
os.Mkdir("testdata/list-#hash", 0755)
_, err := os.Create("testdata/list-#hash/#secret")
assert.NoError(t, err)
body := assert.HTTPBody(makeHandler(listHandler, false), "GET", "/list/testdata/list-%23hash/", nil)
assert.Contains(t, body, `<button form="manage" formaction="/delete/testdata/list-%23hash/%23secret" title="Delete #secret">`)
}

View File

@@ -5,7 +5,7 @@
.nh
.ad l
.\" Begin generated content:
.TH "ODDMU-APACHE" "5" "2024-09-25"
.TH "ODDMU-APACHE" "5" "2025-07-16"
.PP
.SH NAME
.PP
@@ -48,7 +48,7 @@ ServerAdmin alex@alexschroeder\&.ch
<VirtualHost *:443>
ServerName transjovian\&.org
SSLEngine on
ProxyPassMatch "^/((view|preview|diff|edit|save|add|append|upload|drop|list|delete|rename|search|archive)/(\&.*))?$"
ProxyPassMatch "^/((view|preview|diff|edit|save|add|append|upload|drop|search|archive)/(\&.*))?$"
"http://localhost:8080/$1"
</VirtualHost>
.fi
@@ -126,13 +126,13 @@ ServerAdmin alex@alexschroeder\&.ch
ServerName transjovian\&.org
ProxyPassMatch "^/((view|diff|search|archive)/(\&.*))?$"
"http://localhost:8080/$1"
RedirectMatch "^/((edit|save|add|append|upload|drop|list|delete|rename)/(\&.*))?$"
RedirectMatch "^/((edit|save|add|append|upload|drop)/(\&.*))?$"
"https://transjovian\&.org/$1"
</VirtualHost>
<VirtualHost *:443>
ServerName transjovian\&.org
SSLEngine on
ProxyPassMatch "^/((view|preview|diff|edit|save|add|append|upload|drop|list|delete|rename|search|archive)/(\&.*))?$"
ProxyPassMatch "^/((view|preview|diff|edit|save|add|append|upload|drop|search|archive)/(\&.*))?$"
"http://localhost:8080/$1"
</VirtualHost>
.fi
@@ -170,7 +170,7 @@ In that case, you need to use the ProxyPassMatch directive.\&
.PP
.nf
.RS 4
ProxyPassMatch "^/((view|preview|diff|edit|save|add|append|upload|drop|list|delete|rename|search|archive)/(\&.*))?$"
ProxyPassMatch "^/((view|preview|diff|edit|save|add|append|upload|drop|search|archive)/(\&.*))?$"
"unix:/run/oddmu/oddmu\&.sock|http://localhost/$1"
.fi
.RE
@@ -189,7 +189,7 @@ A workaround is to add the redirect manually and drop the question-mark:
.nf
.RS 4
RedirectMatch "^/$" "/view/index"
ProxyPassMatch "^/((view|preview|diff|edit|save|add|append|upload|drop|list|delete|rename|search|archive)/(\&.*))$"
ProxyPassMatch "^/((view|preview|diff|edit|save|add|append|upload|drop|search|archive)/(\&.*))$"
"unix:/run/oddmu/oddmu\&.sock|http://localhost/$1"
.fi
.RE
@@ -234,12 +234,12 @@ htpasswd -D \&.htpasswd berta
.RE
.PP
Modify your site configuration and protect the "/edit/", "/save/", "/add/",
"/append/", "/upload/", "/drop/", "/list/", "/delete/" and "/rename/" URLs with
a password by adding the following to your "<VirtualHost *:443>" section:
"/append/", "/upload/" and "/drop/" URLs with a password by adding the following
to your "<VirtualHost *:443>" section:
.PP
.nf
.RS 4
<LocationMatch "^/(edit|save|add|append|upload|drop|list|delete|rename)/">
<LocationMatch "^/(edit|save|add|append|upload|drop)/">
AuthType Basic
AuthName "Password Required"
AuthUserFile /home/oddmu/\&.htpasswd
@@ -274,7 +274,7 @@ directory:
.PP
.nf
.RS 4
<LocationMatch "^/(edit|save|add|append|upload|drop|list|delete|rename|(view|preview|search|archive)/secret)/">
<LocationMatch "^/(edit|save|add|append|upload|drop|(view|preview|search|archive)/secret)/">
AuthType Basic
AuthName "Password Required"
AuthUserFile /home/oddmu/\&.htpasswd
@@ -300,9 +300,8 @@ DocumentRoot /home/oddmu
.PP
Make sure that none of the subdirectories look like the wiki paths "/view/",
"/diff/", "/edit/", "/save/", "/add/", "/append/", "/upload/", "/drop/",
"/list", "/delete/", "/rename/" "/search/" or "/archive/".\& For example, create a
file called "robots.\&txt" containing the following, telling all robots that
they'\&re not welcome.\&
"/search/" or "/archive/".\& For example, create a file called "robots.\&txt"
containing the following, telling all robots that they'\&re not welcome.\&
.PP
.nf
.RS 4
@@ -350,7 +349,7 @@ This requires a valid login by the user "alex" or "berta":
.PP
.nf
.RS 4
<LocationMatch "^/(edit|save|add|append|upload|drop|list|delete|rename)/intetebi/">
<LocationMatch "^/(edit|save|add|append|upload|drop)/intetebi/">
Require user alex berta
</LocationMatch>
.fi

View File

@@ -40,7 +40,7 @@ ServerAdmin alex@alexschroeder.ch
<VirtualHost *:443>
ServerName transjovian.org
SSLEngine on
ProxyPassMatch "^/((view|preview|diff|edit|save|add|append|upload|drop|list|delete|rename|search|archive)/(.*))?$" \
ProxyPassMatch "^/((view|preview|diff|edit|save|add|append|upload|drop|search|archive)/(.*))?$" \
"http://localhost:8080/$1"
</VirtualHost>
```
@@ -106,13 +106,13 @@ ServerAdmin alex@alexschroeder.ch
ServerName transjovian.org
ProxyPassMatch "^/((view|diff|search|archive)/(.*))?$" \
"http://localhost:8080/$1"
RedirectMatch "^/((edit|save|add|append|upload|drop|list|delete|rename)/(.*))?$" \
RedirectMatch "^/((edit|save|add|append|upload|drop)/(.*))?$" \
"https://transjovian.org/$1"
</VirtualHost>
<VirtualHost *:443>
ServerName transjovian.org
SSLEngine on
ProxyPassMatch "^/((view|preview|diff|edit|save|add|append|upload|drop|list|delete|rename|search|archive)/(.*))?$" \
ProxyPassMatch "^/((view|preview|diff|edit|save|add|append|upload|drop|search|archive)/(.*))?$" \
"http://localhost:8080/$1"
</VirtualHost>
```
@@ -144,7 +144,7 @@ You probably want to serve some static files as well (see *Serve static files*).
In that case, you need to use the ProxyPassMatch directive.
```
ProxyPassMatch "^/((view|preview|diff|edit|save|add|append|upload|drop|list|delete|rename|search|archive)/(.*))?$" \
ProxyPassMatch "^/((view|preview|diff|edit|save|add|append|upload|drop|search|archive)/(.*))?$" \
"unix:/run/oddmu/oddmu.sock|http://localhost/$1"
```
@@ -159,7 +159,7 @@ A workaround is to add the redirect manually and drop the question-mark:
```
RedirectMatch "^/$" "/view/index"
ProxyPassMatch "^/((view|preview|diff|edit|save|add|append|upload|drop|list|delete|rename|search|archive)/(.*))$" \
ProxyPassMatch "^/((view|preview|diff|edit|save|add|append|upload|drop|search|archive)/(.*))$" \
"unix:/run/oddmu/oddmu.sock|http://localhost/$1"
```
@@ -197,11 +197,11 @@ htpasswd -D .htpasswd berta
```
Modify your site configuration and protect the "/edit/", "/save/", "/add/",
"/append/", "/upload/", "/drop/", "/list/", "/delete/" and "/rename/" URLs with
a password by adding the following to your "<VirtualHost \*:443>" section:
"/append/", "/upload/" and "/drop/" URLs with a password by adding the following
to your "<VirtualHost \*:443>" section:
```
<LocationMatch "^/(edit|save|add|append|upload|drop|list|delete|rename)/">
<LocationMatch "^/(edit|save|add|append|upload|drop)/">
AuthType Basic
AuthName "Password Required"
AuthUserFile /home/oddmu/.htpasswd
@@ -234,7 +234,7 @@ You need to configure the web server to prevent access to the "secret/"
directory:
```
<LocationMatch "^/(edit|save|add|append|upload|drop|list|delete|rename|(view|preview|search|archive)/secret)/">
<LocationMatch "^/(edit|save|add|append|upload|drop|(view|preview|search|archive)/secret)/">
AuthType Basic
AuthName "Password Required"
AuthUserFile /home/oddmu/.htpasswd
@@ -257,9 +257,8 @@ DocumentRoot /home/oddmu
Make sure that none of the subdirectories look like the wiki paths "/view/",
"/diff/", "/edit/", "/save/", "/add/", "/append/", "/upload/", "/drop/",
"/list", "/delete/", "/rename/" "/search/" or "/archive/". For example, create a
file called "robots.txt" containing the following, telling all robots that
they're not welcome.
"/search/" or "/archive/". For example, create a file called "robots.txt"
containing the following, telling all robots that they're not welcome.
```
User-agent: *
@@ -302,7 +301,7 @@ password file mentioned above.
This requires a valid login by the user "alex" or "berta":
```
<LocationMatch "^/(edit|save|add|append|upload|drop|list|delete|rename)/intetebi/">
<LocationMatch "^/(edit|save|add|append|upload|drop)/intetebi/">
Require user alex berta
</LocationMatch>
```

View File

@@ -5,20 +5,31 @@
.nh
.ad l
.\" Begin generated content:
.TH "ODDMU-HASHTAGS" "1" "2024-08-29"
.TH "ODDMU-HASHTAGS" "1" "2025-08-09"
.PP
.SH NAME
.PP
oddmu-hashtags - count the hashtags used
oddmu-hashtags - work with hashtags
.PP
.SH SYNOPSIS
.PP
\fBoddmu hashtags\fR
.PP
\fBoddmu hashtags -update\fR [\fB-dry-run\fR]
.PP
.SH DESCRIPTION
.PP
The "hashtags" subcommand counts all the hashtags used and lists them, separated
by a TAB character.\&
By default, the "hashtags" subcommand counts all the hashtags used and lists
them, separated by a TAB character.\&
.PP
With the \fB-update\fR flag, the hashtag pages are update with links to all the blog
pages having the corresponding tag.\& This only necessary when migrating a
collection of Markdown files.\& Ordinarily, Oddmu maintains the hashtag pages
automatically.\& When writing pages offline, use \fIoddmu-notify\fR(1) to update the
hashtag pages.\&
.PP
Use the \fB-dry-run\fR flag to see what would change with the \fB-update\fR flag without
actually changing any files.\&
.PP
.SH EXAMPLES
.PP
@@ -30,6 +41,22 @@ oddmu hashtags | head -n 11
.fi
.RE
.PP
See what kind of changes Oddmu would suggest:
.PP
.nf
.RS 4
oddmu hashtags -update -dry-run
.fi
.RE
.PP
And then do it:
.PP
.nf
.RS 4
oddmu hashtags -update
.fi
.RE
.PP
.SH SEE ALSO
.PP
\fIoddmu\fR(1)

View File

@@ -2,16 +2,27 @@ ODDMU-HASHTAGS(1)
# NAME
oddmu-hashtags - count the hashtags used
oddmu-hashtags - work with hashtags
# SYNOPSIS
*oddmu hashtags*
*oddmu hashtags -update* [*-dry-run*]
# DESCRIPTION
The "hashtags" subcommand counts all the hashtags used and lists them, separated
by a TAB character.
By default, the "hashtags" subcommand counts all the hashtags used and lists
them, separated by a TAB character.
With the *-update* flag, the hashtag pages are update with links to all the blog
pages having the corresponding tag. This only necessary when migrating a
collection of Markdown files. Ordinarily, Oddmu maintains the hashtag pages
automatically. When writing pages offline, use _oddmu-notify_(1) to update the
hashtag pages.
Use the *-dry-run* flag to see what would change with the *-update* flag without
actually changing any files.
# EXAMPLES
@@ -21,6 +32,18 @@ List the top 10 hashtags. This requires 11 lines because of the header line.
oddmu hashtags | head -n 11
```
See what kind of changes Oddmu would suggest:
```
oddmu hashtags -update -dry-run
```
And then do it:
```
oddmu hashtags -update
```
# SEE ALSO
_oddmu_(1)

View File

@@ -5,7 +5,7 @@
.nh
.ad l
.\" Begin generated content:
.TH "ODDMU-NGINX" "5" "2025-03-16"
.TH "ODDMU-NGINX" "5" "2025-07-16"
.PP
.SH NAME
.PP
@@ -27,7 +27,7 @@ section.\& Add a new \fIlocation\fR section after the existing \fIlocation\fR se
.PP
.nf
.RS 4
location ~ ^/(view|preview|diff|edit|save|add|append|upload|drop|list|delete|rename|search|archive)/ {
location ~ ^/(view|preview|diff|edit|save|add|append|upload|drop|search|archive)/ {
proxy_pass http://localhost:8080;
}
.fi
@@ -53,7 +53,7 @@ location ~ ^/(view|diff|search)/ {
proxy_pass http://localhost:8080;
}
# password required
location ~ ^/(edit|save|add|append|upload|drop|list|delete|rename|archive)/ {
location ~ ^/(edit|save|add|append|upload|drop|archive)/ {
auth_basic "Oddmu author";
auth_basic_user_file /etc/nginx/conf\&.d/htpasswd;
proxy_pass http://localhost:8080;
@@ -97,7 +97,7 @@ server configuration.\& On a Debian system, that'\&d be in
.PP
.nf
.RS 4
location ~ ^/(view|preview|diff|edit|save|add|append|upload|drop|list|delete|rename|search|archive)/ {
location ~ ^/(view|preview|diff|edit|save|add|append|upload|drop|search|archive)/ {
proxy_pass http://unix:/run/oddmu/oddmu\&.sock:;
}
.fi

View File

@@ -19,7 +19,7 @@ The site is defined in "/etc/nginx/sites-available/default", in the _server_
section. Add a new _location_ section after the existing _location_ section:
```
location ~ ^/(view|preview|diff|edit|save|add|append|upload|drop|list|delete|rename|search|archive)/ {
location ~ ^/(view|preview|diff|edit|save|add|append|upload|drop|search|archive)/ {
proxy_pass http://localhost:8080;
}
```
@@ -43,7 +43,7 @@ location ~ ^/(view|diff|search)/ {
proxy_pass http://localhost:8080;
}
# password required
location ~ ^/(edit|save|add|append|upload|drop|list|delete|rename|archive)/ {
location ~ ^/(edit|save|add|append|upload|drop|archive)/ {
auth_basic "Oddmu author";
auth_basic_user_file /etc/nginx/conf.d/htpasswd;
proxy_pass http://localhost:8080;
@@ -81,7 +81,7 @@ server configuration. On a Debian system, that'd be in
"/etc/nginx/sites-available/default".
```
location ~ ^/(view|preview|diff|edit|save|add|append|upload|drop|list|delete|rename|search|archive)/ {
location ~ ^/(view|preview|diff|edit|save|add|append|upload|drop|search|archive)/ {
proxy_pass http://unix:/run/oddmu/oddmu.sock:;
}
```

View File

@@ -5,7 +5,7 @@
.nh
.ad l
.\" Begin generated content:
.TH "ODDMU-RELEASES" "7" "2025-04-26"
.TH "ODDMU-RELEASES" "7" "2025-08-10"
.PP
.SH NAME
.PP
@@ -15,6 +15,18 @@ oddmu-releases - what'\&s new?\&
.PP
This page lists user-visible features and template changes to consider.\&
.PP
.SS 1.18 (2025)
.PP
The \fIhashtags\fR gained the option of checking and fixing the hashtag pages by
adding missing links to tagged blog pages.\& See \fIoddmu-hashtags\fR(1) for more.\&
.PP
In an effort to remove features that can be handled by the web server, the
\fIlist\fR, \fIdelete\fR and \fIrename\fR actions were removed again.\& See \fIoddmu-webdav\fR(5)
for a better solution.\&
.PP
You probably need to remove a sentence linking to the list action from the
upload template ("upload.\&html").\&
.PP
.SS 1.17 (2025)
.PP
You need to update the upload template ("upload.\&html").\& Many things have

View File

@@ -8,6 +8,18 @@ oddmu-releases - what's new?
This page lists user-visible features and template changes to consider.
## 1.18 (2025)
The _hashtags_ gained the option of checking and fixing the hashtag pages by
adding missing links to tagged blog pages. See _oddmu-hashtags_(1) for more.
In an effort to remove features that can be handled by the web server, the
_list_, _delete_ and _rename_ actions were removed again. See _oddmu-webdav_(5)
for a better solution.
You probably need to remove a sentence linking to the list action from the
upload template ("upload.html").
## 1.17 (2025)
You need to update the upload template ("upload.html"). Many things have

View File

@@ -5,7 +5,7 @@
.nh
.ad l
.\" Begin generated content:
.TH "ODDMU-WEBDAV" "5" "2024-09-25"
.TH "ODDMU-WEBDAV" "5" "2025-07-16"
.PP
.SH NAME
.PP
@@ -14,27 +14,34 @@ oddmu-webdav - how to setup Web-DAV using Apache for Oddmu
.SH DESCRIPTION
.PP
With the Apache Web-DAV module enabled, users can mount the wiki as a remote
file system and edit files using their favourite text editor.\& If you want to
offer users direct file access to the wiki, this can be accomplished via ssh,
sftp or Web-DAV.\&
.PP
The benefit of using the Apache Web-DAV module is that access has to be
configured only once.\&
file system and manage the files using some other tool.\& Using the Apache Web-DAV
module means that the same user accounts can be used as for the regular wiki.\&
.PP
.SH CONFIGURATION
.PP
In the following example, "data" is not an action provided by Oddmu but an
actual directory for Oddmu files.\& In the example below,
"/home/alex/campaignwiki.\&org/data" is both the document root for static files
and the data directory for Oddmu.\& This is the directory where Oddmu needs to
run.\& When users request the "/data" path, authentication is required but the
request is not proxied to Oddmu since the "ProxyPassMatch" directive doesn'\&t
handle "/data".\& Instead, Apache gets to handle it.\& Since "data" is part of all
the "LocationMatch" directives, credentials are required to save (PUT) files.\&
Consider the "campaignwiki.\&org" site in the example below.\& This site offers
users their own wikis.\& Thus:
.PP
"Dav On" enables Web-DAV for the "knochentanz" wiki.\& It is enabled for all the
actions, but since only "/data" is handled by Apache, this has no effect for all
the other actions, allowing us to specify the required users only once.\&
"https://campaignwiki.\&org/" is a regular website with static files.\&
.PP
"https://campaignwiki.\&org/view/index" is one of the requests that gets passed to
a Unix domain socket.\& See "Socket Activation" in \fIoddmu\fR(1).\&
.PP
Some of these actions are protected by basic authentication.\& A valid user is
required to make changes to the site.\& Valid users are "admin" and "alex".\&
.PP
"data" is the Oddmu working directory.\& WebDAV is turned on for this directory.\& A
shortcut has been taken, here: The "data" subdirectory requires authentication
and offers WebDAV access.\& The other paths also require authentication and map to
Oddmu actions.\& The fact that WebDAV access is "enabled" for the Oddmu actions
has no effect.\& The only drawback is that "https://campaignwiki.\&org/data/" now
requires authentication even if only used for reading.\&
.PP
"https://campaignwiki.\&org/view/knochentanz/index" is a separate site called
"knochentanz".\& The only valid user is "knochentanz".\&
.PP
Notice how the \fIarchive\fR action is not available at the top level, only for
subdirectories.\&
.PP
.nf
.RS 4
@@ -48,28 +55,32 @@ MDomain campaignwiki\&.org
<VirtualHost *:443>
ServerAdmin alex@campaignwiki\&.org
ServerName campaignwiki\&.org
# Static HTML, CSS, JavaScript files and so on are saved here\&.
DocumentRoot /home/alex/campaignwiki\&.org
<Directory /home/alex/campaignwiki\&.org>
Options Includes Indexes MultiViews SymLinksIfOwnerMatch
Options Indexes MultiViews SymLinksIfOwnerMatch
AllowOverride All
Require all granted
Require all granted
</Directory>
SSLEngine on
# Any request to the following paths is passed on to the Unix domain socket\&.
ProxyPassMatch
"^/((view|preview|diff|edit|save|add|append|upload|drop|list|delete|search|archive/\&.+)/(\&.*))$"
"^/((view|preview|diff|edit|save|add|append|upload|drop|search|archive/\&.+)/(\&.*))$"
"unix:/home/oddmu/campaignwiki\&.sock|http://localhost/$1"
# /archive only for subdirectories
Redirect "/archive/data\&.zip" "/view/archive"
<LocationMatch "^/(data|edit|preview|save|add|append|upload|drop|list|delete)/">
# Making changes to the wiki requires authentication\&.
<LocationMatch "^/(data|edit|preview|save|add|append|upload|drop)/">
AuthType Basic
AuthName "Password Required"
AuthUserFile /home/oddmu/\&.htpasswd
Require user admin alex
</LocationMatch>
<LocationMatch "^/(data|edit|preview|save|add|append|upload|drop|list|delete|archive)/knochentanz">
Require user admin alex knochentanz
Dav On
</LocationMatch>
# Making changes to a subdirectory requires different accounts\&.
<LocationMatch "^/(data|edit|preview|save|add|append|upload|drop|archive)/knochentanz">
Require user knochentanz
</LocationMatch>
</VirtualHost>
.fi
.RE
@@ -101,9 +112,13 @@ sudo chmod g+w /home/alex/campaignwiki\&.org/data/knochentanz
Web-DAV clients are often implemented such that they only work with servers that
exactly match their assumptions.\& If you'\&re trying to use \fIgvfs\fR(7), the Windows
File Explorer or the macOS Finder to edit Oddmu pages using Web-DAV, you'\&re on
your own.\&
your own.\& Sometimes it works.\& I'\&ve used Nemo 5.\&6.\&4 to connect to the server and
edited files using gedit 44.\&2.\& But I'\&ve used other file managers and other
editors with WebDAV support and they didn'\&t work very well.\&
.PP
This section has examples sessions using tools that work.\&
On Windows, try third party tools like WinSCP.\&
.PP
This section has examples sessions using command-line tools that work.\&
.PP
.SS cadaver
.PP
@@ -183,6 +198,9 @@ alex@melanobombus ~> echo test >> knochentanz/index\&.md
"Apache Module mod_dav".\&
https://httpd.\&apache.\&org/docs/current/mod/mod_dav.\&html
.PP
"WinSCP"
https://winscp.\&net/
.PP
.SH AUTHORS
.PP
Maintained by Alex Schroeder <alex@gnu.\&org>.\&

View File

@@ -7,27 +7,34 @@ oddmu-webdav - how to setup Web-DAV using Apache for Oddmu
# DESCRIPTION
With the Apache Web-DAV module enabled, users can mount the wiki as a remote
file system and edit files using their favourite text editor. If you want to
offer users direct file access to the wiki, this can be accomplished via ssh,
sftp or Web-DAV.
The benefit of using the Apache Web-DAV module is that access has to be
configured only once.
file system and manage the files using some other tool. Using the Apache Web-DAV
module means that the same user accounts can be used as for the regular wiki.
# CONFIGURATION
In the following example, "data" is not an action provided by Oddmu but an
actual directory for Oddmu files. In the example below,
"/home/alex/campaignwiki.org/data" is both the document root for static files
and the data directory for Oddmu. This is the directory where Oddmu needs to
run. When users request the "/data" path, authentication is required but the
request is not proxied to Oddmu since the "ProxyPassMatch" directive doesn't
handle "/data". Instead, Apache gets to handle it. Since "data" is part of all
the "LocationMatch" directives, credentials are required to save (PUT) files.
Consider the "campaignwiki.org" site in the example below. This site offers
users their own wikis. Thus:
"Dav On" enables Web-DAV for the "knochentanz" wiki. It is enabled for all the
actions, but since only "/data" is handled by Apache, this has no effect for all
the other actions, allowing us to specify the required users only once.
"https://campaignwiki.org/" is a regular website with static files.
"https://campaignwiki.org/view/index" is one of the requests that gets passed to
a Unix domain socket. See "Socket Activation" in _oddmu_(1).
Some of these actions are protected by basic authentication. A valid user is
required to make changes to the site. Valid users are "admin" and "alex".
"data" is the Oddmu working directory. WebDAV is turned on for this directory. A
shortcut has been taken, here: The "data" subdirectory requires authentication
and offers WebDAV access. The other paths also require authentication and map to
Oddmu actions. The fact that WebDAV access is "enabled" for the Oddmu actions
has no effect. The only drawback is that "https://campaignwiki.org/data/" now
requires authentication even if only used for reading.
"https://campaignwiki.org/view/knochentanz/index" is a separate site called
"knochentanz". The only valid user is "knochentanz".
Notice how the _archive_ action is not available at the top level, only for
subdirectories.
```
MDomain campaignwiki.org
@@ -40,28 +47,32 @@ MDomain campaignwiki.org
<VirtualHost *:443>
ServerAdmin alex@campaignwiki.org
ServerName campaignwiki.org
# Static HTML, CSS, JavaScript files and so on are saved here.
DocumentRoot /home/alex/campaignwiki.org
<Directory /home/alex/campaignwiki.org>
Options Includes Indexes MultiViews SymLinksIfOwnerMatch
Options Indexes MultiViews SymLinksIfOwnerMatch
AllowOverride All
Require all granted
Require all granted
</Directory>
SSLEngine on
# Any request to the following paths is passed on to the Unix domain socket.
ProxyPassMatch \
"^/((view|preview|diff|edit|save|add|append|upload|drop|list|delete|search|archive/.+)/(.*))$" \
"^/((view|preview|diff|edit|save|add|append|upload|drop|search|archive/.+)/(.*))$" \
"unix:/home/oddmu/campaignwiki.sock|http://localhost/$1"
# /archive only for subdirectories
Redirect "/archive/data.zip" "/view/archive"
<LocationMatch "^/(data|edit|preview|save|add|append|upload|drop|list|delete)/">
# Making changes to the wiki requires authentication.
<LocationMatch "^/(data|edit|preview|save|add|append|upload|drop)/">
AuthType Basic
AuthName "Password Required"
AuthUserFile /home/oddmu/.htpasswd
Require user admin alex
</LocationMatch>
<LocationMatch "^/(data|edit|preview|save|add|append|upload|drop|list|delete|archive)/knochentanz">
Require user admin alex knochentanz
Dav On
</LocationMatch>
# Making changes to a subdirectory requires different accounts.
<LocationMatch "^/(data|edit|preview|save|add|append|upload|drop|archive)/knochentanz">
Require user knochentanz
</LocationMatch>
</VirtualHost>
```
@@ -88,9 +99,13 @@ sudo chmod g+w /home/alex/campaignwiki.org/data/knochentanz
Web-DAV clients are often implemented such that they only work with servers that
exactly match their assumptions. If you're trying to use _gvfs_(7), the Windows
File Explorer or the macOS Finder to edit Oddmu pages using Web-DAV, you're on
your own.
your own. Sometimes it works. I've used Nemo 5.6.4 to connect to the server and
edited files using gedit 44.2. But I've used other file managers and other
editors with WebDAV support and they didn't work very well.
This section has examples sessions using tools that work.
On Windows, try third party tools like WinSCP.
This section has examples sessions using command-line tools that work.
## cadaver
@@ -164,6 +179,9 @@ _oddmu_(1), _oddmu-apache_(5)
"Apache Module mod_dav".
https://httpd.apache.org/docs/current/mod/mod_dav.html
"WinSCP"
https://winscp.net/
# AUTHORS
Maintained by Alex Schroeder <alex@gnu.org>.

View File

@@ -5,7 +5,7 @@
.nh
.ad l
.\" Begin generated content:
.TH "ODDMU" "1" "2025-03-14"
.TH "ODDMU" "1" "2025-08-09"
.PP
.SH NAME
.PP
@@ -77,12 +77,6 @@ directory:
.IP \(bu 4
\fI/drop/dir/name\fR saves an upload
.IP \(bu 4
\fI/list/dir/\fR lists the files in a directory
.IP \(bu 4
\fI/delete/dir/name\fR deletes a file or directory
.IP \(bu 4
\fI/rename/dir/name?\&name=new\fR renames a file or directory
.IP \(bu 4
\fI/search/dir/?\&q=term\fR to search for a term
.IP \(bu 4
\fI/archive/dir/name.\&zip\fR to download a zip file of a directory
@@ -390,7 +384,7 @@ Oddmu running as a webserver:
.PP
.PD 0
.IP \(bu 4
\fIoddmu-hashtags\fR(1), on how to count the hashtags used
\fIoddmu-hashtags\fR(1), on working with hashtags
.IP \(bu 4
\fIoddmu-html\fR(1), on how to render a page
.IP \(bu 4

View File

@@ -55,9 +55,6 @@ directory:
- _/append/dir/name_ appends an addition to a page
- _/upload/dir/name_ shows a form to upload a file
- _/drop/dir/name_ saves an upload
- _/list/dir/_ lists the files in a directory
- _/delete/dir/name_ deletes a file or directory
- _/rename/dir/name?name=new_ renames a file or directory
- _/search/dir/?q=term_ to search for a term
- _/archive/dir/name.zip_ to download a zip file of a directory
@@ -318,7 +315,7 @@ If you run Oddmu as a web server:
If you run Oddmu as a static site generator or pages offline and sync them with
Oddmu running as a webserver:
- _oddmu-hashtags_(1), on how to count the hashtags used
- _oddmu-hashtags_(1), on working with hashtags
- _oddmu-html_(1), on how to render a page
- _oddmu-list_(1), on how to list pages and titles
- _oddmu-links_(1), on how to list the outgoing links for a page

View File

@@ -71,7 +71,7 @@ func TestManActions(t *testing.T) {
wiki := string(b)
count := 0
// this doesn't match the root handler
re := regexp.MustCompile(`\.HandleFunc\("(/[a-z]+/)", makeHandler\([a-z]+Handler, (true|false)\)\)`)
re := regexp.MustCompile(`\.HandleFunc\("(/[a-z]+/)", makeHandler\([a-z]+Handler, (true|false)(, http\.Method(Get|Post))+\)\)`)
for _, match := range re.FindAllStringSubmatch(wiki, -1) {
count++
var path string

View File

@@ -10,8 +10,13 @@ import (
// otherwise the rendered template has garbage bytes at the end. Note also that we need to remove the title from the
// page so that the preview works as intended (and much like the "view.html" template) where as the editing requires the
// page content including the header… which is why it needs to be added in the "preview.html" template. This makes me
// sad.
// sad. While viewing the preview, links will point to the /preview path. In order to handle this, regular GET requests
// are passed on the the {viewHandler}.
func previewHandler(w http.ResponseWriter, r *http.Request, path string) {
if r.Method != http.MethodPost {
http.Redirect(w, r, "/view/" + strings.TrimPrefix(path, "/preview/"), http.StatusFound)
return
}
body := strings.ReplaceAll(r.FormValue("body"), "\r", "")
p := &Page{Name: path, Body: []byte(body)}
p.handleTitle(true)

View File

@@ -3,6 +3,7 @@ package main
import (
"github.com/stretchr/testify/assert"
"net/url"
"net/http"
"testing"
)
@@ -12,6 +13,6 @@ func TestPreview(t *testing.T) {
data := url.Values{}
data.Set("body", "**Hallo**!")
r := assert.HTTPBody(makeHandler(previewHandler, false), "POST", "/view/testdata/preview/alex", data)
r := assert.HTTPBody(makeHandler(previewHandler, false, http.MethodGet), "POST", "/view/testdata/preview/alex", data)
assert.Contains(t, r, "<strong>Hallo</strong>!")
}

View File

@@ -3,6 +3,7 @@ package main
import (
"fmt"
"github.com/stretchr/testify/assert"
"net/http"
"net/url"
"slices"
"testing"
@@ -63,15 +64,15 @@ func TestSearch(t *testing.T) {
data := url.Values{}
data.Set("q", "oddμ")
body := assert.HTTPBody(makeHandler(searchHandler, false), "GET", "/search/", data)
body := assert.HTTPBody(makeHandler(searchHandler, false, http.MethodGet), "GET", "/search/", data)
assert.Contains(t, body, "Welcome")
assert.Contains(t, body, `<span class="score">5</span>`)
body = assert.HTTPBody(makeHandler(searchHandler, false), "GET", "/search/testdata", data)
body = assert.HTTPBody(makeHandler(searchHandler, false, http.MethodGet), "GET", "/search/testdata", data)
assert.NotContains(t, body, "Welcome")
data.Set("q", "'create a new page'")
body = assert.HTTPBody(makeHandler(searchHandler, false), "GET", "/search/", data)
body = assert.HTTPBody(makeHandler(searchHandler, false, http.MethodGet), "GET", "/search/", data)
assert.Contains(t, body, "Welcome")
}
@@ -158,16 +159,16 @@ Where is lady luck?`)}
data := url.Values{}
data.Set("q", "luck")
body := assert.HTTPBody(makeHandler(searchHandler, false), "GET", "/search/", data)
body := assert.HTTPBody(makeHandler(searchHandler, false, http.MethodGet), "GET", "/search/", data)
assert.Contains(t, body, "luck")
body = assert.HTTPBody(makeHandler(searchHandler, false), "GET", "/search/testdata", data)
body = assert.HTTPBody(makeHandler(searchHandler, false, http.MethodGet), "GET", "/search/testdata", data)
assert.Contains(t, body, "luck")
body = assert.HTTPBody(makeHandler(searchHandler, false), "GET", "/search/testdata/dir", data)
body = assert.HTTPBody(makeHandler(searchHandler, false, http.MethodGet), "GET", "/search/testdata/dir", data)
assert.Contains(t, body, "luck")
body = assert.HTTPBody(makeHandler(searchHandler, false), "GET", "/search/testdata/other", data)
body = assert.HTTPBody(makeHandler(searchHandler, false, http.MethodGet), "GET", "/search/testdata/other", data)
assert.Contains(t, body, "No results")
}
@@ -277,7 +278,7 @@ The silence streches.`)}
p.save()
data := url.Values{}
data.Set("q", "look")
body := assert.HTTPBody(makeHandler(searchHandler, false), "GET", "/search/testdata/question/", data)
body := assert.HTTPBody(makeHandler(searchHandler, false, http.MethodGet), "GET", "/search/testdata/question/", data)
assert.Contains(t, body, "We <b>look</b>")
assert.NotContains(t, body, "Odd?")
assert.Contains(t, body, "Even?")

View File

@@ -4,6 +4,7 @@ import (
"bytes"
"github.com/stretchr/testify/assert"
"mime/multipart"
"net/http"
"testing"
)
@@ -18,7 +19,7 @@ Memories of cold
`)}
p.save()
assert.Contains(t,
assert.HTTPBody(makeHandler(viewHandler, false), "GET", "/view/testdata/templates/snow", nil), "Skip")
assert.HTTPBody(makeHandler(viewHandler, false, http.MethodGet), "GET", "/view/testdata/templates/snow", nil), "Skip")
// save a new view handler
html := "<body><h1>{{.Title}}</h1>{{.Html}}"
form := new(bytes.Buffer)
@@ -32,17 +33,17 @@ Memories of cold
assert.NoError(t, err)
assert.Equal(t, len(html), n)
writer.Close()
HTTPUploadLocation(t, makeHandler(dropHandler, false), "/drop/testdata/templates/", writer.FormDataContentType(), form)
HTTPUploadLocation(t, makeHandler(dropHandler, false, http.MethodPost), "/drop/testdata/templates/", writer.FormDataContentType(), form)
assert.FileExists(t, "view.html", "original view.html still exists")
assert.FileExists(t, "testdata/templates/view.html", "new view.html also exists")
assert.Contains(t,
assert.HTTPBody(makeHandler(viewHandler, false), "GET", "/view/testdata/templates/view.html", nil),
assert.HTTPBody(makeHandler(viewHandler, false, http.MethodGet), "GET", "/view/testdata/templates/view.html", nil),
html)
// verify that it works
body := assert.HTTPBody(makeHandler(viewHandler, false), "GET", "/view/testdata/templates/snow", nil)
body := assert.HTTPBody(makeHandler(viewHandler, false, http.MethodGet), "GET", "/view/testdata/templates/snow", nil)
assert.Contains(t, body, "<h1>Snow</h1>")
assert.NotContains(t, body, "Skip")
// verify that the top level still uses the old template
assert.Contains(t,
assert.HTTPBody(makeHandler(viewHandler, false), "GET", "/view/index", nil), "Skip")
assert.HTTPBody(makeHandler(viewHandler, false, http.MethodGet), "GET", "/view/index", nil), "Skip")
}

View File

@@ -1,70 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="format-detection" content="telephone=no">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<title>Manage Files</title>
<style>
html { max-width: 70ch; padding: 2ch; margin: auto; color: #111; background-color: #ffe }
body { hyphens: auto }
form { width: 100% }
hr { border-bottom: 1px }
table { border-collapse: collapse }
th { font-weight: normal }
th:nth-child(3) { max-width: 3ex; overflow: visible }
td form { display: inline }
td { padding-right: 1ch }
td:last-child { padding-right: 0 }
td:first-child { max-width: 20ch; overflow: hidden }
tr:nth-child(odd) { background-color: #eed }
td:first-child, td:last-child { white-space: nowrap }
@media (prefers-color-scheme: dark) {
html { color: #ffe; background-color: #111 }
a:link { color: #1e90ff }
a:hover { color: #63b8ff }
a:visited { color: #7a67ee }
input, button { color: #eeeee8; background-color: #555 }
tr:nth-child(odd) { background-color: #333 }
mark { color: #111; background-color: #ffa; padding: 4px; border-radius: 4px }
}
</style>
</head>
<body lang="en">
<header>
<a href="#main">Skip</a>
<a href="/view/index">Home</a>
<a href="/archive/{{.Dir}}data.zip" accesskey="z">Zip</a>
<a href="/upload/{{.Dir}}?filename=image-1.jpg" accesskey="u">Upload</a>
<form role="search" action="/search/{{.Dir}}" method="GET">
<label for="search">Search:</label>
<input id="search" type="text" spellcheck="false" name="q" accesskey="f" placeholder="term #tag title:term blog:true" required>
<button>Go</button>
</form>
</header>
<main>
<h1>Manage Files</h1>
<form id="manage">
<p><mark>Deletions and renamings take effect immediately and there is no undo!</mark></p>
</form>
<table>
<tr>
<th>Name</th>
<th>Title</th>
<th>Delete</th>
<th>Rename</th>
</tr>{{range .Files}}
<tr>
<td>{{if .IsDir}}<a href="/list/{{$.Dir}}{{.Path}}">{{.Name}}</a>{{else}}<a href="/view/{{$.Dir}}{{.Path}}">{{.Name}}</a>{{end}}</td>
<td>{{.Title}}</td>
<td>{{if .IsUp}}{{else}}<button form="manage" formaction="/delete/{{$.Dir}}{{.Path}}" title="Delete {{.Name}}">🗑</button>{{end}}</td>
<td>{{if .IsUp}}{{else}}
<form action="/rename/{{$.Dir}}{{.Path}}">
<input name="name" placeholder="New name"/>
<button title="Rename {{.Name}}"></button>
</form>{{end}}</td>
</tr>{{end}}
</table>
</main>
</body>
</html>

View File

@@ -110,7 +110,6 @@ window.addEventListener('load', uploadFiles.init);
Picture metadata is only removed if the pictures gets resized.
Providing a new max width is recommended for all pictures.
If youre uploading multiple files, they are all renamed using the filename above and therefore they all get the same extension so they must be of the same type.
<p>You can delete and rename files <a href="/list/{{.Dir}}">from the file list</a>.
<p><label for="file">Files to upload:</label>
<input type="file" name="file" required multiple>
<p><input type="submit" value="Save">

View File

@@ -103,7 +103,6 @@ window.addEventListener('load', uploadFiles.init);
Picture metadata is only removed if the pictures gets resized.
Providing a new max width is recommended for all pictures.
If youre uploading multiple files, they are all renamed using the filename above and therefore they all get the same extension so they must be of the same type.
<p>You can delete and rename files <a href="/list/{{.Dir}}">from the file list</a>.
<p><label for="file">Files to upload:</label>
<input type="file" name="file" required multiple>
<p><input type="submit" value="Save">

View File

@@ -1,59 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="format-detection" content="telephone=no">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<title>Manage Files</title>
<style>
html { max-width: 70ch; padding: 2ch; margin: auto; color: #111; background-color: #def4b5 }
form { width: 100% }
table { border-collapse: collapse }
th:nth-child(3) { max-width: 3ex; overflow: visible }
td form { display: inline }
td { padding-right: 1ch }
td:last-child { padding-right: 0 }
td:first-child { max-width: 30ch; overflow: hidden }
tr:nth-child(odd) { background-color: #cd9 }
td:first-child, td:last-child { white-space: nowrap }
mark { background-color: #ef4; color: #000; padding: 4px; border-radius: 4px }
</style>
</head>
<body lang="en">
<header>
<a href="#main">Skip</a>
<a href="/view/{{.Dir}}index">Home</a>
<a href="/archive/{{.Dir}}data.zip" accesskey="z">Zip</a>
<a href="/upload/{{.Dir}}?filename=image-1.jpg" accesskey="u">Upload</a>
<form role="search" action="/search/{{.Dir}}" method="GET">
<label for="search">Search:</label>
<input id="search" type="text" spellcheck="false" name="q" accesskey="f" placeholder="term #tag title:term blog:true" required>
<button>Go</button>
</form>
</header>
<main>
<h1>Manage Files</h1>
<form id="manage">
<p><mark>Deletions and renamings take effect immediately and there is no undo!</mark></p>
</form>
<table>
<tr>
<th>Name</th>
<th>Title</th>
<th>Delete</th>
<th>Rename</th>
</tr>{{range .Files}}
<tr>
<td>{{if .IsDir}}<a href="/list/{{$.Dir}}{{.Path}}">{{.Name}}</a>{{else}}<a href="/view/{{$.Dir}}{{.Path}}">{{.Name}}</a>{{end}}</td>
<td>{{.Title}}</td>
<td>{{if .IsUp}}{{else}}<button form="manage" formaction="/delete/{{$.Dir}}{{.Path}}" title="Delete {{.Name}}">🗑</button>{{end}}</td>
<td>{{if .IsUp}}{{else}}
<form action="/rename/{{$.Dir}}{{.Path}}">
<input name="name" placeholder="New name"/>
<button title="Rename {{.Name}}"></button>
</form>{{end}}</td>
</tr>{{end}}
</table>
</main>
</body>
</html>

View File

@@ -103,7 +103,6 @@ window.addEventListener('load', uploadFiles.init);
Picture metadata is only removed if the pictures gets resized.
Providing a new max width is recommended for all pictures.
If youre uploading multiple files, they are all renamed using the filename above and therefore they all get the same extension so they must be of the same type.
<p>You can delete and rename files <a href="/list/{{.Dir}}">from the file list</a>.
<p><label for="file">Files to upload:</label>
<input type="file" name="file" required multiple>
<p><input type="submit" value="Save">

View File

@@ -103,7 +103,6 @@ window.addEventListener('load', uploadFiles.init);
Picture metadata is only removed if the pictures gets resized.
Providing a new max width is recommended for all pictures.
If youre uploading multiple files, they are all renamed using the filename above and therefore they all get the same extension so they must be of the same type.
<p>You can delete and rename files <a href="/list/{{.Dir}}">from the file list</a>.
<p><label for="file">Files to upload:</label>
<input type="file" name="file" required multiple>
<p><input type="submit" value="Save">

View File

@@ -1,62 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="format-detection" content="telephone=no">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<title>Manage Files</title>
<style>
html { max-width: 70ch; padding: 1ch; margin: auto; color: #ddd; background-color: #222 }
a { color: #8cf } a:visited { color: #dbf } a:hover { color: #fff }
input, button { color: #222; background-color: #ddd; border: 1px solid #eee }
mark { color: #222; background-color: #ffa; border-radius: 10px; padding: 7px }
body { hyphens: auto }
form { width: 100% }
table { border-collapse: collapse }
th:nth-child(3) { max-width: 3ex; overflow: visible }
td form { display: inline }
td { padding-right: 1ch }
td:last-child { padding-right: 0 }
td:first-child { max-width: 30ch; overflow: hidden }
tr:nth-child(odd) { background-color: #444 }
td:first-child, td:last-child { white-space: nowrap }
</style>
</head>
<body lang="en">
<header>
<a href="#main">Skip</a>
<a href="/view/index">Home</a>
<a href="/archive/{{.Dir}}data.zip" accesskey="z">Zip</a>
<a href="/upload/{{.Dir}}?filename=image-1.jpg" accesskey="u">Upload</a>
<form role="search" action="/search/{{.Dir}}" method="GET">
<label for="search">Search:</label>
<input id="search" type="text" spellcheck="false" name="q" accesskey="f" placeholder="term #tag title:term blog:true" required>
<button>Go</button>
</form>
</header>
<main>
<h1>Manage Files</h1>
<form id="manage">
<p><mark>Deletions and renamings take effect immediately and there is no undo!</mark></p>
</form>
<table>
<tr>
<th>Name</th>
<th>Title</th>
<th>Delete</th>
<th>Rename</th>
</tr>{{range .Files}}
<tr>
<td>{{if .IsDir}}<a href="/list/{{$.Dir}}{{.Path}}">{{.Name}}</a>{{else}}<a href="/view/{{$.Dir}}{{.Path}}">{{.Name}}</a>{{end}}</td>
<td>{{.Title}}</td>
<td>{{if .IsUp}}{{else}}<button form="manage" formaction="/delete/{{$.Dir}}{{.Path}}" title="Delete {{.Name}}">🗑</button>{{end}}</td>
<td>{{if .IsUp}}{{else}}
<form action="/rename/{{$.Dir}}{{.Path}}">
<input name="name" placeholder="New name"/>
<button title="Rename {{.Name}}"></button>
</form>{{end}}</td>
</tr>{{end}}
</table>
</main>
</body>
</html>

View File

@@ -105,7 +105,6 @@ window.addEventListener('load', uploadFiles.init);
Picture metadata is only removed if the pictures gets resized.
Providing a new max width is recommended for all pictures.
If youre uploading multiple files, they are all renamed using the filename above and therefore they all get the same extension so they must be of the same type.
<p>You can delete and rename files <a href="/list/{{.Dir}}">from the file list</a>.
<p><label for="file">Files to upload:</label>
<input type="file" name="file" required multiple>
<p><input type="submit" value="Save">

View File

@@ -103,7 +103,6 @@ window.addEventListener('load', uploadFiles.init);
Picture metadata is only removed if the pictures gets resized.
Providing a new max width is recommended for all pictures.
If youre uploading multiple files, they are all renamed using the filename above and therefore they all get the same extension so they must be of the same type.
<p>You can delete and rename files <a href="/list/{{.Dir}}">from the file list</a>.
<p><label for="file">Files to upload:</label>
<input type="file" name="file" required multiple>
<p><input type="submit" value="Save">

View File

@@ -11,6 +11,7 @@ import (
"image/png"
"mime"
"mime/multipart"
"net/http"
"net/url"
"os"
"path/filepath"
@@ -21,7 +22,7 @@ func TestUpload(t *testing.T) {
cleanup(t, "testdata/files")
// for uploads, the directory is not created automatically
os.MkdirAll("testdata/files", 0755)
assert.HTTPStatusCode(t, makeHandler(uploadHandler, false), "GET", "/upload/testdata/files/", nil, 200)
assert.HTTPStatusCode(t, makeHandler(uploadHandler, false, http.MethodGet), "GET", "/upload/testdata/files/", nil, 200)
form := new(bytes.Buffer)
writer := multipart.NewWriter(form)
field, err := writer.CreateFormField("filename")
@@ -34,10 +35,10 @@ func TestUpload(t *testing.T) {
assert.NoError(t, err)
err = writer.Close()
assert.NoError(t, err)
HTTPUploadAndRedirectTo(t, makeHandler(dropHandler, false), "/drop/testdata/files/",
HTTPUploadAndRedirectTo(t, makeHandler(dropHandler, false, http.MethodPost), "/drop/testdata/files/",
writer.FormDataContentType(), form, "/upload/testdata/files/?filename=ok.txt&uploads=ok.txt")
assert.Contains(t,
assert.HTTPBody(makeHandler(viewHandler, false), "GET", "/view/testdata/files/ok.txt", nil),
assert.HTTPBody(makeHandler(viewHandler, false, http.MethodGet), "GET", "/view/testdata/files/ok.txt", nil),
"Hello!")
}
@@ -53,7 +54,7 @@ func TestUploadPng(t *testing.T) {
img := image.NewRGBA(image.Rect(0, 0, 20, 20))
png.Encode(file, img)
writer.Close()
HTTPUploadAndRedirectTo(t, makeHandler(dropHandler, false), "/drop/testdata/png/",
HTTPUploadAndRedirectTo(t, makeHandler(dropHandler, false, http.MethodPost), "/drop/testdata/png/",
writer.FormDataContentType(), form, "/upload/testdata/png/?filename=ok.png&uploads=ok.png")
}
@@ -69,7 +70,7 @@ func TestUploadJpg(t *testing.T) {
img := image.NewRGBA(image.Rect(0, 0, 20, 20))
jpeg.Encode(file, img, &jpeg.Options{Quality: 90})
writer.Close()
HTTPUploadAndRedirectTo(t, makeHandler(dropHandler, false), "/drop/testdata/jpg/",
HTTPUploadAndRedirectTo(t, makeHandler(dropHandler, false, http.MethodPost), "/drop/testdata/jpg/",
writer.FormDataContentType(), form, "/upload/testdata/jpg/?filename=ok.jpg&uploads=ok.jpg")
}
@@ -97,7 +98,7 @@ YXQAAAApKAGvEyE1mvXho5qH3STtzcWnOxedwNIXAKNDaJNqz3uONoCHeUhi/HA=`
assert.NoError(t, err)
file.Write(img)
writer.Close()
HTTPUploadAndRedirectTo(t, makeHandler(dropHandler, false), "/drop/testdata/heic/",
HTTPUploadAndRedirectTo(t, makeHandler(dropHandler, false, http.MethodPost), "/drop/testdata/heic/",
writer.FormDataContentType(), form, "/upload/testdata/heic/?filename=ok.jpg&uploads=ok.jpg")
fp := "testdata/heic/ok.jpg"
fi, err := os.Open(fp)
@@ -120,7 +121,7 @@ func TestUploadWebp(t *testing.T) {
img := image.NewRGBA(image.Rect(0, 0, 20, 20))
webp.Encode(file, img)
writer.Close()
HTTPUploadAndRedirectTo(t, makeHandler(dropHandler, false), "/drop/testdata/webp/",
HTTPUploadAndRedirectTo(t, makeHandler(dropHandler, false, http.MethodPost), "/drop/testdata/webp/",
writer.FormDataContentType(), form, "/upload/testdata/webp/?filename=ok.jpg&uploads=ok.jpg")
fp := "testdata/webp/ok.jpg"
fi, err := os.Open(fp)
@@ -143,7 +144,7 @@ func TestConvertToWebp(t *testing.T) {
img := image.NewRGBA(image.Rect(0, 0, 20, 20))
png.Encode(file, img)
writer.Close()
HTTPUploadAndRedirectTo(t, makeHandler(dropHandler, false), "/drop/testdata/towebp/",
HTTPUploadAndRedirectTo(t, makeHandler(dropHandler, false, http.MethodPost), "/drop/testdata/towebp/",
writer.FormDataContentType(), form, "/upload/testdata/towebp/?filename=ok.webp&uploads=ok.webp")
fp := "testdata/towebp/ok.webp"
fi, err := os.Open(fp)
@@ -172,7 +173,7 @@ What happened just now?`), 0644))
file, _ := writer.CreateFormFile("file", "test.txt")
file.Write([]byte(""))
writer.Close()
HTTPUploadAndRedirectTo(t, makeHandler(dropHandler, false), "/drop/testdata/delete/",
HTTPUploadAndRedirectTo(t, makeHandler(dropHandler, false, http.MethodPost), "/drop/testdata/delete/",
writer.FormDataContentType(), form, "/upload/testdata/delete/?filename=nothing.txt&uploads=nothing.txt")
// check that it worked
assert.NoFileExists(t, "testdata/delete/nothing.txt")
@@ -188,11 +189,11 @@ But here: jasmin dreams`)}
p.save()
// check location for upload
body := assert.HTTPBody(makeHandler(viewHandler, false), "GET", "/view/testdata/multi/culture", nil)
body := assert.HTTPBody(makeHandler(viewHandler, false, http.MethodGet), "GET", "/view/testdata/multi/culture", nil)
assert.Contains(t, body, `href="/upload/testdata/multi/?filename=culture-1.jpg&pagename=culture"`)
// check location for drop
body = assert.HTTPBody(makeHandler(uploadHandler, false), "GET", "/upload/testdata/multi/", nil)
body = assert.HTTPBody(makeHandler(uploadHandler, false, http.MethodGet), "GET", "/upload/testdata/multi/", nil)
assert.Contains(t, body, `action="/drop/testdata/multi/"`)
// actually do the upload
@@ -208,7 +209,7 @@ But here: jasmin dreams`)}
img := image.NewRGBA(image.Rect(0, 0, 20, 20))
jpeg.Encode(file, img, &jpeg.Options{Quality: 90})
writer.Close()
location := HTTPUploadLocation(t, makeHandler(dropHandler, false), "/drop/testdata/multi/",
location := HTTPUploadLocation(t, makeHandler(dropHandler, false, http.MethodPost), "/drop/testdata/multi/",
writer.FormDataContentType(), form)
url, _ := url.Parse(location)
assert.Equal(t, "/upload/testdata/multi/", url.Path, "Redirect to upload location")
@@ -218,7 +219,7 @@ But here: jasmin dreams`)}
assert.Equal(t, "50", values.Get("quality"))
// check the result page
body = assert.HTTPBody(makeHandler(uploadHandler, false), "GET", url.Path, values)
body = assert.HTTPBody(makeHandler(uploadHandler, false, http.MethodGet), "GET", url.Path, values)
assert.Contains(t, body, `value="2023-10-02-hike-2.jpg"`)
assert.Contains(t, body, `value="15"`)
assert.Contains(t, body, `value="50"`)
@@ -235,11 +236,11 @@ There is no answer`)}
p.save()
// check location for upload
body := assert.HTTPBody(makeHandler(viewHandler, false), "GET", "/view/testdata/dir/test", nil)
body := assert.HTTPBody(makeHandler(viewHandler, false, http.MethodGet), "GET", "/view/testdata/dir/test", nil)
assert.Contains(t, body, `href="/upload/testdata/dir/?filename=test-1.jpg&pagename=test"`)
// check location for drop
body = assert.HTTPBody(makeHandler(uploadHandler, false), "GET", "/upload/testdata/dir/", nil)
body = assert.HTTPBody(makeHandler(uploadHandler, false, http.MethodGet), "GET", "/upload/testdata/dir/", nil)
assert.Contains(t, body, `action="/drop/testdata/dir/"`)
// actually do the upload
@@ -251,7 +252,7 @@ There is no answer`)}
img := image.NewRGBA(image.Rect(0, 0, 20, 20))
jpeg.Encode(file, img, &jpeg.Options{Quality: 90})
writer.Close()
location := HTTPUploadLocation(t, makeHandler(dropHandler, false), "/drop/testdata/dir/",
location := HTTPUploadLocation(t, makeHandler(dropHandler, false, http.MethodPost), "/drop/testdata/dir/",
writer.FormDataContentType(), form)
url, _ := url.Parse(location)
assert.Equal(t, "/upload/testdata/dir/", url.Path, "Redirect to upload location")
@@ -259,7 +260,7 @@ There is no answer`)}
assert.Equal(t, "test.jpg", values.Get("uploads"))
// check the result page
body = assert.HTTPBody(makeHandler(uploadHandler, false), "GET", url.Path, values)
body = assert.HTTPBody(makeHandler(uploadHandler, false, http.MethodGet), "GET", url.Path, values)
assert.Contains(t, body, `src="/view/testdata/dir/test.jpg"`)
}
@@ -277,7 +278,7 @@ func TestUploadTwoInOne(t *testing.T) {
img2 := image.NewRGBA(image.Rect(0, 0, 20, 20))
jpeg.Encode(file2, img2, &jpeg.Options{Quality: 90})
writer.Close()
location := HTTPUploadLocation(t, makeHandler(dropHandler, false), "/drop/testdata/two/",
location := HTTPUploadLocation(t, makeHandler(dropHandler, false, http.MethodPost), "/drop/testdata/two/",
writer.FormDataContentType(), form)
url, _ := url.Parse(location)
assert.Equal(t, "/upload/testdata/two/", url.Path, "Redirect to upload location")
@@ -303,7 +304,7 @@ func TestUploadTwoInOneAgain(t *testing.T) {
img2 := image.NewRGBA(image.Rect(0, 0, 20, 20))
jpeg.Encode(file2, img2, &jpeg.Options{Quality: 90})
writer.Close()
location := HTTPUploadLocation(t, makeHandler(dropHandler, false), "/drop/testdata/zwei/",
location := HTTPUploadLocation(t, makeHandler(dropHandler, false, http.MethodPost), "/drop/testdata/zwei/",
writer.FormDataContentType(), form)
url, _ := url.Parse(location)
assert.Equal(t, "/upload/testdata/zwei/", url.Path, "Redirect to upload location")
@@ -340,10 +341,10 @@ Leute, die ich nie gesehen
Unfassbar, all das`)}
p.save()
// check location for upload on a page name containing an umlaut
body := assert.HTTPBody(makeHandler(viewHandler, false), "GET", "/view/testdata/umlaut/%C3%A4rger", nil)
body := assert.HTTPBody(makeHandler(viewHandler, false, http.MethodGet), "GET", "/view/testdata/umlaut/%C3%A4rger", nil)
assert.Contains(t, body, `href="/upload/testdata/umlaut/?filename=%c3%a4rger-1.jpg&pagename=%c3%a4rger"`) // lower case
// check location for drop in a directory containing an umlaut
body = assert.HTTPBody(makeHandler(uploadHandler, false), "GET", "/upload/%C3%A4rger/dir/", nil)
body = assert.HTTPBody(makeHandler(uploadHandler, false, http.MethodGet), "GET", "/upload/%C3%A4rger/dir/", nil)
assert.Contains(t, body, `action="/drop/%c3%a4rger/dir/"`) // changed to lowercase
// actual upload
form := new(bytes.Buffer)
@@ -358,14 +359,11 @@ Unfassbar, all das`)}
assert.NoError(t, err)
err = writer.Close()
assert.NoError(t, err)
HTTPUploadAndRedirectTo(t, makeHandler(dropHandler, false), "/drop/testdata/umlaut/",
HTTPUploadAndRedirectTo(t, makeHandler(dropHandler, false, http.MethodPost), "/drop/testdata/umlaut/",
writer.FormDataContentType(), form, "/upload/testdata/umlaut/?filename=%C3%A4rger.txt&uploads=%C3%A4rger.txt")
assert.Contains(t,
assert.HTTPBody(makeHandler(viewHandler, false), "GET", "/view/testdata/umlaut/%C3%A4rger.txt", nil),
assert.HTTPBody(makeHandler(viewHandler, false, http.MethodGet), "GET", "/view/testdata/umlaut/%C3%A4rger.txt", nil),
"Hello!")
assert.Contains(t,
assert.HTTPBody(makeHandler(listHandler, false), "GET", "/list/testdata/umlaut/", nil),
"ärger.txt")
}
func TestUploadHash(t *testing.T) {
@@ -377,10 +375,10 @@ Bald and hairy, wearing hats
I wait my number`)}
p.save()
// check location for upload on a page name containing an hash
body := assert.HTTPBody(makeHandler(viewHandler, false), "GET", "/view/testdata/%23hash/%23number", nil)
body := assert.HTTPBody(makeHandler(viewHandler, false, http.MethodGet), "GET", "/view/testdata/%23hash/%23number", nil)
assert.Contains(t, body, `href="/upload/testdata/%23hash/?filename=%23number-1.jpg&pagename=%23number"`)
// check location for drop in a directory containing an hash
body = assert.HTTPBody(makeHandler(uploadHandler, false), "GET", "/upload/%23number/dir/", nil)
body = assert.HTTPBody(makeHandler(uploadHandler, false, http.MethodGet), "GET", "/upload/%23number/dir/", nil)
assert.Contains(t, body, `action="/drop/%23number/dir/"`)
// actual upload
form := new(bytes.Buffer)
@@ -395,13 +393,10 @@ I wait my number`)}
assert.NoError(t, err)
err = writer.Close()
assert.NoError(t, err)
HTTPUploadAndRedirectTo(t, makeHandler(dropHandler, false), "/drop/testdata/%23hash/",
HTTPUploadAndRedirectTo(t, makeHandler(dropHandler, false, http.MethodPost), "/drop/testdata/%23hash/",
writer.FormDataContentType(), form, "/upload/testdata/%23hash/?filename=%23number.txt&uploads=%23number.txt")
assert.Contains(t,
assert.HTTPBody(makeHandler(viewHandler, false), "GET", "/view/testdata/%23hash/%23number.txt", nil),
assert.HTTPBody(makeHandler(viewHandler, false, http.MethodGet), "GET", "/view/testdata/%23hash/%23number.txt", nil),
"Hello!")
assert.Contains(t,
assert.HTTPBody(makeHandler(listHandler, false), "GET", "/list/testdata/%23hash/", nil),
"#number.txt")
assert.FileExists(t, "testdata/#hash/#number.txt")
}

View File

@@ -15,32 +15,32 @@ func TestRootHandler(t *testing.T) {
// relies on index.md in the current directory!
func TestViewHandler(t *testing.T) {
assert.Contains(t,
assert.HTTPBody(makeHandler(viewHandler, false), "GET", "/view/index", nil),
assert.HTTPBody(makeHandler(viewHandler, false, http.MethodGet), "GET", "/view/index", nil),
"Welcome to Oddμ")
}
func TestViewHandlerDir(t *testing.T) {
cleanup(t, "testdata/dir")
HTTPRedirectTo(t, makeHandler(viewHandler, false), "GET", "/view/", nil, "/view/index")
HTTPRedirectTo(t, makeHandler(viewHandler, false), "GET", "/view/testdata", nil, "/view/testdata/index")
HTTPRedirectTo(t, makeHandler(viewHandler, false), "GET", "/view/testdata/", nil, "/view/testdata/index")
HTTPRedirectTo(t, makeHandler(viewHandler, false, http.MethodGet), "GET", "/view/", nil, "/view/index")
HTTPRedirectTo(t, makeHandler(viewHandler, false, http.MethodGet), "GET", "/view/testdata", nil, "/view/testdata/index")
HTTPRedirectTo(t, makeHandler(viewHandler, false, http.MethodGet), "GET", "/view/testdata/", nil, "/view/testdata/index")
assert.NoError(t, os.Mkdir("testdata/dir", 0755))
HTTPRedirectTo(t, makeHandler(viewHandler, false), "GET", "/view/testdata/dir", nil, "/view/testdata/dir/index")
HTTPRedirectTo(t, makeHandler(viewHandler, false), "GET", "/view/testdata/dir/", nil, "/view/testdata/dir/index")
HTTPRedirectTo(t, makeHandler(viewHandler, false, http.MethodGet), "GET", "/view/testdata/dir", nil, "/view/testdata/dir/index")
HTTPRedirectTo(t, makeHandler(viewHandler, false, http.MethodGet), "GET", "/view/testdata/dir/", nil, "/view/testdata/dir/index")
assert.NoError(t, os.Mkdir("testdata/dir/dir", 0755))
HTTPRedirectTo(t, makeHandler(viewHandler, false), "GET", "/view/testdata/dir", nil, "/view/testdata/dir/index")
HTTPRedirectTo(t, makeHandler(viewHandler, false), "GET", "/view/testdata/dir/", nil, "/view/testdata/dir/index")
HTTPRedirectTo(t, makeHandler(viewHandler, false), "GET", "/view/testdata/dir/dir", nil, "/view/testdata/dir/dir/index")
HTTPRedirectTo(t, makeHandler(viewHandler, false), "GET", "/view/testdata/dir/dir/", nil, "/view/testdata/dir/dir/index")
HTTPRedirectTo(t, makeHandler(viewHandler, false, http.MethodGet), "GET", "/view/testdata/dir", nil, "/view/testdata/dir/index")
HTTPRedirectTo(t, makeHandler(viewHandler, false, http.MethodGet), "GET", "/view/testdata/dir/", nil, "/view/testdata/dir/index")
HTTPRedirectTo(t, makeHandler(viewHandler, false, http.MethodGet), "GET", "/view/testdata/dir/dir", nil, "/view/testdata/dir/dir/index")
HTTPRedirectTo(t, makeHandler(viewHandler, false, http.MethodGet), "GET", "/view/testdata/dir/dir/", nil, "/view/testdata/dir/dir/index")
assert.NoError(t, os.WriteFile("testdata/dir/dir.md", []byte(`# Blackbird
The oven hums and
the music plays, coffee smells
blackbirds sing outside
`), 0644))
assert.Contains(t, assert.HTTPBody(makeHandler(viewHandler, false), "GET", "/view/testdata/dir/dir", nil), "<h1>Blackbird</h1>")
assert.Contains(t, assert.HTTPBody(makeHandler(viewHandler, false), "GET", "/view/testdata/dir/dir.md", nil), "# Blackbird")
HTTPRedirectTo(t, makeHandler(viewHandler, false), "GET", "/view/testdata/dir/dir/", nil, "/view/testdata/dir/dir/index")
assert.Contains(t, assert.HTTPBody(makeHandler(viewHandler, false, http.MethodGet), "GET", "/view/testdata/dir/dir", nil), "<h1>Blackbird</h1>")
assert.Contains(t, assert.HTTPBody(makeHandler(viewHandler, false, http.MethodGet), "GET", "/view/testdata/dir/dir.md", nil), "# Blackbird")
HTTPRedirectTo(t, makeHandler(viewHandler, false, http.MethodGet), "GET", "/view/testdata/dir/dir/", nil, "/view/testdata/dir/dir/index")
}
// relies on index.md in the current directory!
@@ -48,7 +48,7 @@ func TestViewHandlerWithId(t *testing.T) {
data := make(url.Values)
data.Set("id", "index")
assert.Contains(t,
assert.HTTPBody(makeHandler(viewHandler, false), "GET", "/view/", data),
assert.HTTPBody(makeHandler(viewHandler, false, http.MethodGet), "GET", "/view/", data),
"Welcome to Oddμ")
}
@@ -59,14 +59,14 @@ func TestPageTitleWithAmp(t *testing.T) {
p.save()
assert.Contains(t,
assert.HTTPBody(makeHandler(viewHandler, false), "GET", "/view/testdata/amp/Rock%20%26%20Roll", nil),
assert.HTTPBody(makeHandler(viewHandler, false, http.MethodGet), "GET", "/view/testdata/amp/Rock%20%26%20Roll", nil),
"Rock &amp; Roll")
p = &Page{Name: "testdata/amp/Rock & Roll", Body: []byte("# Sex & Drugs & Rock'n'Roll\nOh no!")}
p.save()
assert.Contains(t,
assert.HTTPBody(makeHandler(viewHandler, false), "GET", "/view/testdata/amp/Rock%20%26%20Roll", nil),
assert.HTTPBody(makeHandler(viewHandler, false, http.MethodGet), "GET", "/view/testdata/amp/Rock%20%26%20Roll", nil),
"Sex &amp; Drugs")
}
@@ -76,7 +76,7 @@ func TestPageTitleWithQuestionMark(t *testing.T) {
p := &Page{Name: "testdata/q/How about no?", Body: []byte("No means no")}
p.save()
body := assert.HTTPBody(makeHandler(viewHandler, false), "GET", "/view/testdata/q/How%20about%20no%3F", nil)
body := assert.HTTPBody(makeHandler(viewHandler, false, http.MethodGet), "GET", "/view/testdata/q/How%20about%20no%3F", nil)
assert.Contains(t, body, "No means no")
assert.Contains(t, body, "<a href=\"/edit/testdata/q/How%20about%20no%3F\" accesskey=\"e\">Edit</a>")
}
@@ -91,23 +91,23 @@ In the autumn chill
`), 0644))
fi, err := os.Stat("testdata/file-mod/now.txt")
assert.NoError(t, err)
h := makeHandler(viewHandler, false)
h := makeHandler(viewHandler, false, http.MethodGet)
assert.Equal(t, []string{fi.ModTime().UTC().Format(http.TimeFormat)},
HTTPHeaders(h, "GET", "/view/testdata/file-mod/now.txt", nil, "Last-Modified"))
HTTPStatusCodeIfModifiedSince(t, h, "/view/testdata/file-mod/now.txt", fi.ModTime())
}
func TestForbidden(t *testing.T) {
assert.HTTPStatusCode(t, makeHandler(viewHandler, false), "GET", "/view/", nil, http.StatusFound)
assert.HTTPStatusCode(t, makeHandler(viewHandler, false), "GET", "/view/.", nil, http.StatusForbidden)
assert.HTTPStatusCode(t, makeHandler(viewHandler, false), "GET", "/view/.htaccess", nil, http.StatusForbidden)
assert.HTTPStatusCode(t, makeHandler(viewHandler, false), "GET", "/view/.git/description", nil, http.StatusForbidden)
assert.HTTPStatusCode(t, makeHandler(viewHandler, false), "GET", "/view/../oddmu", nil, http.StatusForbidden)
assert.HTTPStatusCode(t, makeHandler(viewHandler, false, http.MethodGet), "GET", "/view/", nil, http.StatusFound)
assert.HTTPStatusCode(t, makeHandler(viewHandler, false, http.MethodGet), "GET", "/view/.", nil, http.StatusForbidden)
assert.HTTPStatusCode(t, makeHandler(viewHandler, false, http.MethodGet), "GET", "/view/.htaccess", nil, http.StatusForbidden)
assert.HTTPStatusCode(t, makeHandler(viewHandler, false, http.MethodGet), "GET", "/view/.git/description", nil, http.StatusForbidden)
assert.HTTPStatusCode(t, makeHandler(viewHandler, false, http.MethodGet), "GET", "/view/../oddmu", nil, http.StatusForbidden)
data := make(url.Values)
data.Set("id", "..")
assert.HTTPStatusCode(t, makeHandler(viewHandler, false), "GET", "/view/", data, http.StatusForbidden)
assert.HTTPStatusCode(t, makeHandler(viewHandler, false, http.MethodGet), "GET", "/view/", data, http.StatusForbidden)
data.Set("id", "foo/bar")
assert.HTTPStatusCode(t, makeHandler(viewHandler, false), "GET", "/view/", data, http.StatusBadRequest)
assert.HTTPStatusCode(t, makeHandler(viewHandler, false, http.MethodGet), "GET", "/view/", data, http.StatusBadRequest)
}
func TestPageLastModified(t *testing.T) {
@@ -120,7 +120,7 @@ I like spring better
p.save()
fi, err := os.Stat("testdata/page-mod/now.md")
assert.NoError(t, err)
h := makeHandler(viewHandler, false)
h := makeHandler(viewHandler, false, http.MethodGet)
assert.Equal(t, []string{fi.ModTime().UTC().Format(http.TimeFormat)},
HTTPHeaders(h, "GET", "/view/testdata/page-mod/now", nil, "Last-Modified"))
HTTPStatusCodeIfModifiedSince(t, h, "/view/testdata/page-mod/now", fi.ModTime())
@@ -136,7 +136,7 @@ Just me and the birds.
p.save()
fi, err := os.Stat("testdata/head/peace.md")
assert.NoError(t, err)
h := makeHandler(viewHandler, false)
h := makeHandler(viewHandler, false, http.MethodGet, http.MethodHead)
assert.Equal(t, []string(nil),
HTTPHeaders(h, "HEAD", "/view/testdata/head/war", nil, "Last-Modified"))
assert.Equal(t, []string(nil),
@@ -149,13 +149,13 @@ Just me and the birds.
func TestViewUmlaut(t *testing.T) {
assert.Contains(t,
assert.HTTPBody(makeHandler(viewHandler, false), "GET", "/view/%C3%A4rger", nil),
assert.HTTPBody(makeHandler(viewHandler, false, http.MethodGet), "GET", "/view/%C3%A4rger", nil),
`<a href="/edit/%C3%A4rger">`)
}
func TestMimeType(t *testing.T) {
assert.Equal(t, []string{"text/markdown; charset=utf-8"},
HTTPHeaders(makeHandler(viewHandler, false), "GET", "/view/index.md", nil, "Content-Type"))
HTTPHeaders(makeHandler(viewHandler, false, http.MethodGet), "GET", "/view/index.md", nil, "Content-Type"))
assert.Equal(t, []string{"text/html; charset=utf-8"},
HTTPHeaders(makeHandler(viewHandler, false), "GET", "/view/view.html", nil, "Content-Type"))
HTTPHeaders(makeHandler(viewHandler, false, http.MethodGet), "GET", "/view/view.html", nil, "Content-Type"))
}

View File

@@ -2,6 +2,7 @@ package main
import (
"github.com/stretchr/testify/assert"
"net/http"
"os"
"testing"
"time"
@@ -62,7 +63,7 @@ the smell is everywhere
`)}
p.save()
assert.Contains(t,
assert.HTTPBody(makeHandler(viewHandler, false), "GET", "/view/testdata/watched-template/raclette", nil), "Skip")
assert.HTTPBody(makeHandler(viewHandler, false, http.MethodGet), "GET", "/view/testdata/watched-template/raclette", nil), "Skip")
// save a new view handler directly
assert.NoError(t,
@@ -82,7 +83,7 @@ the smell is everywhere
watches.watchTimer(path)
body := assert.HTTPBody(makeHandler(viewHandler, false), "GET", "/view/"+name, nil)
body := assert.HTTPBody(makeHandler(viewHandler, false, http.MethodGet), "GET", "/view/"+name, nil)
assert.Contains(t, body, "<h1>Raclette</h1>") // page text is still there
assert.NotContains(t, body, "Skip") // but the header is not
}

36
wiki.go
View File

@@ -45,8 +45,17 @@ func isHiddenName (name string) bool {
// handle itself is called with the remaining URL path fragment. Any path segment beginning with a period is rejected
// because it's considered to be a hidden file or directory. This also takes care of path traversal since ".." is
// treated the same.
func makeHandler(fn func(http.ResponseWriter, *http.Request, string), required bool) http.HandlerFunc {
func makeHandler(fn func(http.ResponseWriter, *http.Request, string), required bool, methods ...string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
validMethod := false
for i := range methods {
if r.Method == methods[i] {
validMethod = true
}
}
if !validMethod {
http.Error(w, fmt.Sprintf("bad request method %s in %v", r.Method, methods), http.StatusMethodNotAllowed)
}
if isHiddenName(r.URL.Path) {
http.Error(w, "can neither confirm nor deny the existence of this resource", http.StatusForbidden)
return
@@ -187,20 +196,17 @@ func serve() {
go scheduleInstallWatcher()
mux := http.NewServeMux()
mux.HandleFunc("/", rootHandler)
mux.HandleFunc("/archive/", makeHandler(archiveHandler, true))
mux.HandleFunc("/view/", makeHandler(viewHandler, false))
mux.HandleFunc("/preview/", makeHandler(previewHandler, false))
mux.HandleFunc("/diff/", makeHandler(diffHandler, true))
mux.HandleFunc("/edit/", makeHandler(editHandler, true))
mux.HandleFunc("/save/", makeHandler(saveHandler, true))
mux.HandleFunc("/add/", makeHandler(addHandler, true))
mux.HandleFunc("/append/", makeHandler(appendHandler, true))
mux.HandleFunc("/upload/", makeHandler(uploadHandler, false))
mux.HandleFunc("/drop/", makeHandler(dropHandler, false))
mux.HandleFunc("/list/", makeHandler(listHandler, false))
mux.HandleFunc("/delete/", makeHandler(deleteHandler, true))
mux.HandleFunc("/rename/", makeHandler(renameHandler, true))
mux.HandleFunc("/search/", makeHandler(searchHandler, false))
mux.HandleFunc("/archive/", makeHandler(archiveHandler, true, http.MethodGet))
mux.HandleFunc("/view/", makeHandler(viewHandler, false, http.MethodGet, http.MethodHead))
mux.HandleFunc("/preview/", makeHandler(previewHandler, false, http.MethodGet, http.MethodPost))
mux.HandleFunc("/diff/", makeHandler(diffHandler, true, http.MethodGet))
mux.HandleFunc("/edit/", makeHandler(editHandler, true, http.MethodGet))
mux.HandleFunc("/save/", makeHandler(saveHandler, true, http.MethodPost))
mux.HandleFunc("/add/", makeHandler(addHandler, true, http.MethodGet))
mux.HandleFunc("/append/", makeHandler(appendHandler, true, http.MethodPost))
mux.HandleFunc("/upload/", makeHandler(uploadHandler, false, http.MethodGet))
mux.HandleFunc("/drop/", makeHandler(dropHandler, false, http.MethodPost))
mux.HandleFunc("/search/", makeHandler(searchHandler, false, http.MethodGet, http.MethodPost))
srv := &http.Server{
ReadTimeout: 2 * time.Minute,
WriteTimeout: 5 * time.Minute,