forked from mirror/oddmu
Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4eb013a4da | ||
|
|
e8f6ae0450 | ||
|
|
9bf3beb440 | ||
|
|
cd6809d791 | ||
|
|
7c5a3860e7 | ||
|
|
a7c343decb | ||
|
|
18bb5da8c0 | ||
|
|
2a0ea791ec | ||
|
|
726586b39d | ||
|
|
8f30704be9 | ||
|
|
616ae0a1ba | ||
|
|
af86b865bf |
2
Makefile
2
Makefile
@@ -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
|
||||
|
||||
51
README.md
51
README.md
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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{}
|
||||
|
||||
83
changes.go
83
changes.go
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
|
||||
@@ -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>`)
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
`
|
||||
}
|
||||
|
||||
|
||||
@@ -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>")
|
||||
|
||||
149
hashtags_cmd.go
149
hashtags_cmd.go
@@ -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
121
list.go
@@ -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)
|
||||
}
|
||||
59
list.html
59
list.html
@@ -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>
|
||||
76
list_test.go
76
list_test.go
@@ -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">`)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
```
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:;
|
||||
}
|
||||
```
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>.\&
|
||||
|
||||
@@ -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>.
|
||||
|
||||
10
man/oddmu.1
10
man/oddmu.1
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>!")
|
||||
}
|
||||
|
||||
@@ -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?")
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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 you’re 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">
|
||||
|
||||
@@ -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 you’re 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">
|
||||
|
||||
@@ -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>
|
||||
@@ -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 you’re 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">
|
||||
|
||||
@@ -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 you’re 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">
|
||||
|
||||
@@ -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>
|
||||
@@ -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 you’re 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">
|
||||
|
||||
@@ -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 you’re 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">
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
60
view_test.go
60
view_test.go
@@ -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 & 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 & 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"))
|
||||
}
|
||||
|
||||
@@ -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
36
wiki.go
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user