1 Commits

Author SHA1 Message Date
Alex Schroeder
1b9c1babc3 Switch to dark CSS 2024-01-19 01:15:08 +01:00
80 changed files with 894 additions and 3360 deletions

View File

@@ -1,7 +1,4 @@
SHELL=/bin/bash
PREFIX=${HOME}/.local
.PHONY: help build test run upload docs install missing
help:
@echo Help for Oddmu
@@ -11,12 +8,12 @@ help:
@echo " runs program, offline"
@echo
@echo make test
@echo " runs the tests without log output"
@echo " runs the tests"
@echo
@echo make docs
@echo " create man pages from text files"
@echo
@echo make build
@echo go build
@echo " just build it"
@echo
@echo make install
@@ -25,25 +22,26 @@ help:
@echo make upload
@echo " this is how I upgrade my server"
build: oddmu
oddmu: *.go
go build
test:
go test -shuffle on .
run:
go run .
upload: build
test:
go test
upload:
go build
rsync --itemize-changes --archive oddmu sibirocobombus.root:/home/oddmu/
ssh sibirocobombus.root "systemctl restart oddmu; systemctl restart alex; systemctl restart claudia; systemctl restart campaignwiki"
ssh sibirocobombus.root "systemctl restart oddmu; systemctl restart alex; systemctl restart claudia"
@echo Changes to the template files need careful consideration
docs:
cd man; make
install: build docs
for n in 1 5 7; do install -D -t ${PREFIX}/share/man/man$$n man/*.$$n; done
install -D -t ${PREFIX}/.local/bin oddmu
install:
make docs
for n in 1 5 7; do install -D -t $$HOME/.local/share/man/man$$n man/*.$$n; done
go build
install -D -t $$HOME/.local/bin oddmu
missing:
for f in man/*.txt; do grep --quiet "$$f" README.md || echo $$f is not in the README; done

164
README.md
View File

@@ -48,18 +48,6 @@ 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)](/oddmu.git/blob/main/man/oddmu-releases.7.txt):
This man page lists all the Oddmu versions and their user-visible
changes.
[oddmu-releases(7)](/oddmu.git/blob/main/man/oddmu-releases.7.txt):
This man page lists all the Oddmu versions and their user-visible
changes.
[oddmu-version(1)](/oddmu.git/blob/main/man/oddmu-version.1.txt): This
man page documents the "version" subcommand which you can use to get
installed Oddmu version.
[oddmu-list(1)](/oddmu.git/blob/main/man/oddmu-list.1.txt): This man
page documents the "list" subcommand which you can use to get page
names and page titles.
@@ -71,10 +59,6 @@ indexes lists of page links. These are important for feeds.
[oddmu-search(7)](/oddmu.git/blob/main/man/oddmu-search.7.txt): This
man page documents how search and scoring work.
[oddmu-filter(7)](/oddmu.git/blob/main/man/oddmu-filter.7.txt): This
man page documents how to exclude subdirectories from search and
archiving.
[oddmu-replace(1)](/oddmu.git/blob/main/man/oddmu-replace.1.txt): This
man page documents the "replace" subcommand to make mass changes to
the files much like find(1), grep(1) and sed(1) or perl(1).
@@ -103,12 +87,8 @@ This man page documents how the templates can be changed (how they
templates.
[oddmu-apache(5)](/oddmu.git/blob/main/man/oddmu-apache.5.txt): This
man page documents how to set up the Apache web server for various
common tasks such as using logins to limit what visitors can edit.
[oddmu-nginx(5)](/oddmu.git/blob/main/man/oddmu-nginx.5.txt): This man
page documents how to set up the freenginx web server for various
common tasks such as using logins to limit what visitors can edit.
man page documents how to set up the web server for various common
tasks such as using logins to limit what visitors can edit.
[oddmu.service(5)](/oddmu.git/blob/main/man/oddmu.service.5.txt): This
man page documents how to setup a systemd unit and have it manage
@@ -116,28 +96,15 @@ Oddmu. “Great configurability brings great burdens.”
## Building
To build the binary:
```sh
go build
```
The man pages are already built. If you want to rebuild them, you need
to have [scdoc](https://git.sr.ht/~sircmpwn/scdoc) installed.
```sh
make docs
```
The `Makefile` in the `man` directory has targets to create Markdown
and HTML files.
## Running
The working directory is where pages are saved and where templates are
loaded from. You need a copy of the template files in this directory.
Here's how to build and run straight from the source directory:
Here's how to start it in the source directory:
```sh
go run .
@@ -146,119 +113,38 @@ go run .
The program serves the local directory as a wiki on port 8080. Point
your browser to http://localhost:8080/ to use it.
Once the `oddmu` binary is built, you can run it instead:
```sh
./oddmu
```
To read the main man page witihout installing Oddmu:
```sh
man -l man/oddmu.1
```
## Installing
This installs `oddmu` into `$HOME/.local/bin` and the manual pages
into `$HOME/.local/share/man/`.
```sh
make install
```
To install it elsewhere, here's an example using [GNU
Stow](https://www.gnu.org/software/stow/) to install it into
`/usr/local/stow` in a way that allows you to uninstall it later:
```sh
sudo mkdir /usr/local/stow/oddmu
sudo make install PREFIX=/usr/local/stow/oddmu/
cd /usr/local/stow
sudo stow oddmu
```
## Bugs
If you spot any, [contact](https://alexschroeder.ch/wiki/Contact) me.
## Hacking
## Source
If you're interested in making changes to the code, here's a
high-level introduction to the various source files.
- `*_test.go` are the test files; a few library functions are defined
in `wiki_test.go`.
- `*_cmd.go` are the files implementing the various subcommands with
- *_test.go are the test files; a few library functions are defined in
wiki_test.go.
- *_cmd.go are the files implementing the various subcommands with
matching names
- `accounts.go` implements the webfinger code to fetch fediverse
account link destinations with the URI provided by webfinger
- `add_append.go` implements the `/add` and `/append` handlers
- `archive.go` implements the `/archive` handler
- `changes.go` implements the "notifications": the automatic addition
of links to index, changes and hashtag files when pages are edited
- `diff.go` implements the `/diff` handler
- `edit_save.go` implements the `/edit` and `/save` handlers
- `feed.go` implements the feed for a page based on the links it lists
- `highlight.go` implements the bold tags for matches when showing
- accounts.go implements the webfinger code to fetch fediverse account
link destinations with the URI provided by webfinger
- add_append.go implements the /add and /append handlers
- diff.go implements the /diff handler
- edit_save.go implements the /edit and /save handlers
- feed.go implements the feed for a page based on the links it lists
- highlight.go implements the bold tags for matches when showing
search results
- `index.go` implements the index of all the hashtags
- `languages.go` implements the language detection
- `page.go` implements the page loading and saving
- `parser.go` implements the Markdown parsing
- `score.go` implements the page scoring when showing search results
- `search.go` implements the `/search` handler
- `snippets.go` implements the page summaries for search results
- `templates.go` implements template loading and reloading
- `tokenizer.go` implements the various tokenizers used
- `upload_drop.go` implements the `/upload` and `/drop` handlers
- `view.go` implements the `/view` handler
- `watch.go` implements the filesystem notification watch
- `wiki.go` implements the main function
### Changing the markup rules
If you want to change the markup rules, your starting point should be
`parser.go`. Make sure you read the documentation of [Go
Markdown](https://github.com/gomarkdown/markdown) and note that it
offers MathJax support (needs a change to the `view.html` template so
that the MathJax Javascript gets loaded) and
[MMark](https://mmark.miek.nl/post/syntax/) support, and it shows how
extensions can be added.
### Filenames and URL path
One of the sad parts of the code is the distinction between path and
filepath. On a Linux system, this doesn't matter. I suspect that it
also doesn't matter on MacOS and Windows because the file systems
handle forward slashes just fine. The code still tries to do the right
thing. A path that is derived from a URL is a path with slashes.
Before accessing a file, it has to be turned into a filepath using
`filepath.FromSlashes` and in the rare case where the inverse happens,
use `filepath.ToSlashes`. Any path received via the URL path uses
slashes and needs to be converted to a filepath before passing it to
any `os` function. Any path received within a `path/filepath.WalkFunc`
is a filepath and needs to be converted to use slashes when used in
HTML output.
If you need to access the page name in code that is used from a
template, you have to decode the path. See the code in `diff.go` for
an example.
### HTTP handlers
The URL paths all have the form `/action/directory/pagename` (with
directory being optional and pagename sometimes being optional). If
you need to limit access in Apache or nginx or some other web server
acting as a [reverse
proxy](https://en.wikipedia.org/wiki/Reverse_proxy), you can do that.
See `man oddmu-apache` and `man oddmu-nginx` for some configuration
examples.
This is how you can prevent some actions by simply not passing them on
to Oddmu, or you can require authentication for certain actions.
Furthermore, you can do the same for directories, allowing you to use
subdirectories as separate sites, each with their own editors.
- index.go implements the index of all the hashtags
- languages.go implements the language detection
- page.go implements the page loading and saving
- parser.go implements the Markdown parsing
- score.go implements the page scoring when showing search results
- search.go implements the /search handler
- snippets.go implements the page summaries for search results
- tokenizer.go implements the various tokenizers used
- upload_drop.go implements the /upload and /drop handlers
- view.go implements the /view handler
- wiki.go implements the main function
## References

14
RELEASE
View File

@@ -1,14 +0,0 @@
When preparing a new release
----------------------------
1. Run tests
2. Update man/oddmu-releases.7.txt
3. make docs
4. Make sure all files are checked in
5. Tag the release and push the tag to all remotes
6. cd man && make upload

View File

@@ -11,36 +11,43 @@ import (
"sync"
)
// useWebfinger indicates whether Oddmu looks up the profile pages of fediverse accounts. To enable this, set the
// environment variable ODDMU_WEBFINGER to "1".
// useWebfinger indicates whether Oddmu looks up the profile pages of
// fediverse accounts. To enable this, set the environment variable
// ODDMU_WEBFINGER to "1".
var useWebfinger = false
// accountStore controlls access to the usernames. Make sure to lock and unlock as appropriate.
type accountStore struct {
// Accounts contains the map used to set the usernames. Make sure to
// lock and unlock as appropriate.
type Accounts struct {
sync.RWMutex
// uris is a map, mapping account names likes "@alex@alexschroeder.ch" to URIs like
// uris is a map, mapping account names likes
// "@alex@alexschroeder.ch" to URIs like
// "https://social.alexschroeder.ch/@alex".
uris map[string]string
}
// accounts holds the global mapping of accounts to profile URIs.
var accounts accountStore
var accounts Accounts
// This is called once at startup and therefore does not need to be locked. On every restart, this map starts empty and
// is slowly repopulated as pages are visited.
func init() {
// initAccounts sets up the accounts map. This is called once at
// startup and therefore does not need to be locked. On ever restart,
// this map starts empty and is slowly repopulated as pages are
// visited.
func initAccounts() {
if os.Getenv("ODDMU_WEBFINGER") == "1" {
accounts.uris = make(map[string]string)
useWebfinger = true
}
}
// accountLink links a social media accountLink like @accountLink@domain to a profile page like https://domain/user/accountLink. Any
// accountLink seen for the first time uses a best guess profile URI. It is also looked up using webfinger, in parallel. See
// lookUpAccountUri. If the lookup succeeds, the best guess is replaced with the new URI so on subsequent requests, the
// URI is correct.
func accountLink(p *parser.Parser, data []byte, offset int) (int, ast.Node) {
// account links a social media account like @account@domain to a
// profile page like https://domain/user/account. Any account seen for
// the first time uses a best guess profile URI. It is also looked up
// using webfinger, in parallel. See lookUpAccountUri. If the lookup
// succeeds, the best guess is replaced with the new URI so on
// subsequent requests, the URI is correct.
func account(p *parser.Parser, data []byte, offset int) (int, ast.Node) {
data = data[offset:]
i := 1 // skip @ of username
n := len(data)
@@ -90,8 +97,10 @@ func accountLink(p *parser.Parser, data []byte, offset int) (int, ast.Node) {
return i, link
}
// lookUpAccountUri is called for accounts that haven't been seen before. It calls webfinger and parses the JSON. If
// possible, it extracts the link to the profile page and replaces the entry in accounts.
// lookUpAccountUri is called for accounts that haven't been seen
// before. It calls webfinger and parses the JSON. If possible, it
// extracts the link to the profile page and replaces the entry in
// accounts.
func lookUpAccountUri(account, domain string) {
uri := "https://" + domain + "/.well-known/webfinger"
resp, err := http.Get(uri + "?resource=acct:" + account)
@@ -105,7 +114,7 @@ func lookUpAccountUri(account, domain string) {
log.Printf("Failed to read from %s: %s", account, err)
return
}
var wf webFinger
var wf WebFinger
err = json.Unmarshal([]byte(body), &wf)
if err != nil {
log.Printf("Failed to parse the JSON from %s: %s", account, err)
@@ -121,24 +130,25 @@ func lookUpAccountUri(account, domain string) {
accounts.uris[account] = uri
}
// link a link in the WebFinger JSON.
type link struct {
// Link a link in the WebFinger JSON.
type Link struct {
Rel string `json:"rel"`
Type string `json:"type"`
Href string `json:"href"`
}
// webFinger is a structure used to unmarshall JSON.
type webFinger struct {
// WebFinger is a structure used to unmarshall JSON.
type WebFinger struct {
Subject string `json:"subject"`
Aliases []string `json:"aliases"`
Links []link `json:"links"`
Links []Link `json:"links"`
}
// parseWebFinger parses the web finger JSON and returns the profile page URI. For unmarshalling the JSON, it uses the
// Link and WebFinger structs.
// parseWebFinger parses the web finger JSON and returns the profile
// page URI. For unmarshalling the JSON, it uses the Link and
// WebFinger structs.
func parseWebFinger(body []byte) (string, error) {
var wf webFinger
var wf WebFinger
err := json.Unmarshal(body, &wf)
if err != nil {
return "", err

View File

@@ -5,6 +5,16 @@ import (
"testing"
)
// This causes network access!
// func TestPageAccount(t *testing.T) {
// initAccounts()
// p := &Page{Body: []byte(`@alex, @alex@alexschroeder.ch said`)}
// p.renderHtml()
// r := `<p>@alex, <a href="https://alexschroeder.ch/users/alex">@alex</a> said</p>
// `
// assert.Equal(t, r, string(p.Html))
// }
func TestWebfingerParsing(t *testing.T) {
body := []byte(`{
"subject": "acct:Gargron@mastodon.social",

View File

@@ -6,7 +6,9 @@
<meta name="viewport" content="width=device-width">
<title>Add to {{.Title}}</title>
<style>
html { max-width: 70ch; padding: 2ch; margin: auto; color: #111; background-color: #ffe; }
html { max-width: 70ch; padding: 2ch; margin: auto; color: #ddd; background-color: #222; }
a { color: #8cf } a:visited { color: #dbf } a:hover { color: #fff }
textarea, input, button { color: #222; background-color: #ddd; border: 1px solid #eee; }
form, textarea { width: 100%; }
</style>
</head>

View File

@@ -16,11 +16,11 @@ func addHandler(w http.ResponseWriter, r *http.Request, name string) {
} else {
p.handleTitle(false)
}
renderTemplate(w, p.Dir(), "add", p)
renderTemplate(w, "add", p)
}
// appendHandler takes the "body" form parameter and appends it. The browser is redirected to the page view. This is
// similar to the saveHandler.
// appendHandler takes the "body" form parameter and appends it. The
// browser is redirected to the page view.
func appendHandler(w http.ResponseWriter, r *http.Request, name string) {
body := r.FormValue("body")
p, err := loadPage(name)
@@ -35,17 +35,10 @@ func appendHandler(w http.ResponseWriter, r *http.Request, name string) {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
username, _, ok := r.BasicAuth()
if ok {
log.Println("Save", name, "by", username)
} else {
log.Println("Save", name)
}
if r.FormValue("notify") == "on" {
err = p.notify()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
log.Println("notify:", err)
}
}
http.Redirect(w, r, "/view/"+name, http.StatusFound)

View File

@@ -24,6 +24,7 @@ Into the oven`)
func TestAddAppend(t *testing.T) {
cleanup(t, "testdata/add")
index.load()
p := &Page{Name: "testdata/add/fire", Body: []byte(`# Fire
Orange sky above
Reflects a distant fire

View File

@@ -1,69 +0,0 @@
package main
import (
"archive/zip"
"io"
"io/fs"
"log"
"net/http"
"os"
"path/filepath"
"regexp"
"strings"
)
// archiveHandler serves a zip file. Directories starting with a period are skipped. Filenames starting with a period
// are skipped. If the environment variable ODDMU_FILTER is a regular expression that matches the starting directory,
// this is a "separate site"; if the regular expression does not match, this is the "main site" and page names must also
// not match the regular expression.
func archiveHandler(w http.ResponseWriter, r *http.Request, path string) {
filter := os.Getenv("ODDMU_FILTER")
re, err := regexp.Compile(filter)
if err != nil {
log.Println("ODDMU_FILTER does not compile:", filter, err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
matches := re.MatchString(path)
dir := filepath.Dir(filepath.FromSlash(path))
z := zip.NewWriter(w)
err = filepath.Walk(dir, func(path string, info fs.FileInfo, err error) error {
if err != nil {
return err
}
if info.IsDir() {
if path != "." && strings.HasPrefix(filepath.Base(path), ".") {
return filepath.SkipDir
}
} else if !strings.HasPrefix(filepath.Base(path), ".") &&
(matches || !re.MatchString(path)) {
zf, err := z.Create(path)
if err != nil {
log.Println(err)
return err
}
file, err := os.Open(path)
if err != nil {
log.Println(err)
return err
}
_, err = io.Copy(zf, file)
if err != nil {
log.Println(err)
return err
}
}
return nil
})
if err != nil {
log.Println(err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
err = z.Close()
if err != nil {
log.Println(err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
}

View File

@@ -1,27 +0,0 @@
package main
import (
"archive/zip"
"github.com/stretchr/testify/assert"
"os"
"strings"
"testing"
)
func TestArchive(t *testing.T) {
cleanup(t, "testdata/archive")
assert.NoError(t, os.MkdirAll("testdata/archive/public", 0755))
assert.NoError(t, os.MkdirAll("testdata/archive/secret", 0755))
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)
r, err := zip.NewReader(strings.NewReader(body), int64(len(body)))
assert.NoError(t, err, "Unzip")
names := []string{}
for _, file := range r.File {
names = append(names, file.Name)
}
assert.Contains(t, names, "testdata/archive/public/index.md")
assert.NotContains(t, names, "testdata/archive/secret/index.md")
}

View File

@@ -2,6 +2,7 @@ package main
import (
"log"
"os"
"path"
"regexp"
"strings"
@@ -13,8 +14,7 @@ import (
// hashtag pages is only added for blog pages. If the "changes" page does not exist, it is created. If the hashtag page
// does not exist, it is not. Hashtag pages are considered optional. If the page that's being edited is in a
// subdirectory, then the "changes", "index" and hashtag pages of that particular subdirectory are affected. Every
// subdirectory is treated like a potentially independent wiki. Errors are logged before being returned because the
// error messages are confusing from the point of view of the saveHandler.
// subdirectory is treated like a potentially independent wiki.
func (p *Page) notify() error {
p.handleTitle(false)
if p.Title == "" {
@@ -24,9 +24,15 @@ func (p *Page) notify() error {
link := "* [" + p.Title + "](" + esc + ")\n"
re := regexp.MustCompile(`(?m)^\* \[[^\]]+\]\(` + esc + `\)\n`)
dir := path.Dir(p.Name)
if dir != "." {
err := os.MkdirAll(dir, 0755)
if err != nil {
log.Printf("Creating directory %s failed: %s", dir, err)
return err
}
}
err := addLinkWithDate(path.Join(dir, "changes"), link, re)
if err != nil {
log.Printf("Updating changes in %s failed: %s", dir, err)
return err
}
if p.isBlog() {
@@ -56,40 +62,40 @@ func (p *Page) notify() error {
func addLinkWithDate(name, link string, re *regexp.Regexp) error {
date := time.Now().Format(time.DateOnly)
org := ""
p, err := loadPage(name)
c, err := loadPage(name)
if err != nil {
// create a new page
p = &Page{Name: name, Body: []byte("# Changes\n\n## " + date + "\n" + link)}
c = &Page{Name: name, Body: []byte("# Changes\n\n## " + date + "\n" + link)}
} else {
org = string(p.Body)
org = string(c.Body)
// remove the old match, if one exists
loc := re.FindIndex(p.Body)
loc := re.FindIndex(c.Body)
if loc != nil {
r := p.Body[:loc[0]]
if loc[1] < len(p.Body) {
r = append(r, p.Body[loc[1]:]...)
r := c.Body[:loc[0]]
if loc[1] < len(c.Body) {
r = append(r, c.Body[loc[1]:]...)
}
p.Body = r
if loc[0] >= 14 && len(p.Body) >= loc[0]+15 {
c.Body = r
if loc[0] >= 14 && len(c.Body) >= loc[0]+15 {
// remove the preceding date if there are now two dates following each other
re := regexp.MustCompile(`(?m)^## (\d\d\d\d-\d\d-\d\d)\n\n## (\d\d\d\d-\d\d-\d\d)\n`)
if re.Match(p.Body[loc[0]-14 : loc[0]+15]) {
p.Body = append(p.Body[0:loc[0]-14], p.Body[loc[0]+1:]...)
if re.Match(c.Body[loc[0]-14 : loc[0]+15]) {
c.Body = append(c.Body[0:loc[0]-14], c.Body[loc[0]+1:]...)
}
} else if len(p.Body) == loc[0] {
} else if len(c.Body) == loc[0] {
// remove a trailing date
re := regexp.MustCompile(`## (\d\d\d\d-\d\d-\d\d)\n`)
if re.Match(p.Body[loc[0]-14 : loc[0]]) {
p.Body = p.Body[0 : loc[0]-14]
if re.Match(c.Body[loc[0]-14 : loc[0]]) {
c.Body = c.Body[0 : loc[0]-14]
}
}
}
// locate the beginning of the list to insert the line
re := regexp.MustCompile(`(?m)^\* \[[^\]]+\]\([^\)]+\)\n`)
loc = re.FindIndex(p.Body)
loc = re.FindIndex(c.Body)
if loc == nil {
// if no list was found, use the end of the page
loc = []int{len(p.Body)}
loc = []int{len(c.Body)}
}
// start with new page content
r := []byte("")
@@ -97,10 +103,10 @@ func addLinkWithDate(name, link string, re *regexp.Regexp) error {
addDate := true
if loc[0] >= 14 {
re := regexp.MustCompile(`(?m)^## (\d\d\d\d-\d\d-\d\d)\n`)
m := re.Find(p.Body[loc[0]-14 : loc[0]])
m := re.Find(c.Body[loc[0]-14 : loc[0]])
if m == nil {
// not a date: insert date, don't move insertion point
} else if string(p.Body[loc[0]-11:loc[0]-1]) == date {
} else if string(c.Body[loc[0]-11:loc[0]-1]) == date {
// if the date is our date, don't add it, don't move insertion point
addDate = false
} else {
@@ -109,7 +115,7 @@ func addLinkWithDate(name, link string, re *regexp.Regexp) error {
}
}
// append up to the insertion point
r = append(r, p.Body[:loc[0]]...)
r = append(r, c.Body[:loc[0]]...)
// append date, if necessary
if addDate {
// ensure paragraph break
@@ -126,16 +132,16 @@ func addLinkWithDate(name, link string, re *regexp.Regexp) error {
// append link
r = append(r, []byte(link)...)
// if we just added a date, add an empty line after the single-element list
if len(p.Body) > loc[0] && p.Body[loc[0]] != '*' {
if len(c.Body) > loc[0] && c.Body[loc[0]] != '*' {
r = append(r, '\n')
}
// append the rest
r = append(r, p.Body[loc[0]:]...)
p.Body = r
r = append(r, c.Body[loc[0]:]...)
c.Body = r
}
// only save if something changed
if string(p.Body) != org {
return p.save()
if string(c.Body) != org {
return c.save()
}
return nil
}
@@ -143,27 +149,27 @@ func addLinkWithDate(name, link string, re *regexp.Regexp) error {
// addLink adds a link to a named page, if the page exists and doesn't contain the link. If the link exists but with a
// different title, the title is fixed.
func addLink(name string, mandatory bool, link string, re *regexp.Regexp) error {
p, err := loadPage(name)
c, err := loadPage(name)
if err != nil {
if mandatory {
p = &Page{Name: name, Body: []byte(link)}
return p.save()
if (mandatory) {
c = &Page{Name: name, Body: []byte(link)}
return c.save()
} else {
// Skip non-existing files: no error
return nil
}
}
org := string(p.Body)
org := string(c.Body)
// 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)
loc := re.FindIndex(c.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)
loc = re.FindIndex(c.Body)
if loc == nil {
// if no list was found, use the end of the page
m := len(p.Body)
m := len(c.Body)
loc = []int{m, m}
} else {
// if a list item was found, use just the beginning as insertion point
@@ -173,15 +179,15 @@ func addLink(name string, mandatory bool, link string, re *regexp.Regexp) error
// start with new page content
r := []byte("")
// append up to the insertion point
r = append(r, p.Body[:loc[0]]...)
r = append(r, c.Body[:loc[0]]...)
// append link
r = append(r, []byte(link)...)
// append the rest
r = append(r, p.Body[loc[1]:]...)
p.Body = r
r = append(r, c.Body[loc[1]:]...)
c.Body = r
// only save if something changed
if string(p.Body) != org {
return p.save()
if string(c.Body) != org {
return c.save()
}
return nil
}

15
concurrency_test.go Normal file
View File

@@ -0,0 +1,15 @@
package main
import (
"github.com/stretchr/testify/assert"
"testing"
)
// Use go test -race to see whether this is a race condition.
func TestLoadAndSearch(t *testing.T) {
index.reset()
go index.load()
q := "Oddµ"
pages, _ := search(q, "", 1, false)
assert.Zero(t, len(pages))
}

10
diff.go
View File

@@ -8,7 +8,6 @@ import (
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
)
@@ -20,22 +19,21 @@ func diffHandler(w http.ResponseWriter, r *http.Request, name string) {
}
p.handleTitle(true)
p.renderHtml()
renderTemplate(w, p.Dir(), "diff", p)
renderTemplate(w, "diff", p)
}
// Diff computes the diff for a page. At this point, renderHtml has already been called so the Name is escaped.
func (p *Page) Diff() template.HTML {
path, err := url.PathUnescape(p.Name)
name, err := url.PathUnescape(p.Name)
if err != nil {
return template.HTML("Cannot unescape " + p.Name)
}
fp := filepath.FromSlash(path)
a := fp + ".md~"
a := name + ".md~"
t1, err := os.ReadFile(a)
if err != nil {
return template.HTML("Cannot read " + a + ", so the page is new.")
}
b := fp + ".md"
b := name + ".md"
t2, err := os.ReadFile(b)
if err != nil {
return template.HTML("Cannot read " + b + ", so the page was deleted.")

View File

@@ -6,11 +6,12 @@
<meta name="viewport" content="width=device-width">
<title>{{.Title}}</title>
<style>
html { max-width: 70ch; padding: 1ch; margin: auto; color: #111; background-color: #ffe; }
html { max-width: 70ch; padding: 1ch; margin: auto; color: #ddd; background-color: #222; }
a { color: #8cf } a:visited { color: #dbf } a:hover { color: #fff }
body { hyphens: auto; }
del { background-color: #fab }
ins { background-color: #af8 }
pre { white-space: normal; background-color: white; border: 1px solid #eee; padding: 1ch }
pre { white-space: normal; color: #222; background-color: #ddd; border: 1px solid #eee; padding: 1ch }
</style>
</head>
<body>

View File

@@ -6,7 +6,9 @@
<meta name="viewport" content="width=device-width">
<title>Editing {{.Title}}</title>
<style>
html { max-width: 70ch; padding: 2ch; margin: auto; color: #111; background-color: #ffe; }
html { max-width: 70ch; padding: 2ch; margin: auto; color: #ddd; background-color: #222; }
a { color: #8cf } a:visited { color: #dbf } a:hover { color: #fff }
textarea, input, button { color: #222; background-color: #ddd; border: 1px solid #eee; }
form, textarea { width: 100%; }
</style>
</head>
@@ -16,7 +18,7 @@ form, textarea { width: 100%; }
<textarea name="body" rows="20" cols="80" placeholder="# Title
Text" lang="" autofocus>{{printf "%s" .Body}}</textarea>
<p><label><input type="checkbox" name="notify" checked> Add link to <a href="changes">the list of changes</a>.</label></p>
<p><label><input type="checkbox" name="notify" checked> Add link to <a href="/view/changes">the list of changes</a>.</label></p>
<p><input type="submit" value="Save">
<a href="/view/{{.Name}}"><button type="button">Cancel</button></a></p>
</form>

View File

@@ -5,8 +5,10 @@ import (
"net/http"
)
// editHandler uses the "edit.html" template to present an edit page. When editing, the page title is not overriden by a
// title in the text. Instead, the page name is used. The edit is saved using the saveHandler.
// editHandler uses the "edit.html" template to present an edit page.
// When editing, the page title is not overriden by a title in the
// text. Instead, the page name is used. The edit is saved using the
// saveHandler.
func editHandler(w http.ResponseWriter, r *http.Request, name string) {
p, err := loadPage(name)
if err != nil {
@@ -14,31 +16,23 @@ func editHandler(w http.ResponseWriter, r *http.Request, name string) {
} else {
p.handleTitle(false)
}
renderTemplate(w, p.Dir(), "edit", p)
renderTemplate(w, "edit", p)
}
// saveHandler takes the "body" form parameter and saves it. The browser is redirected to the page view. This is similar
// to the appendHandler.
// saveHandler takes the "body" form parameter and saves it. The
// browser is redirected to the page view.
func saveHandler(w http.ResponseWriter, r *http.Request, name string) {
body := r.FormValue("body")
p := &Page{Name: name, Body: []byte(body)}
err := p.save()
if err != nil {
log.Println(err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
username, _, ok := r.BasicAuth()
if ok {
log.Println("Save", name, "by", username)
} else {
log.Println("Save", name)
}
if r.FormValue("notify") == "on" {
err = p.notify() // errors have already been logged, so no logging here
err = p.notify()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
log.Println("notify:", err)
}
}
http.Redirect(w, r, "/view/"+name, http.StatusFound)

View File

@@ -44,8 +44,8 @@ func TestEditSaveChanges(t *testing.T) {
today := time.Now().Format("2006-01-02")
// Posting to the save URL saves a page
HTTPRedirectTo(t, makeHandler(saveHandler, true),
"POST", "/save/testdata/notification/"+today,
data, "/view/testdata/notification/"+today)
"POST", "/save/testdata/notification/" + today,
data, "/view/testdata/notification/" + today)
// The changes.md file was created
s, err := os.ReadFile("testdata/notification/changes.md")
assert.NoError(t, err)
@@ -59,18 +59,3 @@ func TestEditSaveChanges(t *testing.T) {
// New index contains just the link
assert.Equal(t, string(s), "* [testdata/notification/"+today+"]("+today+")\n")
}
// Test the following view.html:
// <form action="/edit/" method="GET">
//
// <label for="id">New page:</label>
// <input id="id" type="text" spellcheck="false" name="id" accesskey="g" value="{{.Dir}}/{{.Today}}" required>
// <button>Edit</button>
//
// </form>
func TestEditId(t *testing.T) {
cleanup(t, "testdata/id")
assert.Contains(t, assert.HTTPBody(makeHandler(editHandler, true),
"GET", "/edit/?id=testdata/id/alex", nil),
"Editing testdata/id/alex")
}

20
feed.go
View File

@@ -7,37 +7,19 @@ import (
"html/template"
"os"
"path"
"path/filepath"
"time"
)
// Item is a Page plus a Date.
type Item struct {
// Page is the page being used as the feed item.
Page
// 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
}
// Feed is an Item used for the feed itself, plus an array of items based on the linked pages.
type Feed struct {
// Item is the page containing the list of links. It's title is used for the feed and it's last modified time is
// used for the publication date. Thus, if linked pages change but the page with the links doesn't change, the
// publication date remains unchanged.
Item
// Items are based on the pages linked in list items starting with an asterisk ("*"). Links in
// list items starting with a minus ("-") are ignored!
Items []Item
}
// feed returns a RSS 2.0 feed for any page. The feed items it contains are the pages linked from in list items starting
// with an asterisk ("*").
func feed(p *Page, ti time.Time) *Feed {
feed := new(Feed)
feed.Name = p.Name
@@ -64,7 +46,7 @@ func feed(p *Page, ti time.Time) *Feed {
return ast.GoToNext
}
name := path.Join(path.Dir(p.Name), string(link.Destination))
fi, err := os.Stat(filepath.FromSlash(name) + ".md")
fi, err := os.Stat(name + ".md")
if err != nil {
return ast.GoToNext
}

View File

@@ -3,10 +3,10 @@
<docs>http://blogs.law.harvard.edu/tech/rss</docs>
<title>{{.Title}}</title>
<link>https://example.org/</link>
<managingEditor>you@example.org (Your Name)</managingEditor>
<webMaster>you@example.org (Your Name)</webMaster>
<managingEditor>jupiter@transjovian.org (Ashivom Bandaralum)</managingEditor>
<webMaster>jupiter@transjovian.org (Ashivom Bandaralum)</webMaster>
<atom:link href="https://example.org/view/{{.Name}}.rss" rel="self" type="application/rss+xml"/>
<description>This is the digital garden of Your Name.</description>
<description>This is the digital garden of Ashivom Bandaralum.</description>
<image>
<url>https://example.org/view/logo.jpg</url>
<title>{{.Title}}</title>

View File

@@ -2,7 +2,6 @@ package main
import (
"github.com/stretchr/testify/assert"
"net/http"
"testing"
)
@@ -12,11 +11,6 @@ func TestFeed(t *testing.T) {
"Welcome to Oddµ")
}
func TestNoFeed(t *testing.T) {
assert.HTTPStatusCode(t,
makeHandler(viewHandler, true), "GET", "/view/no-feed.rss", nil, http.StatusNotFound)
}
func TestFeedItems(t *testing.T) {
cleanup(t, "testdata/feed")
index.load()

18
go.mod
View File

@@ -3,19 +3,17 @@ module alexschroeder.ch/cgit/oddmu
go 1.21.0
require (
github.com/anthonynsimon/bild v0.13.0
github.com/bashdrew/goheif v0.0.0-20230406184952-7a08ca9c9bdd
github.com/charmbracelet/lipgloss v0.9.1
github.com/disintegration/imaging v1.6.2
github.com/edwvee/exiffix v0.0.0-20210922235313-0f6cbda5e58f
github.com/fsnotify/fsnotify v1.7.0
github.com/gomarkdown/markdown v0.0.0-20231222211730-1d6d20845b47
github.com/gomarkdown/markdown v0.0.0-20231115200524-a660076da3fd
github.com/google/subcommands v1.2.0
github.com/hexops/gotextdiff v1.0.3
github.com/microcosm-cc/bluemonday v1.0.26
github.com/pemistahl/lingua-go v1.4.0
github.com/sergi/go-diff v1.3.1
github.com/stretchr/testify v1.8.4
golang.org/x/exp v0.0.0-20240119083558-1b970713d09a
golang.org/x/exp v0.0.0-20231206192017-f3f8817b8deb
)
require (
@@ -29,12 +27,12 @@ require (
github.com/muesli/reflow v0.3.0 // indirect
github.com/muesli/termenv v0.15.2 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/rivo/uniseg v0.4.6 // indirect
github.com/rivo/uniseg v0.4.4 // indirect
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd // indirect
github.com/shopspring/decimal v1.3.1 // indirect
golang.org/x/image v0.15.0 // indirect
golang.org/x/net v0.20.0 // indirect
golang.org/x/sys v0.16.0 // indirect
google.golang.org/protobuf v1.32.0 // indirect
golang.org/x/image v0.14.0 // indirect
golang.org/x/net v0.19.0 // indirect
golang.org/x/sys v0.15.0 // indirect
google.golang.org/protobuf v1.31.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

67
go.sum
View File

@@ -1,3 +1,7 @@
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/anthonynsimon/bild v0.13.0 h1:mN3tMaNds1wBWi1BrJq0ipDBhpkooYfu7ZFSMhXt1C8=
github.com/anthonynsimon/bild v0.13.0/go.mod h1:tpzzp0aYkAsMi1zmfhimaDyX1xjn2OUc1AJZK/TF0AE=
github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
@@ -6,25 +10,28 @@ github.com/bashdrew/goheif v0.0.0-20230406184952-7a08ca9c9bdd h1:SxkQeH4jjXT0zMg
github.com/bashdrew/goheif v0.0.0-20230406184952-7a08ca9c9bdd/go.mod h1:p1sbxRy+MY71fEWHcfRmerC8WUYXDFCExF9A7aXwp98=
github.com/charmbracelet/lipgloss v0.9.1 h1:PNyd3jvaJbg4jRHKWXnCj1akQm4rh8dbEzN1p/u1KWg=
github.com/charmbracelet/lipgloss v0.9.1/go.mod h1:1mPmG4cxScwUQALAAnacHaigiiHB9Pmr+v1VEawJl6I=
github.com/coreos/etcd v3.3.10+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE=
github.com/coreos/go-etcd v2.0.0+incompatible/go.mod h1:Jez6KQU2B/sWsbdaef3ED8NzMklzPG4d5KIOhIy30Tk=
github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk=
github.com/cpuguy83/go-md2man v1.0.10/go.mod h1:SmD6nW6nTyfqj6ABTjUi3V3JVMnlJmwcJI5acqYI6dE=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
github.com/edwvee/exiffix v0.0.0-20210922235313-0f6cbda5e58f h1:RMnUwTnNR070mFAEIoqMYjNirHj8i0h79VXTYyBCyVA=
github.com/edwvee/exiffix v0.0.0-20210922235313-0f6cbda5e58f/go.mod h1:KoE3Ti1qbQXCb3s/XGj0yApHnbnNnn1bXTtB5Auq/Vc=
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
github.com/gomarkdown/markdown v0.0.0-20231222211730-1d6d20845b47 h1:k4Tw0nt6lwro3Uin8eqoET7MDA4JnT8YgbCjc/g5E3k=
github.com/gomarkdown/markdown v0.0.0-20231222211730-1d6d20845b47/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/gomarkdown/markdown v0.0.0-20231115200524-a660076da3fd h1:PppHBegd3uPZ3Y/Iax/2mlCFJm1w4Qf/zP1MdW4ju2o=
github.com/gomarkdown/markdown v0.0.0-20231115200524-a660076da3fd/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.8 h1:e6P7q2lk1O+qJJb4BtCQXlK8vWEO8V1ZeuEdJNOqZyg=
github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/subcommands v1.2.0 h1:vWQspBTo2nEqTUFita5/KeEWlUL8kQObDFbub/EN9oE=
github.com/google/subcommands v1.2.0/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk=
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=
github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
@@ -32,6 +39,7 @@ github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
@@ -39,41 +47,58 @@ github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZ
github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/microcosm-cc/bluemonday v1.0.26 h1:xbqSvqzQMeEHCqMi64VAs4d8uy6Mequs3rQ0k/Khz58=
github.com/microcosm-cc/bluemonday v1.0.26/go.mod h1:JyzOCs9gkyQyjs+6h10UEVSe02CGwkhd72Xdqh78TWs=
github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s=
github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8=
github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo=
github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8=
github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic=
github.com/pemistahl/lingua-go v1.4.0 h1:ifYhthrlW7iO4icdubwlduYnmwU37V1sbNrwhKBR4rM=
github.com/pemistahl/lingua-go v1.4.0/go.mod h1:ECuM1Hp/3hvyh7k8aWSqNCPlTxLemFZsRjocUf3KgME=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.6 h1:Sovz9sDSwbOz9tgUy8JpT+KgCkPYJEN/oYzlJiYTNLg=
github.com/rivo/uniseg v0.4.6/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rivo/uniseg v0.4.4 h1:8TfxU8dW6PdqD27gjM8MVNuicgxIjxpm4K7x4jp8sis=
github.com/rivo/uniseg v0.4.4/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g=
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd h1:CmH9+J6ZSsIjUK3dcGsnCnO41eRBOnY12zwkn5qVwgc=
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd/go.mod h1:hPqNNc0+uJM6H+SuU8sEs5K5IQeKccPqeSjfgcKGgPk=
github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8=
github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I=
github.com/shopspring/decimal v1.3.1 h1:2Usl1nmF/WZucqkFZhnfFYxxxu8LG21F6nPQBE5gKV8=
github.com/shopspring/decimal v1.3.1/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o=
github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ=
github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE=
github.com/spf13/cobra v0.0.5/go.mod h1:3K3wKZymM7VvHMDS9+Akkh4K60UwM26emMESw8tLCHU=
github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo=
github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=
github.com/spf13/viper v1.3.2/go.mod h1:ZiWeW+zYFKm7srdB9IoDzzZXaJaI5eL9QjNiN/DMA2s=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
golang.org/x/exp v0.0.0-20240119083558-1b970713d09a h1:Q8/wZp0KX97QFTc2ywcOE0YRjZPVIx+MXInMzdvQqcA=
golang.org/x/exp v0.0.0-20240119083558-1b970713d09a/go.mod h1:idGWGoKP1toJGkd5/ig9ZLuPcZBC3ewk7SzmH0uou08=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.15.0 h1:kOELfmgrmJlw4Cdb7g/QGuB3CvDrXbqEIww/pNtNBm8=
golang.org/x/image v0.15.0/go.mod h1:HUYqC05R2ZcZ3ejNQsIHQDQiwWM4JBqmm6MKANTp4LE=
golang.org/x/net v0.20.0 h1:aCL9BSgETF1k+blQaYUBx9hJ9LOGP3gAVemcZlf1Kpo=
golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY=
github.com/ugorji/go/codec v0.0.0-20181204163529-d75b2dcb6bc8/go.mod h1:VFNgLljTbGfSG7qAOspJ7OScBnGdDN/yBr0sguwnwf0=
github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q=
golang.org/x/crypto v0.0.0-20181203042331-505ab145d0a9/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/exp v0.0.0-20231206192017-f3f8817b8deb h1:c0vyKkb6yr3KR7jEfJaOSv4lG7xPkbN6r52aJz1d8a8=
golang.org/x/exp v0.0.0-20231206192017-f3f8817b8deb/go.mod h1:iRJReGqOEeBhDZGkGbynYwcHlctCvnjTYIamk7uXpHI=
golang.org/x/image v0.0.0-20190703141733-d6a02ce849c9/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.14.0 h1:tNgSxAFe3jC4uYqvZdTr84SZoM1KfwdC9SKIFrLjFn4=
golang.org/x/image v0.14.0/go.mod h1:HUYqC05R2ZcZ3ejNQsIHQDQiwWM4JBqmm6MKANTp4LE=
golang.org/x/net v0.19.0 h1:zTwKpTd2XuCqf8huc7Fo2iSy+4RHPd10s4KzeTnVr1c=
golang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U=
golang.org/x/sys v0.0.0-20181205085412-a5c9d58dba9a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU=
golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc=
golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
google.golang.org/protobuf v1.32.0 h1:pPC6BG5ex8PDFnkbrGU3EixyhKcQ2aDuBS36lqK/C7I=
google.golang.org/protobuf v1.32.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8=
google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=

View File

@@ -16,8 +16,8 @@ type htmlCmd struct {
func (*htmlCmd) Name() string { return "html" }
func (*htmlCmd) Synopsis() string { return "render a page as HTML" }
func (*htmlCmd) Usage() string {
return `html [-view] <page name> ...:
Render one or more pages as HTML.
return `html [-view] <page name>:
Render a page as HTML.
`
}
@@ -36,12 +36,13 @@ func htmlCli(w io.Writer, useTemplate bool, args []string) subcommands.ExitStatu
fmt.Fprintf(os.Stderr, "Cannot load %s: %s\n", arg, err)
return subcommands.ExitFailure
}
initAccounts()
if useTemplate {
p.handleTitle(true)
p.renderHtml()
t := "view.html"
loadTemplates()
err := templates.template[t].Execute(w, p)
templates := loadTemplates()
err := templates.ExecuteTemplate(w, t, p)
if err != nil {
fmt.Fprintf(os.Stderr, "Cannot execute %s template for %s: %s\n", t, arg, err)
return subcommands.ExitFailure

233
index.go
View File

@@ -16,8 +16,9 @@ import (
type docid uint
// indexStore controls access to the maps used for search. Make sure to lock and unlock as appropriate.
type indexStore struct {
// Index contains the two maps used for search. Make sure to lock and
// unlock as appropriate.
type Index struct {
sync.RWMutex
// next_id is the number of the next document added to the index
@@ -33,22 +34,18 @@ type indexStore struct {
titles map[string]string
}
var index indexStore
var index Index
func init() {
index.reset()
// reset resets the Index. This assumes that the index is locked!
func (idx *Index) reset() {
idx.token = nil
idx.documents = nil
idx.titles = nil
}
// reset the index. This assumes that the index is locked. It's useful for tests.
func (idx *indexStore) reset() {
idx.next_id = 0
idx.token = make(map[string][]docid)
idx.documents = make(map[docid]string)
idx.titles = make(map[string]string)
}
// addDocument adds the text as a new document. This assumes that the index is locked!
func (idx *indexStore) addDocument(text []byte) docid {
// addDocument adds the text as a new document. This assumes that the
// index is locked!
func (idx *Index) addDocument(text []byte) docid {
id := idx.next_id
idx.next_id++
for _, token := range hashtags(text) {
@@ -56,7 +53,7 @@ func (idx *indexStore) addDocument(text []byte) docid {
// Don't add same ID more than once. Checking the last
// position of the []docid works because the id is
// always a new one, i.e. the last one, if at all.
if len(ids) > 0 && ids[len(ids)-1] == id {
if ids != nil && ids[len(ids)-1] == id {
continue
}
idx.token[token] = append(ids, id)
@@ -64,124 +61,148 @@ func (idx *indexStore) addDocument(text []byte) docid {
return id
}
// deleteDocument deletes all references to the id. The id can no longer be used. This assumes that the index is locked.
func (idx *indexStore) deleteDocument(id docid) {
// Looping through all tokens makes sense if there are few tokens (like hashtags). It doesn't make sense if the
// number of tokens is large (like for full-text search or a trigram index).
for token, ids := range idx.token {
// If the token appears only in this document, remove the whole entry.
// deleteDocument deletes the text as a new document. The id can no
// longer be used. This assumes that the index is locked!
func (idx *Index) deleteDocument(text []byte, id docid) {
for _, token := range hashtags(text) {
ids := index.token[token]
// Tokens can appear multiple times in a text but they
// can only be deleted once. deleted.
if ids == nil {
continue
}
// If the token appears only in this document, remove
// the whole entry.
if len(ids) == 1 && ids[0] == id {
delete(idx.token, token)
delete(index.token, token)
continue
}
// Otherwise, remove the token from the index.
i := sort.Search(len(ids), func(i int) bool { return ids[i] >= id })
if i != -1 && i < len(ids) && ids[i] == id {
copy(ids[i:], ids[i+1:])
idx.token[token] = ids[:len(ids)-1]
index.token[token] = ids[:len(ids)-1]
continue
}
// If none of the above, then our docid wasn't
// indexed. This shouldn't happen, either.
log.Printf("The index for token %s does not contain doc id %d", token, id)
}
delete(index.documents, id)
}
// deletePageName determines the document id based on the page name and calls deleteDocument to delete all references.
// This assumes that the index is unlocked.
func (idx *indexStore) deletePageName(name string) {
idx.Lock()
defer idx.Unlock()
var id docid
// Reverse lookup! At least it's in memory.
for key, value := range idx.documents {
if value == name {
id = key
break
}
}
if id != 0 {
idx.deleteDocument(id)
delete(idx.documents, id)
}
delete(idx.titles, name)
}
// remove the page from the index. Do this when deleting a page. This assumes that the index is unlocked.
func (idx *indexStore) remove(p *Page) {
idx.deletePageName(p.Name)
}
// load loads all the pages and indexes them. This takes a while. It returns the number of pages indexed.
func (idx *indexStore) load() (int, error) {
idx.Lock()
defer idx.Unlock()
err := filepath.Walk(".", idx.walk)
// add reads a file and adds it to the index. This must happen while
// the idx is locked.
func (idx *Index) add(path string, info fs.FileInfo, err error) error {
if err != nil {
return err
}
filename := path
if info.IsDir() || strings.HasPrefix(filename, ".") || !strings.HasSuffix(filename, ".md") {
return nil
}
name := strings.TrimSuffix(filename, ".md")
p, err := loadPage(name)
if err != nil {
return err
}
p.handleTitle(false)
id := idx.addDocument(p.Body)
idx.documents[id] = p.Name
idx.titles[p.Name] = p.Title
return nil
}
// load loads all the pages and indexes them. This takes a while.
// It returns the number of pages indexed.
func (idx *Index) load() (int, error) {
idx.Lock()
defer idx.Unlock()
idx.token = make(map[string][]docid)
idx.documents = make(map[docid]string)
idx.titles = make(map[string]string)
err := filepath.Walk(".", idx.add)
if err != nil {
idx.reset()
return 0, err
}
n := len(idx.documents)
return n, nil
}
// walk reads a file and adds it to the index. This assumes that the index is locked.
func (idx *indexStore) walk(path string, info fs.FileInfo, err error) error {
if err != nil {
return err
}
// skip hidden directories and files
if path != "." && strings.HasPrefix(filepath.Base(path), ".") {
if info.IsDir() {
return filepath.SkipDir
} else {
return nil
}
}
// skipp all but page files
if !strings.HasSuffix(path, ".md") {
return nil
}
p, err := loadPage(strings.TrimSuffix(path, ".md"))
if err != nil {
return err
}
p.handleTitle(false)
idx.addPage(p)
return nil
}
// addPage adds a page to the index. This assumes that the index is locked.
func (idx *indexStore) addPage(p *Page) {
id := idx.addDocument(p.Body)
idx.documents[id] = p.Name
p.handleTitle(false)
idx.titles[p.Name] = p.Title
}
// add a page to the index. This assumes that the index is unlocked.
func (idx *indexStore) add(p *Page) {
idx.Lock()
defer idx.Unlock()
idx.addPage(p)
}
// dump prints the index to the log for debugging.
func (idx *indexStore) dump() {
idx.RLock()
defer idx.RUnlock()
// dump prints the index to the log for debugging. Must already be readlocked.
func (idx *Index) dump() {
index.RLock()
defer index.RUnlock()
for token, ids := range idx.token {
log.Printf("%s: %v", token, ids)
}
}
// updateIndex updates the index for a single page.
func (idx *indexStore) update(p *Page) {
idx.remove(p)
idx.add(p)
// updateIndex updates the index for a single page. The old text is
// loaded from the disk and removed from the index first, if it
// exists.
func (p *Page) updateIndex() {
index.Lock()
defer index.Unlock()
var id docid
// Reverse lookup! At least it's in memory.
for docId, name := range index.documents {
if name == p.Name {
id = docId
break
}
}
if id == 0 {
id = index.addDocument(p.Body)
index.documents[id] = p.Name
index.titles[p.Name] = p.Title
} else {
if o, err := loadPage(p.Name); err == nil {
index.deleteDocument(o.Body, id)
}
// Do not reuse the old id. We need a new one for
// indexing to work.
id = index.addDocument(p.Body)
// The page name stays the same but the title may have
// changed.
index.documents[id] = p.Name
p.handleTitle(false)
index.titles[p.Name] = p.Title
}
}
// removeFromIndex removes the page from the index. Do this when
// deleting a page.
func (p *Page) removeFromIndex() {
index.Lock()
defer index.Unlock()
var id docid
// Reverse lookup! At least it's in memory.
for docId, name := range index.documents {
if name == p.Name {
id = docId
break
}
}
if id == 0 {
log.Printf("Page %s is not indexed", p.Name)
return
}
o, err := loadPage(p.Name)
if err != nil {
log.Printf("Page %s cannot removed from the index: %s", p.Name, err)
return
}
index.deleteDocument(o.Body, id)
}
// search searches the index for a query string and returns page
// names.
func (idx *indexStore) search(q string) []string {
idx.RLock()
defer idx.RUnlock()
func (idx *Index) search(q string) []string {
index.RLock()
defer index.RUnlock()
names := make([]string, 0)
hashtags := hashtags([]byte(q))
if len(hashtags) > 0 {

View File

@@ -6,23 +6,11 @@ import (
"testing"
)
func TestIndexAdd(t *testing.T) {
idx := &indexStore{}
idx.reset()
idx.Lock()
defer idx.Unlock()
tag := "#hello"
id := idx.addDocument([]byte("oh hi " + tag))
assert.Contains(t, idx.token, tag)
idx.deleteDocument(id)
assert.NotContains(t, idx.token, tag)
}
// TestIndex relies on README.md being indexed
func TestIndex(t *testing.T) {
index.load()
q := "Oddµ"
pages, _ := search(q, "", "", 1, false)
pages, _ := search(q, "", 1, false)
assert.NotZero(t, len(pages))
for _, p := range pages {
assert.NotContains(t, p.Title, "<b>")
@@ -34,7 +22,7 @@ func TestIndex(t *testing.T) {
func TestSearchHashtag(t *testing.T) {
index.load()
q := "#like_this"
pages, _ := search(q, "", "", 1, false)
pages, _ := search(q, "", 1, false)
assert.NotZero(t, len(pages))
}
@@ -46,7 +34,7 @@ func TestIndexUpdates(t *testing.T) {
p.save()
// Find the phrase
pages, _ := search("This is a test", "", "", 1, false)
pages, _ := search("This is a test", "", 1, false)
found := false
for _, p := range pages {
if p.Name == name {
@@ -57,7 +45,7 @@ func TestIndexUpdates(t *testing.T) {
assert.True(t, found)
// Find the phrase, case insensitive
pages, _ = search("this is a test", "", "", 1, false)
pages, _ = search("this is a test", "", 1, false)
found = false
for _, p := range pages {
if p.Name == name {
@@ -68,7 +56,7 @@ func TestIndexUpdates(t *testing.T) {
assert.True(t, found)
// Find some words
pages, _ = search("this test", "", "", 1, false)
pages, _ = search("this test", "", 1, false)
found = false
for _, p := range pages {
if p.Name == name {
@@ -81,7 +69,7 @@ func TestIndexUpdates(t *testing.T) {
// Update the page and no longer find it with the old phrase
p = &Page{Name: name, Body: []byte("# New page\nGuvf vf n grfg.")}
p.save()
pages, _ = search("This is a test", "", "", 1, false)
pages, _ = search("This is a test", "", 1, false)
found = false
for _, p := range pages {
if p.Name == name {
@@ -92,7 +80,7 @@ func TestIndexUpdates(t *testing.T) {
assert.False(t, found)
// Find page using a new word
pages, _ = search("Guvf", "", "", 1, false)
pages, _ = search("Guvf", "", 1, false)
found = false
for _, p := range pages {
if p.Name == name {
@@ -105,5 +93,5 @@ func TestIndexUpdates(t *testing.T) {
// Make sure the title was updated
index.RLock()
defer index.RUnlock()
assert.Equal(t, "New page", index.titles[name])
assert.Equal(t, index.titles[name], "New page")
}

View File

@@ -7,7 +7,8 @@ import (
"strings"
)
// getLanguages returns the environment variable ODDMU_LANGUAGES or all languages.
// getLangauges returns the environment variable ODDMU_LANGUAGES or
// all languages.
func getLanguages() ([]lingua.Language, error) {
v := os.Getenv("ODDMU_LANGUAGES")
if v == "" {
@@ -28,9 +29,8 @@ func getLanguages() ([]lingua.Language, error) {
// detector is the LanguageDetector initialized at startup by loadLanguages.
var detector lingua.LanguageDetector
// loadLanguages initializes the detector using the languages returned by getLanguages and returns the number of
// languages loaded. If this is skipped, no language detection happens and the templates cannot use {{.Language}} to use
// this. Usually this is used for correct hyphenation by the browser.
// loadLanguages initializes the detector using the languages returned
// by getLanguages and returns the number of languages loaded.
func loadLanguages() int {
langs, err := getLanguages()
if err == nil {

View File

@@ -50,11 +50,10 @@ func listCli(w io.Writer, dir string, args []string) subcommands.ExitStatus {
return subcommands.ExitSuccess
}
// checkDir returns an error if the directory doesn't exist. If if exists, it returns a copy ending in a slash suiteable
// for substring matching of page names.
func checkDir(dir string) (string, error) {
// checkDir returns an error if the directory doesn't exist. If if exists, it returns a copy ending in a slash.
func checkDir (dir string) (string, error) {
if dir != "" {
fi, err := os.Stat(filepath.FromSlash(dir))
fi, err := os.Stat(dir)
if err != nil {
fmt.Println(err)
return "", err
@@ -63,7 +62,8 @@ func checkDir(dir string) (string, error) {
fmt.Println("This is not a sub-directory:", dir)
return "", err
}
if !strings.HasSuffix(dir, "/") {
dir = filepath.ToSlash(dir);
if (!strings.HasSuffix(dir, "/")) {
dir += "/"
}
}

1
man/.gitignore vendored
View File

@@ -1 +0,0 @@
*.md

View File

@@ -1,35 +1,6 @@
TEXT=$(wildcard *.txt)
MAN=$(patsubst %.txt,%,${TEXT})
HTML=$(patsubst %.txt,%.html,${TEXT})
MD=$(patsubst %.txt,%.md,${TEXT})
docs: oddmu-apache.5 oddmu-html.1 oddmu-missing.1 oddmu-notify.1 \
oddmu-replace.1 oddmu-search.1 oddmu-search.7 oddmu-static.1 \
oddmu-list.1 oddmu-templates.5 oddmu.1 oddmu.5 oddmu.service.5
man: ${MAN}
%: %.txt
oddmu%: oddmu%.txt
scdoc < $< > $@
html: ${HTML}
%.html: %
groff -mandoc -Dutf8 -Thtml $< | sed 's/<style type="text\/css">/<style type="text\/css">\n body {font-family: mono; max-width: 80ch }/' > $@
md: ${MD}
%.md: %.txt
sed --regexp-extended \
-e 's/\*([^*]+)\*/**\1**/g' \
-e 's/_(oddmu[a-z.-]*)_\(([1-9])\)/[\1(\2)](\1.\2)/g' \
-e 's/\b_([^_]+)_\b/*\1*/g' \
-e 's/^#/##/' \
-e 's/^([A-Z.-]*\([1-9]\))( ".*")?$$/# \1/' \
< $< > $@
upload: ${MD}
rsync --itemize-changes --archive *.md sibirocobombus:alexschroeder.ch/wiki/oddmu/
make clean
clean:
rm --force ${HTML} ${MD}
realclean: clean
rm --force ${MAN}

View File

@@ -1,11 +1,11 @@
.\" Generated by scdoc 1.11.3
.\" Generated by scdoc 1.11.2
.\" Complete documentation for this program is not available as a GNU info page
.ie \n(.g .ds Aq \(aq
.el .ds Aq '
.nh
.ad l
.\" Begin generated content:
.TH "ODDMU-APACHE" "5" "2024-02-19"
.TH "ODDMU-APACHE" "5" "2024-01-17"
.PP
.SH NAME
.PP
@@ -28,28 +28,25 @@ HTTPS is not part of Oddmu.\& You probably want to configure this in your
webserver.\& I guess you could use stunnel, too.\& If you'\&re using Apache, you can
use "mod_md" to manage your domain.\&
.PP
The examples below use the domain "transjovian.\&org" and the Apache installation
is the one that comes with Debian.\&
.PP
The site itself is configured in a file called
"/etc/apache2/sites-available/transjovian.\&conf" and a link points there from
In the example below, the site is configured in a file called
"/etc/apache2/sites-available/500-transjovian.\&conf" and a link poins there from
"/etc/apache2/sites-enabled".\& Create this link using \fIa2ensite\fR(1).\&
.PP
.nf
.RS 4
MDomain transjovian\&.org
MDCertificateAgreement accepted
ServerAdmin alex@alexschroeder\&.ch
<VirtualHost *:80>
ServerName transjovian\&.org
Redirect "/" "https://transjovian\&.org/"
ServerName transjovian\&.org
RewriteEngine on
RewriteRule ^/(\&.*) https://%{HTTP_HOST}/$1 [redirect]
</VirtualHost>
<VirtualHost *:443>
ServerName transjovian\&.org
SSLEngine on
ProxyPassMatch "^/((view|diff|edit|save|add|append|upload|drop|search|archive)/(\&.*))?$"
"http://localhost:8080/$1"
ServerAdmin alex@alexschroeder\&.ch
ServerName transjovian\&.org
SSLEngine on
ProxyPassMatch "^/((view|diff|edit|save|add|append|upload|drop|search)/(\&.*))?$" "http://localhost:8080/$1"
</VirtualHost>
.fi
.RE
@@ -58,7 +55,7 @@ First, it manages the domain, getting the necessary certificates.\& It redirects
regular HTTP traffic from port 80 to port 443.\& It turns on the SSL engine for
port 443.\& It proxies the requests for Oddmu to port 8080.\& Importantly, it
doesn'\&t send \fIall\fR the requests to Oddmu.\& This allows us to still host static
files using the web server (see \fBServe static files\fR).\&
files using the web server.\&
.PP
This is what happens:
.PP
@@ -81,62 +78,10 @@ apachectl graceful
.fi
.RE
.PP
In a situation where Apache acts as a reverse proxy, you can prevent some
actions from being proxied.\& If you don'\&t want to allow strangers to make
changes, search or archive the site, use a limited setup like the following:
.PP
.nf
.RS 4
MDomain transjovian\&.org
MDCertificateAgreement accepted
ServerAdmin alex@alexschroeder\&.ch
<VirtualHost *:80>
ServerName transjovian\&.org
Redirect "/" "https://transjovian\&.org/"
</VirtualHost>
<VirtualHost *:443>
ServerName transjovian\&.org
SSLEngine on
ProxyPassMatch "^/(view/\&.*)?$" "http://localhost:8080/$1"
</VirtualHost>
.fi
.RE
.PP
You'\&ll need to edit the source pages some other way.\& Edit them locally and
upload them using rsync; edit them remotely using an editor that can do this;
use SSHFS to mount the remote directory locally for editing; use \fIstunnel\fR(8) to
access the remote wiki on the local port 8080 for editing.\& There are probably a
lot more such options available.\& All of them have the drawback that they'\&re
probably not easy to use when on a mobile phone.\&
.PP
.SS Allow HTTP for viewing
.PP
When looking at pages, you might want to allow HTTP since no password is
required.\& Therefore, proxy the read-only requests from the virtual host on port
80 to the wiki instead of redirecting them to port 443.\&
.PP
.nf
.RS 4
MDomain transjovian\&.org
MDCertificateAgreement accepted
ServerAdmin alex@alexschroeder\&.ch
<VirtualHost *:80>
ServerName transjovian\&.org
ProxyPassMatch "^/((view|diff|search|archive)/(\&.*))?$"
"http://localhost:8080/$1"
RedirectMatch "^/((edit|save|add|append|upload|drop)/(\&.*))?$"
"https://transjovian\&.org/$1"
</VirtualHost>
<VirtualHost *:443>
ServerName transjovian\&.org
SSLEngine on
ProxyPassMatch "^/((view|diff|edit|save|add|append|upload|drop|search|archive)/(\&.*))?$"
"http://localhost:8080/$1"
</VirtualHost>
.fi
.RE
To serve both HTTP and HTTPS, don'\&t redirect from the first virtual host to the
second instead just proxy to the wiki like you did for the second virtual
host: use a copy of the "ProxyPassMatch" directive instead of "RewriteEngine on"
and "RewriteRule".\&
.PP
.SS Using a Unix-domain Socket
.PP
@@ -144,72 +89,23 @@ Instead of having Oddmu listen on a TCP port, you can have it listen on a
Unix-domain socket.\& This requires socket activation.\& An example of configuring
the service is given in \fIoddmu.\&service(5)\fR.\&
.PP
To test just the unix domain socket, use \fIncat(1)\fR:
On the Apache side, you only need to change ProxyMatch directives.\& For instance:
.PP
.nf
.RS 4
echo -e "GET /view/index HTTP/1\&.1rnHost: localhostrnrn"
| ncat --unixsock /run/oddmu/oddmu\&.sock
.fi
.RE
.PP
On the Apache side, you can proxy to the socket directly.\& This sends all
requests to the socket:
.PP
.nf
.RS 4
ProxyPass "/" "unix:/run/oddmu/oddmu\&.sock|http://localhost/"
ProxyPassMatch "^/((view|diff|edit|save|add|append|upload|drop|search)/(\&.*))?$"
"unix:/run/oddmu/oddmu\&.sock|http://localhost/$1"
.fi
.RE
.PP
Now, all traffic between the web server and the wiki goes over the socket at
"/run/oddmu/oddmu.\&sock".\&
.PP
To test it on the command-line, use a tool like \fIcurl(1)\fR.\& Make sure to provide
the correct servername!\&
.PP
.nf
.RS 4
curl http://transjovian\&.org/view/index
.fi
.RE
.PP
You probably want to serve some static files as well (see \fBServe static files\fR).\&
In that case, you need to use the ProxyPassMatch directive.\&
.PP
.nf
.RS 4
ProxyPassMatch "^/((view|diff|edit|save|add|append|upload|drop|search|archive)/(\&.*))?$"
"unix:/run/oddmu/oddmu\&.sock|http://localhost/$1"
.fi
.RE
.PP
There'\&s a curious problem with this expression, however.\& If you use \fIcurl(1)\fR to
get the root path, Apache hangs:
.PP
.nf
.RS 4
curl http://transjovian\&.org/
.fi
.RE
.PP
A workaround is to add the redirect manually and drop the question-mark:
.PP
.nf
.RS 4
RedirectMatch "^/$" "/view/index"
ProxyPassMatch "^/((view|diff|edit|save|add|append|upload|drop|search|archive)/(\&.*))$"
"unix:/run/oddmu/oddmu\&.sock|http://localhost/$1"
.fi
.RE
.PP
If you know why this is happening, let me know.\&
.PP
.SS Access
.PP
Access control is not part of Oddmu.\& By default, the wiki is editable by all.\&
This is most likely not what you want unless you'\&re running it stand-alone,
unconnected to the Internet a personal memex on your laptop, for example.\&
unconnected to the Internet a person memex on your laptop, for example.\&
.PP
The following instructions create user accounts with passwords just for Oddmu.\&
These users are not real users on the web server and don'\&t have access to a
@@ -257,41 +153,6 @@ to your "<VirtualHost *:443>" section:
.fi
.RE
.PP
The way Oddmu handles subdirectories is that all files and directories are
visible, except for "hidden" files and directories (whose name starts with a
period).\& Specifically, do not rely on Apache to hide locations in subdirectories
from public view.\& Search reveals the existence of these pages and produces an
extract, even if users cannot follow the links.\& Archive links pack all the
subdirectories, including locations you may have hidden from view using Apache.\&
.PP
If you to treat subdirectories as separate sites, you need to set the
environment variable ODDMU_FILTER to a regular expression matching the those
directories.\& If search starts in a directory that doesn'\&t match the regular
expression, all directories matching the regular expression are excluded.\& See
\fIoddmu-filter\fR(7).\&
.PP
In the following example, ODDMU_FILTER is set to "^secret/".\&
.PP
http://transjovian.\&org/search/index?\&q=something does not search the "secret/"
directory and its subdirectories are excluded.\&
.PP
http://transjovian.\&org/search/secret/index?\&q=something searches just the
"secret" directory and its subdirectories.\&
.PP
You need to configure the web server to prevent access to the "secret/"
directory:
.PP
.nf
.RS 4
<LocationMatch "^/(edit|save|add|append|upload|drop|(view|search|archive)/secret)/">
AuthType Basic
AuthName "Password Required"
AuthUserFile /home/oddmu/\&.htpasswd
Require valid-user
</LocationMatch>
.fi
.RE
.PP
.SS Serve static files
.PP
If you want to serve static files as well, add a document root to your webserver
@@ -302,15 +163,15 @@ data files are.\& Apache does not serve files such as ".\&htpasswd".\&
.RS 4
DocumentRoot /home/oddmu
<Directory /home/oddmu>
Require all granted
Require all granted
</Directory>
.fi
.RE
.PP
Make sure that none of the subdirectories look like the wiki paths "/view/",
"/diff/", "/edit/", "/save/", "/add/", "/append/", "/upload/", "/drop/",
"/search/" or "/archive/".\& For example, create a file called "robots.\&txt"
containing the following, telling all robots that they'\&re not welcome.\&
"/diff/", "/edit/", "/save/", "/add/", "/append/", "/upload/", "/drop/" or
"/search/".\& For example, create a file called "robots.\&txt" containing the
following, telling all robots that they'\&re not welcome.\&
.PP
.nf
.RS 4
@@ -319,34 +180,12 @@ Disallow: /
.fi
.RE
.PP
Your site now serves "/robots.\&txt" without interfering with the wiki, and
without needing a wiki page.\&
You site now serves "/robots.\&txt" without interfering with the wiki, and without
needing a wiki page.\&
.PP
Another option would be to create a CSS file and use it with a <link> element in
all the templates instead of relying on the <style> element.\&
.PP
The "view.\&html" template would start as follows:
.PP
.nf
.RS 4
<!DOCTYPE html>
<html lang="{{\&.Language}}">
<head>
<meta charset="utf-8">
<meta name="format-detection" content="telephone=no">
<meta name="viewport" content="width=device-width">
<title>{{\&.Title}}</title>
<link href="/css/oddmu-2023\&.css" rel="stylesheet" />
<link rel="alternate" type="application/rss+xml" title="Alex Schroeder: {{\&.Title}}" href="/view/{{\&.Name}}\&.rss" />
</head>
.fi
.RE
.PP
In this case, "/css/oddmu-2023.\&css" would be the name of your stylesheet.\& If
your document root is "/home/oddmu", then the filename of your stylesheet would
have to be "/home/oddmu/css/oddmu-2023.\&css" for this to work.\&
.PP
.SS Different logins for different access rights
.PP
What if you have a site with various subdirectories and each subdirectory is for
@@ -366,8 +205,8 @@ This requires a valid login by the user "alex" or "berta":
.PP
.SS Private wikis
.PP
Based on the above, you can prevent people from \fIreading\fR the wiki.\& The location
must cover all the URLs in order to protect everything.\&
Based on the above, you can prevent people from \fIreading\fR the wiki.\& The
"LocationMatch" must cover all the URLs in order to protect everything.\&
.PP
.nf
.RS 4
@@ -390,7 +229,7 @@ such that ever domain acts as a reverse proxy to a different Oddmu instance.\&
.PP
.SH SEE ALSO
.PP
\fIoddmu\fR(1), \fIoddmu-filter\fR(7), \fIoddmu-nginx\fR(5)
\fIoddmu\fR(1)
.PP
"Apache Core Features".\&
https://httpd.\&apache.\&org/docs/current/mod/core.\&html

View File

@@ -21,27 +21,24 @@ HTTPS is not part of Oddmu. You probably want to configure this in your
webserver. I guess you could use stunnel, too. If you're using Apache, you can
use "mod_md" to manage your domain.
The examples below use the domain "transjovian.org" and the Apache installation
is the one that comes with Debian.
The site itself is configured in a file called
"/etc/apache2/sites-available/transjovian.conf" and a link points there from
In the example below, the site is configured in a file called
"/etc/apache2/sites-available/500-transjovian.conf" and a link poins there from
"/etc/apache2/sites-enabled". Create this link using _a2ensite_(1).
```
MDomain transjovian.org
MDCertificateAgreement accepted
ServerAdmin alex@alexschroeder.ch
<VirtualHost *:80>
ServerName transjovian.org
Redirect "/" "https://transjovian.org/"
ServerName transjovian.org
RewriteEngine on
RewriteRule ^/(.*) https://%{HTTP_HOST}/$1 [redirect]
</VirtualHost>
<VirtualHost *:443>
ServerName transjovian.org
SSLEngine on
ProxyPassMatch "^/((view|diff|edit|save|add|append|upload|drop|search|archive)/(.*))?$" \
"http://localhost:8080/$1"
ServerAdmin alex@alexschroeder.ch
ServerName transjovian.org
SSLEngine on
ProxyPassMatch "^/((view|diff|edit|save|add|append|upload|drop|search)/(.*))?$" "http://localhost:8080/$1"
</VirtualHost>
```
@@ -49,7 +46,7 @@ First, it manages the domain, getting the necessary certificates. It redirects
regular HTTP traffic from port 80 to port 443. It turns on the SSL engine for
port 443. It proxies the requests for Oddmu to port 8080. Importantly, it
doesn't send _all_ the requests to Oddmu. This allows us to still host static
files using the web server (see *Serve static files*).
files using the web server.
This is what happens:
@@ -64,58 +61,10 @@ Restart the server, gracefully:
apachectl graceful
```
In a situation where Apache acts as a reverse proxy, you can prevent some
actions from being proxied. If you don't want to allow strangers to make
changes, search or archive the site, use a limited setup like the following:
```
MDomain transjovian.org
MDCertificateAgreement accepted
ServerAdmin alex@alexschroeder.ch
<VirtualHost *:80>
ServerName transjovian.org
Redirect "/" "https://transjovian.org/"
</VirtualHost>
<VirtualHost *:443>
ServerName transjovian.org
SSLEngine on
ProxyPassMatch "^/(view/.*)?$" "http://localhost:8080/$1"
</VirtualHost>
```
You'll need to edit the source pages some other way. Edit them locally and
upload them using rsync; edit them remotely using an editor that can do this;
use SSHFS to mount the remote directory locally for editing; use _stunnel_(8) to
access the remote wiki on the local port 8080 for editing. There are probably a
lot more such options available. All of them have the drawback that they're
probably not easy to use when on a mobile phone.
## Allow HTTP for viewing
When looking at pages, you might want to allow HTTP since no password is
required. Therefore, proxy the read-only requests from the virtual host on port
80 to the wiki instead of redirecting them to port 443.
```
MDomain transjovian.org
MDCertificateAgreement accepted
ServerAdmin alex@alexschroeder.ch
<VirtualHost *:80>
ServerName transjovian.org
ProxyPassMatch "^/((view|diff|search|archive)/(.*))?$" \
"http://localhost:8080/$1"
RedirectMatch "^/((edit|save|add|append|upload|drop)/(.*))?$" \
"https://transjovian.org/$1"
</VirtualHost>
<VirtualHost *:443>
ServerName transjovian.org
SSLEngine on
ProxyPassMatch "^/((view|diff|edit|save|add|append|upload|drop|search|archive)/(.*))?$" \
"http://localhost:8080/$1"
</VirtualHost>
```
To serve both HTTP and HTTPS, don't redirect from the first virtual host to the
second instead just proxy to the wiki like you did for the second virtual
host: use a copy of the "ProxyPassMatch" directive instead of "RewriteEngine on"
and "RewriteRule".
## Using a Unix-domain Socket
@@ -123,60 +72,21 @@ Instead of having Oddmu listen on a TCP port, you can have it listen on a
Unix-domain socket. This requires socket activation. An example of configuring
the service is given in _oddmu.service(5)_.
To test just the unix domain socket, use _ncat(1)_:
On the Apache side, you only need to change ProxyMatch directives. For instance:
```
echo -e "GET /view/index HTTP/1.1\r\nHost: localhost\r\n\r\n" \
| ncat --unixsock /run/oddmu/oddmu.sock
```
On the Apache side, you can proxy to the socket directly. This sends all
requests to the socket:
```
ProxyPass "/" "unix:/run/oddmu/oddmu.sock|http://localhost/"
ProxyPassMatch "^/((view|diff|edit|save|add|append|upload|drop|search)/(.*))?$" \
"unix:/run/oddmu/oddmu.sock|http://localhost/$1"
```
Now, all traffic between the web server and the wiki goes over the socket at
"/run/oddmu/oddmu.sock".
To test it on the command-line, use a tool like _curl(1)_. Make sure to provide
the correct servername!
```
curl http://transjovian.org/view/index
```
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|diff|edit|save|add|append|upload|drop|search|archive)/(.*))?$" \
"unix:/run/oddmu/oddmu.sock|http://localhost/$1"
```
There's a curious problem with this expression, however. If you use _curl(1)_ to
get the root path, Apache hangs:
```
curl http://transjovian.org/
```
A workaround is to add the redirect manually and drop the question-mark:
```
RedirectMatch "^/$" "/view/index"
ProxyPassMatch "^/((view|diff|edit|save|add|append|upload|drop|search|archive)/(.*))$" \
"unix:/run/oddmu/oddmu.sock|http://localhost/$1"
```
If you know why this is happening, let me know.
## Access
Access control is not part of Oddmu. By default, the wiki is editable by all.
This is most likely not what you want unless you're running it stand-alone,
unconnected to the Internet a personal memex on your laptop, for example.
unconnected to the Internet a person memex on your laptop, for example.
The following instructions create user accounts with passwords just for Oddmu.
These users are not real users on the web server and don't have access to a
@@ -216,39 +126,6 @@ to your "<VirtualHost \*:443>" section:
</LocationMatch>
```
The way Oddmu handles subdirectories is that all files and directories are
visible, except for "hidden" files and directories (whose name starts with a
period). Specifically, do not rely on Apache to hide locations in subdirectories
from public view. Search reveals the existence of these pages and produces an
extract, even if users cannot follow the links. Archive links pack all the
subdirectories, including locations you may have hidden from view using Apache.
If you to treat subdirectories as separate sites, you need to set the
environment variable ODDMU_FILTER to a regular expression matching the those
directories. If search starts in a directory that doesn't match the regular
expression, all directories matching the regular expression are excluded. See
_oddmu-filter_(7).
In the following example, ODDMU_FILTER is set to "^secret/".
http://transjovian.org/search/index?q=something does not search the "secret/"
directory and its subdirectories are excluded.
http://transjovian.org/search/secret/index?q=something searches just the
"secret" directory and its subdirectories.
You need to configure the web server to prevent access to the "secret/"
directory:
```
<LocationMatch "^/(edit|save|add|append|upload|drop|(view|search|archive)/secret)/">
AuthType Basic
AuthName "Password Required"
AuthUserFile /home/oddmu/.htpasswd
Require valid-user
</LocationMatch>
```
## Serve static files
If you want to serve static files as well, add a document root to your webserver
@@ -258,46 +135,26 @@ data files are. Apache does not serve files such as ".htpasswd".
```
DocumentRoot /home/oddmu
<Directory /home/oddmu>
Require all granted
Require all granted
</Directory>
```
Make sure that none of the subdirectories look like the wiki paths "/view/",
"/diff/", "/edit/", "/save/", "/add/", "/append/", "/upload/", "/drop/",
"/search/" or "/archive/". For example, create a file called "robots.txt"
containing the following, telling all robots that they're not welcome.
"/diff/", "/edit/", "/save/", "/add/", "/append/", "/upload/", "/drop/" or
"/search/". For example, create a file called "robots.txt" containing the
following, telling all robots that they're not welcome.
```
User-agent: *
Disallow: /
```
Your site now serves "/robots.txt" without interfering with the wiki, and
without needing a wiki page.
You site now serves "/robots.txt" without interfering with the wiki, and without
needing a wiki page.
Another option would be to create a CSS file and use it with a <link> element in
all the templates instead of relying on the <style> element.
The "view.html" template would start as follows:
```
<!DOCTYPE html>
<html lang="{{.Language}}">
<head>
<meta charset="utf-8">
<meta name="format-detection" content="telephone=no">
<meta name="viewport" content="width=device-width">
<title>{{.Title}}</title>
<link href="/css/oddmu-2023.css" rel="stylesheet" />
<link rel="alternate" type="application/rss+xml" title="Alex Schroeder: {{.Title}}" href="/view/{{.Name}}.rss" />
</head>
```
In this case, "/css/oddmu-2023.css" would be the name of your stylesheet. If
your document root is "/home/oddmu", then the filename of your stylesheet would
have to be "/home/oddmu/css/oddmu-2023.css" for this to work.
## Different logins for different access rights
What if you have a site with various subdirectories and each subdirectory is for
@@ -315,8 +172,8 @@ This requires a valid login by the user "alex" or "berta":
## Private wikis
Based on the above, you can prevent people from _reading_ the wiki. The location
must cover all the URLs in order to protect everything.
Based on the above, you can prevent people from _reading_ the wiki. The
"LocationMatch" must cover all the URLs in order to protect everything.
```
<Location />
@@ -337,7 +194,7 @@ such that ever domain acts as a reverse proxy to a different Oddmu instance.
# SEE ALSO
_oddmu_(1), _oddmu-filter_(7), _oddmu-nginx_(5)
_oddmu_(1)
"Apache Core Features".
https://httpd.apache.org/docs/current/mod/core.html

View File

@@ -1,63 +0,0 @@
.\" Generated by scdoc 1.11.3
.\" Complete documentation for this program is not available as a GNU info page
.ie \n(.g .ds Aq \(aq
.el .ds Aq '
.nh
.ad l
.\" Begin generated content:
.TH "ODDMU-FILTER" "7" "2024-02-19"
.PP
.SH NAME
.PP
oddmu-filter - keeping subdirectories separate
.PP
.SH DESCRIPTION
.PP
There are actions such as searching and archiving that act on multiple pages,
not just a single page.\& These actions walk the directory tree, including all
subdirectories.\& In some cases, this is not desirable.\&
.PP
Sometimes, subdirectories are separate sites, like the sites of other projects
or different people.\& Essentially, the subdirectory acts as a different site.\&
Depending on how you think about it, you might not want to include those "sites"
in searches or archives of the whole site.\&
.PP
What'\&s important in this situation is whether the visitor is looking at the
"main site" (a page further up in the directory tree) or at a particular page in
a "separate site".\&
.PP
Since directory tree actions always start in the directory the visitor is
currenly looking at, directory tree actions starting in a "separate site"
automatically act as expected.\& The action is limited to that subdirectory tree.\&
.PP
When visitors look at a page in the "main site", however, directory tree actions
must skip any sub directories that are part of a "separate site".\&
.PP
The way to identify separate sates is via the environment variable ODDMU_FILTER.\&
It'\&s value is a regular expression matching separate sites.\&
.PP
.SH EXAMPLES
.PP
"ODDMU_FILTER=^project/" means that a directory tree action outside the
"project/" directory does not include pages in the "project/" directory.\&
.PP
In other words, http://localhost:8080/search/?\&q=oddmu skips any pages in
"project/".\&
.PP
At the same time, http://localhost:8080/search/project/?\&q=oddmu works like it
always does: search is limited to "project/" and its subdirectories.\&
.PP
.SH SECURITY
.PP
If the subdirectory is a private site, then you need to use ODDMU_FILTER to
exclude it from directory tree actions in the main site, and you need to
configure your web server such that it doesn'\&t allow visitors access to the
directory tree without authentication.\&
.PP
.SH SEE ALSO
.PP
\fIoddmu\fR(1), \fIoddmu-search\fR(7), \fIoddmu-apache\fR(5), \fIoddmu-nginx\fR(5)
.PP
.SH AUTHORS
.PP
Maintained by Alex Schroeder <alex@gnu.\&org>.\&

View File

@@ -1,56 +0,0 @@
ODDMU-FILTER(7)
# NAME
oddmu-filter - keeping subdirectories separate
# DESCRIPTION
There are actions such as searching and archiving that act on multiple pages,
not just a single page. These actions walk the directory tree, including all
subdirectories. In some cases, this is not desirable.
Sometimes, subdirectories are separate sites, like the sites of other projects
or different people. Essentially, the subdirectory acts as a different site.
Depending on how you think about it, you might not want to include those "sites"
in searches or archives of the whole site.
What's important in this situation is whether the visitor is looking at the
"main site" (a page further up in the directory tree) or at a particular page in
a "separate site".
Since directory tree actions always start in the directory the visitor is
currenly looking at, directory tree actions starting in a "separate site"
automatically act as expected. The action is limited to that subdirectory tree.
When visitors look at a page in the "main site", however, directory tree actions
must skip any sub directories that are part of a "separate site".
The way to identify separate sates is via the environment variable ODDMU_FILTER.
It's value is a regular expression matching separate sites.
# EXAMPLES
"ODDMU_FILTER=^project/" means that a directory tree action outside the
"project/" directory does not include pages in the "project/" directory.
In other words, http://localhost:8080/search/?q=oddmu skips any pages in
"project/".
At the same time, http://localhost:8080/search/project/?q=oddmu works like it
always does: search is limited to "project/" and its subdirectories.
# SECURITY
If the subdirectory is a private site, then you need to use ODDMU_FILTER to
exclude it from directory tree actions in the main site, and you need to
configure your web server such that it doesn't allow visitors access to the
directory tree without authentication.
# SEE ALSO
_oddmu_(1), _oddmu-search_(7), _oddmu-apache_(5), _oddmu-nginx_(5)
# AUTHORS
Maintained by Alex Schroeder <alex@gnu.org>.

View File

@@ -1,11 +1,11 @@
.\" Generated by scdoc 1.11.3
.\" Generated by scdoc 1.11.2
.\" Complete documentation for this program is not available as a GNU info page
.ie \n(.g .ds Aq \(aq
.el .ds Aq '
.nh
.ad l
.\" Begin generated content:
.TH "ODDMU-HTML" "1" "2024-02-26"
.TH "ODDMU-HTML" "1" "2023-10-09"
.PP
.SH NAME
.PP

View File

@@ -1,11 +1,11 @@
.\" Generated by scdoc 1.11.3
.\" Generated by scdoc 1.11.2
.\" Complete documentation for this program is not available as a GNU info page
.ie \n(.g .ds Aq \(aq
.el .ds Aq '
.nh
.ad l
.\" Begin generated content:
.TH "ODDMU-LIST" "1" "2024-02-24"
.TH "ODDMU-LIST" "1" "2023-12-20"
.PP
.SH NAME
.PP
@@ -13,7 +13,7 @@ oddmu-list - list page names and titles from the command-line
.PP
.SH SYNOPSIS
.PP
\fBoddmu list\fR [-dir \fIstring\fR]
\fBoddmu list\fR [-dir string]
.PP
.SH DESCRIPTION
.PP

View File

@@ -6,7 +6,7 @@ oddmu-list - list page names and titles from the command-line
# SYNOPSIS
*oddmu list* [-dir _string_]
*oddmu list* [-dir string]
# DESCRIPTION

View File

@@ -1,11 +1,11 @@
.\" Generated by scdoc 1.11.3
.\" Generated by scdoc 1.11.2
.\" Complete documentation for this program is not available as a GNU info page
.ie \n(.g .ds Aq \(aq
.el .ds Aq '
.nh
.ad l
.\" Begin generated content:
.TH "ODDMU-MISSING" "1" "2024-02-17"
.TH "ODDMU-MISSING" "1" "2023-11-05"
.PP
.SH NAME
.PP

View File

@@ -1,91 +0,0 @@
.\" Generated by scdoc 1.11.3
.\" Complete documentation for this program is not available as a GNU info page
.ie \n(.g .ds Aq \(aq
.el .ds Aq '
.nh
.ad l
.\" Begin generated content:
.TH "ODDMU-NGINX" "5" "2024-02-19"
.PP
.SH NAME
.PP
oddmu-nginx - how to setup Nginx as a reverse proxy for Oddmu
.PP
.SS DESCRIPTION
.PP
The oddmu program serves the current working directory as a wiki on port 8080.\&
This is an unpriviledged port so an ordinary user account can do this.\&
.PP
This page explains how to setup NGINX on Debian to act as a reverse proxy for
Oddmu.\& Once this is done, you can use NGINX to provide HTTPS, request users to
authenticate themselves, and so on.\&
.PP
.SS CONFIGURATION
.PP
The site is defined in "/etc/nginx/sites-available/default", in the \fIserver\fR
section.\& Add a new \fIlocation\fR section after the existing \fIlocation\fR section:
.PP
.nf
.RS 4
location ~ ^/(view|diff|edit|save|add|append|upload|drop|search|archive)/ {
proxy_pass http://localhost:8080;
}
.fi
.RE
.PP
If you remove an action from the regular expression, those requests no longer
get passed on to Oddmu.\& They are essentially disabled.\& Somebody on the same
machine pointing their browser at http://localhost:8080/ directly would still
have access to all the actions, of course.\&
.PP
To restrict access to some actions, use two different \fIlocation\fR sections:
.PP
.nf
.RS 4
# public
location ~ ^/(view|diff|search)/ {
proxy_pass http://localhost:8080;
}
# password required
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;
}
.fi
.RE
.PP
The passwords in "/etc/nginx/conf.\&d/htpasswd" are generated using \fIopenssl\fR(1).\&
Assuming the password is "CPTk&qO[Y@?\&M~L>qKOkd", this is how you encrypt it:
.PP
.nf
.RS 4
openssl passwd \&'CPTk&qO[Y@?M~L>qKOkd\&'
.fi
.RE
.PP
The output gets used in "/etc/nginx/conf.\&d/htpasswd".\& Here'\&s the user "alex"
using this password:
.PP
.nf
.RS 4
alex:$1$DOwphABk$W4VmR9p8t2\&.htxF6ctXHX\&.
.fi
.RE
.PP
.SH SEE ALSO
.PP
\fIoddmu\fR(1), \fIoddmu-apache\fR(5)
.PP
"freenginx"
http://freenginx.\&org/
.PP
"freenginx ngx_http_proxy_module", proxy_pass
http://freenginx.\&org/en/docs/http/ngx_http_proxy_module.\&html#proxy_pass
.PP
"freenginx ngx_http_auth_basic_module"
http://freenginx.\&org/en/docs/http/ngx_http_auth_basic_module.\&html
.PP
.SH AUTHORS
.PP
Maintained by Alex Schroeder <alex@gnu.\&org>.\&

View File

@@ -1,76 +0,0 @@
ODDMU-NGINX(5)
# NAME
oddmu-nginx - how to setup Nginx as a reverse proxy for Oddmu
## DESCRIPTION
The oddmu program serves the current working directory as a wiki on port 8080.
This is an unpriviledged port so an ordinary user account can do this.
This page explains how to setup NGINX on Debian to act as a reverse proxy for
Oddmu. Once this is done, you can use NGINX to provide HTTPS, request users to
authenticate themselves, and so on.
## CONFIGURATION
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|diff|edit|save|add|append|upload|drop|search|archive)/ {
proxy_pass http://localhost:8080;
}
```
If you remove an action from the regular expression, those requests no longer
get passed on to Oddmu. They are essentially disabled. Somebody on the same
machine pointing their browser at http://localhost:8080/ directly would still
have access to all the actions, of course.
To restrict access to some actions, use two different _location_ sections:
```
# public
location ~ ^/(view|diff|search)/ {
proxy_pass http://localhost:8080;
}
# password required
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;
}
```
The passwords in "/etc/nginx/conf.d/htpasswd" are generated using _openssl_(1).
Assuming the password is "CPTk&qO[Y@?M~L>qKOkd", this is how you encrypt it:
```
openssl passwd 'CPTk&qO[Y@?M~L>qKOkd'
```
The output gets used in "/etc/nginx/conf.d/htpasswd". Here's the user "alex"
using this password:
```
alex:$1$DOwphABk$W4VmR9p8t2.htxF6ctXHX.
```
# SEE ALSO
_oddmu_(1), _oddmu-apache_(5)
"freenginx"
http://freenginx.org/
"freenginx ngx_http_proxy_module", proxy_pass
http://freenginx.org/en/docs/http/ngx_http_proxy_module.html#proxy_pass
"freenginx ngx_http_auth_basic_module"
http://freenginx.org/en/docs/http/ngx_http_auth_basic_module.html
# AUTHORS
Maintained by Alex Schroeder <alex@gnu.org>.

View File

@@ -1,11 +1,11 @@
.\" Generated by scdoc 1.11.3
.\" Generated by scdoc 1.11.2
.\" Complete documentation for this program is not available as a GNU info page
.ie \n(.g .ds Aq \(aq
.el .ds Aq '
.nh
.ad l
.\" Begin generated content:
.TH "ODDMU-NOTIFY" "1" "2024-02-17"
.TH "ODDMU-NOTIFY" "1" "2024-01-17"
.PP
.SH NAME
.PP

View File

@@ -1,177 +0,0 @@
.\" Generated by scdoc 1.11.3
.\" Complete documentation for this program is not available as a GNU info page
.ie \n(.g .ds Aq \(aq
.el .ds Aq '
.nh
.ad l
.\" Begin generated content:
.TH "ODDMU-RELEASES" "7" "2024-02-26"
.PP
.SH NAME
.PP
oddmu-releases - what'\&s new in this releases?\&
.PP
.SH DESCRIPTION
.PP
This page lists user-visible features and template changes to consider.\&
.PP
.SS 1.8 (2024)
.PP
No user-visible changes.\& Documentation and code comments got better.\&
.PP
.SS 1.7 (2024)
.PP
Allow upload of multiple files.\& This requires an update to the \fIupload.\&html\fR
template: Add the \fImultiple\fR attribute to the file input element and change the
label from "file" to "files".\&
.PP
Fix orientation of uploaded images.\& JPG and HEIC images have EXIF data telling a
viewer how to orient the image.\& Oddmu now uses this information to rotate the
image correctly before stripping it.\&
.PP
The version command now displays much less information unless given the -full
argument.\&
.PP
.SS 1.6 (2024)
.PP
Add \fIarchive\fR action to serve a zip file.\&
.PP
.SS 1.5 (2024)
.PP
Filtering separate sites in subdirectories via the ODDMU_FILTER environment
variable in order to exclude them from the \fIsearch\fR action.\&
.PP
Add \fIversion\fR subcommand.\&
.PP
Add filesystem watchers to automatically reindex changed pages and reload
changed templates.\&
.PP
When rendering a page, use templates in the same directory, if available.\&
.PP
Delete uploaded files by uploading a file with zero bytes.\&
.PP
.SS 1.4 (2024)
.PP
If stdin is a Unix-domain socket, use that to serve the site.\& Otherwise, allow
specifying a listen address via the ODDMU_ADDRESS environment variable.\&
.PP
.SS 1.3 (2024)
.PP
Add support for resizing HEIC images (and saving them as JPG files).\&
.PP
.SS 1.2 (2023)
.PP
Add \fIlist\fR subcommand.\&
.PP
.SS 1.1 (2023)
.PP
Rewrote most of the README into man pages.\&
.PP
Add fediverse account rendering if ODDMU_WEBFINGER is set.\&
.PP
Add notifications when saving files: adding links to \fIindex\fR, \fIchanges\fR and
\fIhashtag\fR pages.\&
.PP
Add \fIreplace\fR subcommand.\& Add \fImissing\fR subcommand.\& Add \fInotify\fR command.\& Add
\fIstatic\fR command.\&
.PP
Add \fIdiff\fR action.\&
.PP
Add feed generation based on the local links from a page.\&
.PP
Add caching support by considering the If-Modified-Since header in requests and
providing a Last-Modified header in responses.\&
.PP
Handle HEAD requests.\&
.PP
Remove HTML sanitization.\&
.PP
Remove MathJax support from the wiki parser.\& The templates never included the
necessary MathJax JavaScript anyway so the special handling of $ was just an
annoyance.\&
.PP
Drop trigram index and just search all the files.\& This takes much less RAM and
doesn'\&t take too much time even with a few thousand pages.\&
.PP
Add "blog:true" and "blog:false" predicates to search.\&
.PP
Limit search to the current directory tree.\&
.PP
Do not overwrite fresh backups: there must be a 1h break before the backup is
overwritten.\&
.PP
.SS 1.0 (2023)
.PP
Paginate search results and no longer sort search results by score.\&
.PP
.SS 0.9 (2023)
.PP
Add image resizing.\&
.PP
Add wiki links in double square brackets to the parser.\&
.PP
.SS 0.8 (2023)
.PP
Rename files to backups before saving.\&
.PP
Rename the \fIsaveUpload\fR action to \fIdrop\fR.\&
.PP
Add the \fIsearch\fR subcommand.\&
.PP
.SS 0.7 (2023)
.PP
Add \fIupload\fR and \fIsaveUpload\fR action so that one can upload files.\&
.PP
Add \fIhtml\fR subcommand.\&
.PP
.SS 0.6 (2003)
.PP
Add \fIadd\fR and \fIappend\fR action so that one can add to an existing page.\& This is
important for me as editing pages on the phone can be cumbersome but leaving
comments on my own site has always been easy to do.\&
.PP
Serve all existing files, not just text files.\&
.PP
Save an empty page to delete it.\&
.PP
Changed default permissions from 600 to 644 for files and from 700 to 755 for
directories.\&
.PP
Make language detection configurable using an environment variable.\&
.PP
.SS 0.5 (2023)
.PP
Add hyphenation to templates using Peter M.\& Stahl'\&s Lingua library.\&
.PP
.SS 0.4 (2023)
.PP
Create subdirectories as necessary.\&
.PP
.SS 0.3 (2023)
.PP
Add \fIsearch\fR action using Damian Gryski'\&s trigram indexing, with scoring,
highlighting and snippet extraction.\&
.PP
.SS 0.2 (2023)
.PP
Switch to Krzysztof Kowalczyk'\&s Go Markdown fork of Blackfriday to render
Markdown.\& Use Dee'\&s Bluemonday to sanitize HTML.\&
.PP
Switch to GNU Affero GPL 3 license.\&
.PP
Serve text files (.\&txt).\&
.PP
Support serving on any port via the environment variable ODDMU_PORT.\&
.PP
.SS 0.1 (2015)
.PP
A web server that allows editing files in Wiki Creole Matt Self'\&s Cajun library.\&
Supported actions are \fIedit\fR, \fIsave\fR, and \fIview\fR.\&
.PP
.SH SEE ALSO
.PP
\fIoddmu\fR(1)
.PP
.SH AUTHORS
.PP
Maintained by Alex Schroeder <alex@gnu.\&org>.\&

View File

@@ -1,170 +0,0 @@
ODDMU-RELEASES(7)
# NAME
oddmu-releases - what's new in this releases?
# DESCRIPTION
This page lists user-visible features and template changes to consider.
## 1.8 (2024)
No user-visible changes. Documentation and code comments got better.
## 1.7 (2024)
Allow upload of multiple files. This requires an update to the _upload.html_
template: Add the _multiple_ attribute to the file input element and change the
label from "file" to "files".
Fix orientation of uploaded images. JPG and HEIC images have EXIF data telling a
viewer how to orient the image. Oddmu now uses this information to rotate the
image correctly before stripping it.
The version command now displays much less information unless given the -full
argument.
## 1.6 (2024)
Add _archive_ action to serve a zip file.
## 1.5 (2024)
Filtering separate sites in subdirectories via the ODDMU_FILTER environment
variable in order to exclude them from the _search_ action.
Add _version_ subcommand.
Add filesystem watchers to automatically reindex changed pages and reload
changed templates.
When rendering a page, use templates in the same directory, if available.
Delete uploaded files by uploading a file with zero bytes.
## 1.4 (2024)
If stdin is a Unix-domain socket, use that to serve the site. Otherwise, allow
specifying a listen address via the ODDMU_ADDRESS environment variable.
## 1.3 (2024)
Add support for resizing HEIC images (and saving them as JPG files).
## 1.2 (2023)
Add _list_ subcommand.
## 1.1 (2023)
Rewrote most of the README into man pages.
Add fediverse account rendering if ODDMU_WEBFINGER is set.
Add notifications when saving files: adding links to _index_, _changes_ and
_hashtag_ pages.
Add _replace_ subcommand. Add _missing_ subcommand. Add _notify_ command. Add
_static_ command.
Add _diff_ action.
Add feed generation based on the local links from a page.
Add caching support by considering the If-Modified-Since header in requests and
providing a Last-Modified header in responses.
Handle HEAD requests.
Remove HTML sanitization.
Remove MathJax support from the wiki parser. The templates never included the
necessary MathJax JavaScript anyway so the special handling of $ was just an
annoyance.
Drop trigram index and just search all the files. This takes much less RAM and
doesn't take too much time even with a few thousand pages.
Add "blog:true" and "blog:false" predicates to search.
Limit search to the current directory tree.
Do not overwrite fresh backups: there must be a 1h break before the backup is
overwritten.
## 1.0 (2023)
Paginate search results and no longer sort search results by score.
## 0.9 (2023)
Add image resizing.
Add wiki links in double square brackets to the parser.
## 0.8 (2023)
Rename files to backups before saving.
Rename the _saveUpload_ action to _drop_.
Add the _search_ subcommand.
## 0.7 (2023)
Add _upload_ and _saveUpload_ action so that one can upload files.
Add _html_ subcommand.
## 0.6 (2003)
Add _add_ and _append_ action so that one can add to an existing page. This is
important for me as editing pages on the phone can be cumbersome but leaving
comments on my own site has always been easy to do.
Serve all existing files, not just text files.
Save an empty page to delete it.
Changed default permissions from 600 to 644 for files and from 700 to 755 for
directories.
Make language detection configurable using an environment variable.
## 0.5 (2023)
Add hyphenation to templates using Peter M. Stahl's Lingua library.
## 0.4 (2023)
Create subdirectories as necessary.
## 0.3 (2023)
Add _search_ action using Damian Gryski's trigram indexing, with scoring,
highlighting and snippet extraction.
## 0.2 (2023)
Switch to Krzysztof Kowalczyk's Go Markdown fork of Blackfriday to render
Markdown. Use Dee's Bluemonday to sanitize HTML.
Switch to GNU Affero GPL 3 license.
Serve text files (.txt).
Support serving on any port via the environment variable ODDMU_PORT.
## 0.1 (2015)
A web server that allows editing files in Wiki Creole Matt Self's Cajun library.
Supported actions are _edit_, _save_, and _view_.
# SEE ALSO
_oddmu_(1)
# AUTHORS
Maintained by Alex Schroeder <alex@gnu.org>.

View File

@@ -1,11 +1,11 @@
.\" Generated by scdoc 1.11.3
.\" Generated by scdoc 1.11.2
.\" Complete documentation for this program is not available as a GNU info page
.ie \n(.g .ds Aq \(aq
.el .ds Aq '
.nh
.ad l
.\" Begin generated content:
.TH "ODDMU-REPLACE" "1" "2024-02-17"
.TH "ODDMU-REPLACE" "1" "2023-11-24"
.PP
.SH NAME
.PP

View File

@@ -1,11 +1,11 @@
.\" Generated by scdoc 1.11.3
.\" Generated by scdoc 1.11.2
.\" Complete documentation for this program is not available as a GNU info page
.ie \n(.g .ds Aq \(aq
.el .ds Aq '
.nh
.ad l
.\" Begin generated content:
.TH "ODDMU-SEARCH" "1" "2024-02-17"
.TH "ODDMU-SEARCH" "1" "2023-12-20"
.PP
.SH NAME
.PP

View File

@@ -1,16 +1,20 @@
.\" Generated by scdoc 1.11.3
.\" Generated by scdoc 1.11.2
.\" Complete documentation for this program is not available as a GNU info page
.ie \n(.g .ds Aq \(aq
.el .ds Aq '
.nh
.ad l
.\" Begin generated content:
.TH "ODDMU-SEARCH" "7" "2024-02-19"
.TH "ODDMU-SEARCH" "7" "2023-10-28"
.PP
.SH NAME
.PP
oddmu-search - understanding the Oddmu search engine
.PP
.SH SYNOPSIS
.PP
\fBoddmu search\fR \fIterms\fR.\&.\&.\&
.PP
.SH DESCRIPTION
.PP
The wiki keeps an index of all the hash tags and page titles in memory.\& Using
@@ -85,22 +89,9 @@ A document with content "This is a test" when searched with the phrase "this
test" therefore gets a score of 8: the entire phrase does not match but each
word gets four points.\&
.PP
.SH ENVIRONMENT
.PP
To exclude subdirectories from searches, use the ODDMU_FILTER environment
variable.\& Set it to a regular expression matching sub-directories such as
"^projects/".\& If search starts in a directory matching the regular expression,
it is limited to the directory tree, as always.\& However, if search starts in a
directory that doesn'\&t match, subdirectories that do match are skipped.\& See
\fIoddmu-filter\fR(7).\&
.PP
To prevent access to a private directory tree, you must configure the web server
in addition to setting the ODDMU_FILTER environment variable.\&
.PP
.SH SEE ALSO
.PP
\fIoddmu\fR(1), \fIoddmu-search\fR(1), \fIoddmu-filter\fR(7), \fIoddmu-apache\fR(5),
\fIoddmu-nginx\fR(5)
\fIoddmu\fR(1), \fIoddmu-search\fR(1)
.PP
.SH AUTHORS
.PP

View File

@@ -4,6 +4,10 @@ ODDMU-SEARCH(7)
oddmu-search - understanding the Oddmu search engine
# SYNOPSIS
*oddmu search* _terms_...
# DESCRIPTION
The wiki keeps an index of all the hash tags and page titles in memory. Using
@@ -65,22 +69,9 @@ A document with content "This is a test" when searched with the phrase "this
test" therefore gets a score of 8: the entire phrase does not match but each
word gets four points.
# ENVIRONMENT
To exclude subdirectories from searches, use the ODDMU_FILTER environment
variable. Set it to a regular expression matching sub-directories such as
"^projects/". If search starts in a directory matching the regular expression,
it is limited to the directory tree, as always. However, if search starts in a
directory that doesn't match, subdirectories that do match are skipped. See
_oddmu-filter_(7).
To prevent access to a private directory tree, you must configure the web server
in addition to setting the ODDMU_FILTER environment variable.
# SEE ALSO
_oddmu_(1), _oddmu-search_(1), _oddmu-filter_(7), _oddmu-apache_(5),
_oddmu-nginx_(5)
_oddmu_(1), _oddmu-search_(1)
# AUTHORS

View File

@@ -1,11 +1,11 @@
.\" Generated by scdoc 1.11.3
.\" Generated by scdoc 1.11.2
.\" Complete documentation for this program is not available as a GNU info page
.ie \n(.g .ds Aq \(aq
.el .ds Aq '
.nh
.ad l
.\" Begin generated content:
.TH "ODDMU-STATIC" "1" "2024-02-26"
.TH "ODDMU-STATIC" "1" "2024-01-17"
.PP
.SH NAME
.PP
@@ -36,15 +36,10 @@ all those images, most of the time.\&
Note, however: Hard links cannot span filesystems.\& A hard link is just an extra
name for the same file.\& This is why the destination directory for the static
site has to be on same filesystem as the current directory, if it contains any
other files besides Markdown files.\&
other place.\&
.PP
Furthermore, in-place editing changes the file for all names.\& Avoid editing the
hard-linked files (anything that'\&s not a HTML file) in the destination
directory, just to be on the safe side.\& Usually you should be fine, as an editor
moves the file that'\&s being edited to a backup file and creates a new file.\& But
then again, who knows.\& A SQLite file, for example, would change in-place, and
therefore making changes to it in the destination directory would change the
original, too.\&
image files in the destination directory, just to be on the safe side.\&
.PP
.SH EXAMPLE
.PP

View File

@@ -29,15 +29,10 @@ all those images, most of the time.
Note, however: Hard links cannot span filesystems. A hard link is just an extra
name for the same file. This is why the destination directory for the static
site has to be on same filesystem as the current directory, if it contains any
other files besides Markdown files.
other place.
Furthermore, in-place editing changes the file for all names. Avoid editing the
hard-linked files (anything that's not a HTML file) in the destination
directory, just to be on the safe side. Usually you should be fine, as an editor
moves the file that's being edited to a backup file and creates a new file. But
then again, who knows. A SQLite file, for example, would change in-place, and
therefore making changes to it in the destination directory would change the
original, too.
image files in the destination directory, just to be on the safe side.
# EXAMPLE

View File

@@ -1,22 +1,16 @@
.\" Generated by scdoc 1.11.3
.\" Generated by scdoc 1.11.2
.\" Complete documentation for this program is not available as a GNU info page
.ie \n(.g .ds Aq \(aq
.el .ds Aq '
.nh
.ad l
.\" Begin generated content:
.TH "ODDMU-TEMPLATES" "5" "2024-02-17" "File Formats Manual"
.TH "ODDMU-TEMPLATES" "5" "2023-10-29" "File Formats Manual"
.PP
.SH NAME
.PP
oddmu-templates - how to write the templates
.PP
.SH SYNOPSIS
.PP
These files act as HTML templates: add.\&html, diff.\&html, edit.\&html, feed.\&html,
search.\&html, static.\&html, upload.\&html and view.\&html.\& They contain special
placeholders in double bracers {{like this}}.\&
.PP
.SH SYNTAX
.PP
The templates can refer to the following properties of a page:
@@ -30,9 +24,6 @@ extension.\&
.PP
\fI{{.\&Dir}}\fR is the page directory, percent-escaped except for the slashes.\&
.PP
\fI{{.\&Base}}\fR is the basename of the current file (without the directory and
without the \fI.\&md\fR extension), escaped for use in URLs.\&
.PP
\fI{{.\&Today}}\fR is the current date, in ISO format.\& This is useful for "new page"
like links or forms (see \fBEXAMPLE\fR below).\&
.PP
@@ -99,6 +90,25 @@ For items in the feed:
.PP
The \fIupload.\&html\fR template cannot refer to anything.\&
.PP
When calling the \fIsave\fR and \fIappend\fR action, the page name is taken from the URL
path and the page content is taken from the \fIbody\fR form parameter.\& To
illustrate, here'\&s how to edit the "welcome" page using \fIcurl\fR:
.PP
.nf
.RS 4
curl --form body="Did you bring a towel?"
http://localhost:8080/save/welcome
.fi
.RE
.PP
When calling the \fIsearch\fR action, the query is taken from the URL parameter \fIq\fR.\&
.PP
.nf
.RS 4
curl http://localhost:8080/search/?q=towel
.fi
.RE
.PP
.SS Non-English hyphenation
.PP
Automatic hyphenation by the browser requires two things: The style sheet must
@@ -149,22 +159,16 @@ The following form allows people to edit the suggested page name.\&
.PP
.SH NOTES
.PP
The templates are always used as-is, irrespective of the current directory.\&
The template are always used as-is, irrespective of the current directory.\&
Therefore, a link to a specific page must be \fIabsolute\fR or it'\&ll point to a
different page depending on the current directory.\&
.PP
Consider the link to "/view/index".\& No matter what page a visitor is looking,
this takes visitors to the top "index" page.\& If the link points to "index"
instead, it takes a visitor to the "index" page of the current directory.\& In
this case, a visitor looking at "/view/projects/wiki" following a link to
"index" ends up on "/view/projects/index", not on "/view/index".\&
instead, it takes a visitor to the "index" page of the current directory.\&
.PP
It'\&s up to you to decide what'\&s best for your site, of course.\&
.PP
Templates can be changed by uploading new copies of the template files.\&
.PP
Subdirectories can have their own copies of template files.\& One example use for
this is that they can point to a different CSS file.\&
Example: If a visitor is looking at "/view/projects/wiki" and follows a link to
"index", they end up on "/view/projects/index", not on "/view/index".\&
.PP
.SH SEE ALSO
.PP

View File

@@ -4,12 +4,6 @@ ODDMU-TEMPLATES(5) "File Formats Manual"
oddmu-templates - how to write the templates
# SYNOPSIS
These files act as HTML templates: add.html, diff.html, edit.html, feed.html,
search.html, static.html, upload.html and view.html. They contain special
placeholders in double bracers {{like this}}.
# SYNTAX
The templates can refer to the following properties of a page:
@@ -23,9 +17,6 @@ extension.
_{{.Dir}}_ is the page directory, percent-escaped except for the slashes.
_{{.Base}}_ is the basename of the current file (without the directory and
without the _.md_ extension), escaped for use in URLs.
_{{.Today}}_ is the current date, in ISO format. This is useful for "new page"
like links or forms (see *EXAMPLE* below).
@@ -92,6 +83,21 @@ _{{.Date}}_, the date of the last update to this page, in RFC 822 format.
The _upload.html_ template cannot refer to anything.
When calling the _save_ and _append_ action, the page name is taken from the URL
path and the page content is taken from the _body_ form parameter. To
illustrate, here's how to edit the "welcome" page using _curl_:
```
curl --form body="Did you bring a towel?" \
http://localhost:8080/save/welcome
```
When calling the _search_ action, the query is taken from the URL parameter _q_.
```
curl http://localhost:8080/search/?q=towel
```
## Non-English hyphenation
Automatic hyphenation by the browser requires two things: The style sheet must
@@ -138,22 +144,16 @@ The following form allows people to edit the suggested page name.
# NOTES
The templates are always used as-is, irrespective of the current directory.
The template are always used as-is, irrespective of the current directory.
Therefore, a link to a specific page must be _absolute_ or it'll point to a
different page depending on the current directory.
Consider the link to "/view/index". No matter what page a visitor is looking,
this takes visitors to the top "index" page. If the link points to "index"
instead, it takes a visitor to the "index" page of the current directory. In
this case, a visitor looking at "/view/projects/wiki" following a link to
"index" ends up on "/view/projects/index", not on "/view/index".
instead, it takes a visitor to the "index" page of the current directory.
It's up to you to decide what's best for your site, of course.
Templates can be changed by uploading new copies of the template files.
Subdirectories can have their own copies of template files. One example use for
this is that they can point to a different CSS file.
Example: If a visitor is looking at "/view/projects/wiki" and follows a link to
"index", they end up on "/view/projects/index", not on "/view/index".
# SEE ALSO

View File

@@ -1,38 +0,0 @@
.\" Generated by scdoc 1.11.3
.\" Complete documentation for this program is not available as a GNU info page
.ie \n(.g .ds Aq \(aq
.el .ds Aq '
.nh
.ad l
.\" Begin generated content:
.TH "ODDMU-VERSION" "1" "2024-02-23"
.PP
.SH NAME
.PP
oddmu-version - print build info on the command-line
.PP
.SH SYNOPSIS
.PP
\fBoddmu version\fR [-full]
.PP
.SH DESCRIPTION
.PP
The "version" subcommand prints information related to the version control
system state when it was built: what remote was used, what commit was checked
out, whether there were any local changes were made.\&
.PP
.SH OPTIONS
.PP
\fB-full\fR
.RS 4
Print a lot more information, including the versions of dependencies
used.\& It'\&s the equivalent of running "go version -m oddmu".\&
.PP
.RE
.SH SEE ALSO
.PP
\fIoddmu\fR(1)
.PP
.SH AUTHORS
.PP
Maintained by Alex Schroeder <alex@gnu.\&org>.\&

View File

@@ -1,29 +0,0 @@
ODDMU-VERSION(1)
# NAME
oddmu-version - print build info on the command-line
# SYNOPSIS
*oddmu version* [-full]
# DESCRIPTION
The "version" subcommand prints information related to the version control
system state when it was built: what remote was used, what commit was checked
out, whether there were any local changes were made.
# OPTIONS
*-full*
Print a lot more information, including the versions of dependencies
used. It's the equivalent of running "go version -m oddmu".
# SEE ALSO
_oddmu_(1)
# AUTHORS
Maintained by Alex Schroeder <alex@gnu.org>.

View File

@@ -1,11 +1,11 @@
.\" Generated by scdoc 1.11.3
.\" Generated by scdoc 1.11.2
.\" Complete documentation for this program is not available as a GNU info page
.ie \n(.g .ds Aq \(aq
.el .ds Aq '
.nh
.ad l
.\" Begin generated content:
.TH "ODDMU" "1" "2024-02-23"
.TH "ODDMU" "1" "2024-01-17"
.PP
.SH NAME
.PP
@@ -19,21 +19,14 @@ Oddmu is sometimes written Oddµ because µ is the letter mu.\&
.PP
.SH DESCRIPTION
.PP
Oddmu can be used as a static site generator, turning Markdown files into HTML
files, or it can be used as a public or a private wiki server.\& If it runs as a
public wiki server, a regular webserver should be used as reverse proxy.\&
The oddmu program serves the current working directory as a wiki on port 8080.\&
Point your browser to http://localhost:8080/ to get started.\& This is equivalent
to http://localhost:8080/view/index the first page you'\&ll create, most likely.\&
.PP
Run Oddmu without any arguments to serve the current working directory as a wiki
on port 8080.\& Point your browser to http://localhost:8080/ to use it.\& This
redirects you to http://localhost:8080/view/index the first page you'\&ll
create, most likely.\&
.PP
See \fIoddmu\fR(5) for details about the page formatting.\&
.PP
If you request a page that doesn'\&t exist, Oddmu tries to find a matching
If you request a page that doesn'\&t exist, oddmu tries to find a matching
Markdown file by appending the extension ".\&md" to the page name.\& In the example
above, the page name requested is "index" and the file name Oddmu tries to read
is "index.\&md".\& If no such file exists, Oddmu offers you to create the page.\&
above, the page name requested is "index" and the file name oddmu tries to read
is "index.\&md".\& If no such file exists, oddmu offers you to create the page.\&
.PP
If your files don'\&t provide their own title ("# title"), the file name (without
".\&md") is used for the page title.\&
@@ -44,126 +37,22 @@ feed items are based on links in bullet lists using the asterix
.PP
Subdirectories are created as necessary.\&
.PP
The wiki knows the following actions for a given page name and (optional)
directory:
.PP
.PD 0
.IP \(bu 4
\fI/\fR redirects to /view/index
.IP \(bu 4
\fI/view/dir/\fR redirects to /view/dir/index
.IP \(bu 4
\fI/view/dir/name\fR shows a page
.IP \(bu 4
\fI/view/dir/name.\&md\fR shows the source text of a page
.IP \(bu 4
\fI/view/dir/name.\&rss\fR shows the RSS feed for the pages linked
.IP \(bu 4
\fI/diff/dir/name\fR shows the last change to a page
.IP \(bu 4
\fI/edit/dir/name\fR shows a form to edit a page
.IP \(bu 4
\fI/save/dir/name\fR saves an edit
.IP \(bu 4
\fI/add/dir/name\fR shows a form to add to a page
.IP \(bu 4
\fI/append/dir/name\fR appends an addition to a page
.IP \(bu 4
\fI/upload/dir/name\fR shows a form to upload a file
.IP \(bu 4
\fI/drop/dir/name\fR saves an upload
.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
.PD
.PP
When calling the \fIsave\fR and \fIappend\fR action, the page name is taken from the URL
path and the page content is taken from the \fIbody\fR form parameter.\& To
illustrate, here'\&s how to edit the "welcome" page using \fIcurl\fR:
.PP
.nf
.RS 4
curl --form body="Did you bring a towel?"
http://localhost:8080/save/welcome
.fi
.RE
.PP
When calling the \fIdrop\fR action, the query parameters used are \fIname\fR for the
target filename, \fIfile\fR for the file to upload.\& If the query parameter
\fImaxwidth\fR is set, an attempt is made to decode and resize the image.\& JPG, PNG
and HEIC files can be decoded.\& Only JPG and PNG files can be encoded, however.\&
If the target name ends in \fI.\&jpg\fR, the \fIquality\fR query parameter is also taken
into account.\& To upload some thumbnails:
.PP
.nf
.RS 4
for f in *\&.jpg; do
curl --form name="$f" --form file=@"$f" --form maxwidth=100
http://localhost:8080/drop/
done
.fi
.RE
.PP
When calling the \fIsearch\fR action, the search terms are taken from the query
parameter \fIq\fR.\&
.PP
.nf
.RS 4
curl \&'http://localhost:8080/search/?q=towel\&'
.fi
.RE
.PP
The page name to act upon is optionally taken from the query parameter \fIid\fR.\& In
this case, the directory must also be part of the query parameter and not of the
URL path.\&
.PP
.nf
.RS 4
curl \&'http://localhost:8080/view/?id=man/oddmu\&.1\&.txt\&'
.fi
.RE
.PP
The base name for the \fIarchive\fR action is used by the browser to save the
downloaded file.\& For Oddmu, only the directory is important.\& The following zips
the \fIman\fR directory and saves it as \fIman.\&zip\fR.\&
.PP
.nf
.RS 4
curl --remote-name \&'http://localhost:8080/archive/man/man\&.zip
.fi
.RE
See \fIoddmu\fR(5) for details about the page formatting.\&
.PP
.SH CONFIGURATION
.PP
The template files are the HTML files in the working directory:
"add.\&html", "diff.\&html", "edit.\&html", "search.\&html", "upload.\&html" and
"view.\&html".\& Feel free to change the templates and restart the server.\&
.PP
.PD 0
.IP \(bu 4
\fIview.\&html\fR shows a page
.IP \(bu 4
\fIdiff.\&html\fR shows the last change to a page
.IP \(bu 4
\fIedit.\&html\fR shows a form to edit a page
.IP \(bu 4
\fIadd.\&html\fR shows a form to add to a page
.IP \(bu 4
\fIupload.\&html\fR shows a form to upload a file
.IP \(bu 4
\fIsearch.\&html\fR shows the search results
.IP \(bu 4
\fIstatic.\&html\fR is used to generate a static site
.IP \(bu 4
\fIfeed.\&html\fR is used to generate a RSS feed
.PD
The first change you should make is to replace the name and email
address in the footer of "view.\&html".\& Look for "Your Name" and
"example.\&org".\&
.PP
Please change the templates!\&
.PP
The first change you should make is to replace the name and email address in the
footer of \fIview.\&html\fR.\& Look for "Your Name" and "example.\&org".\&
.PP
The second change you should make is to replace the name, email address and
domain name in "feed.\&html".\& Look for "Your Name" and "example.\&org".\&
The second change you should make is to replace the name, email
address and domain name in "feed.\&html".\& Look for "Your Name" and
"example.\&org".\& This second template is used to generate the RSS feeds
(despite its ".\&html" extension).\&
.PP
See \fIoddmu-templates\fR(5) for more.\&
.PP
@@ -176,12 +65,8 @@ variable to either an IPv4 address or an IPv6 address.\& If ODDMU_ADDRESS is
unset, then the program listens on all available unicast addresses, both IPv4
and IPv6.\& Here are a few example addresses:
.PP
.nf
.RS 4
ODDMU_ADDRESS=127\&.0\&.0\&.1 # The loopback IPv4 address\&.
ODDMU_ADDRESS=2001:db8::3:1 # An IPv6 address\&.
.fi
.RE
ODDMU_ADDRESS=127.\&0.\&0.\&1 # The loopback IPv4 address.\&
ODDMU_ADDRESS=2001:db8::3:1 # An IPv6 address.\&
.PP
See the Socket Activation section for an alternative method of listening which
supports Unix-domain sockets.\&
@@ -193,12 +78,6 @@ codes, e.\&g.\& "en" or "en,de,fr,pt".\&
You can enable webfinger to link fediverse accounts to their correct profile
pages by setting ODDMU_WEBFINGER to "1".\& See \fIoddmu\fR(5).\&
.PP
If you use secret subdirectories, you cannot rely on the web server to hide
those pages because some actions such as searching and archiving include
subdirectories.\& They act upon a whole tree of pages, not just a single page.\& The
ODDMU_FILTER can be used to exclude subdirectories from such tree actions.\& See
\fIoddmu-filter\fR(7) and \fIoddmu-apache\fR(5).\&
.PP
.SH Socket Activation
.PP
Instead of specifying ODDMU_ADDRESS or ODDMU_PORT, you can start the service
@@ -224,7 +103,7 @@ For an extra dose of security, consider using a Unix-domain socket.\&
.PP
.SH OPTIONS
.PP
Oddmu can be run on the command-line using various subcommands.\&
The oddmu program can be run on the command-line using various subcommands.\&
.PP
.PD 0
.IP \(bu 4
@@ -275,7 +154,7 @@ on your point of view.\& See \fIoddmu-apache\fR(5).\&
.SH NOTES
.PP
Page names are filenames with ".\&md" appended.\& If your filesystem cannot handle
it, it can'\&t be a page name.\& Filenames can contain slashes and Oddmu creates
it, it can'\&t be a page name.\& Filenames can contain slashes and oddmu creates
subdirectories as necessary.\&
.PP
Files may not end with a tilde ('\&~'\&) these are backup files.\& When saving pages
@@ -330,53 +209,27 @@ A hashtag consists of a number sign ('\&#'\&) followed by Unicode letters, numbe
or the underscore ('\&_'\&).\& Thus, a hashtag ends with punctuation or whitespace.\&
.PP
The page names, titles and hashtags are loaded into memory when the server
starts.\& If you have a lot of pages, this takes a lot of memory.\&
.PP
Oddmu watches the working directory and any subdirectories for changes made
directly.\& Thus, in theory, it'\&s not necessary to restart it after making such
changes.\&
starts.\& If you have a lot of pages, this takes a lot of memory.\& If you change
the files while the wiki runs, changes to names (creating, renaming or deleting
files), titles or hashtags confuse Oddmu.\& Restart the program in order to
resolve this.\&
.PP
You cannot edit uploaded files.\& If you upload a file called "hello.\&txt" and
attempt to edit it by using "/edit/hello.\&txt" you create a page with the name
"hello.\&txt.\&md" instead.\&
.PP
In order to delete uploaded files via the web, create an empty file and upload
it.\& In order to delete a wiki page, save an empty page.\&
.PP
Note that some HTML file names are special: they act as templates.\& See
\fIoddmu-templates\fR(5) for their names and their use.\&
You cannot delete uploaded files via the web but you can delete regular wiki
pages by saving an empty page.\&
.PP
.SH SEE ALSO
.PP
.PD 0
.IP \(bu 4
\fIoddmu\fR(5), about the markup syntax and how feeds are generated based on link
lists
.IP \(bu 4
\fIoddmu-releases\fR(7), on what features are part of the latest release
.IP \(bu 4
\fIoddmu-filter\fR(7), on how to treat subdirectories as separate sites
.IP \(bu 4
\fIoddmu-search\fR(7), on how search works
.IP \(bu 4
\fIoddmu-templates\fR(5), on how to write the HTML templates
.PD
.PP
If you run Oddmu as a web server:
.PP
.PD 0
.IP \(bu 4
\fIoddmu-apache\fR(5), on how to set up Apache as a reverse proxy
.IP \(bu 4
\fIoddmu-nginx\fR(5), on how to set up freenginx as a reverse proxy
\fIoddmu\fR(5), about the markup syntax and how feeds are generated based on link lists
.IP \(bu 4
\fIoddmu.\&service\fR(5), on how to run the service under systemd
.PD
.PP
If you run Oddmu as a static site generator or pages offline and sync them with
Oddmu running as a webserver:
.PP
.PD 0
.IP \(bu 4
\fIoddmu-apache\fR(5), on how to set up a web server such as Apache
.IP \(bu 4
\fIoddmu-html\fR(1), on how to render a page from the command-line
.IP \(bu 4
@@ -384,16 +237,15 @@ Oddmu running as a webserver:
.IP \(bu 4
\fIoddmu-missing\fR(1), on how to find broken local links from the command-line
.IP \(bu 4
\fIoddmu-notify\fR(1), on updating index, changes and hashtag pages from the
command-line
.IP \(bu 4
\fIoddmu-replace\fR(1), on how to search and replace text from the command-line
.IP \(bu 4
\fIoddmu-search\fR(1), on how to run a search from the command-line
.IP \(bu 4
\fIoddmu-search\fR(7), on how search works
.IP \(bu 4
\fIoddmu-static\fR(1), on generating a static site from the command-line
.IP \(bu 4
\fIoddmu-version\fR(1), on how to get all the build information from the binary
\fIoddmu-templates\fR(5), on how to write the HTML templates
.PD
.PP
.SH AUTHORS

View File

@@ -12,21 +12,14 @@ Oddmu is sometimes written Oddµ because µ is the letter mu.
# DESCRIPTION
Oddmu can be used as a static site generator, turning Markdown files into HTML
files, or it can be used as a public or a private wiki server. If it runs as a
public wiki server, a regular webserver should be used as reverse proxy.
The oddmu program serves the current working directory as a wiki on port 8080.
Point your browser to http://localhost:8080/ to get started. This is equivalent
to http://localhost:8080/view/index the first page you'll create, most likely.
Run Oddmu without any arguments to serve the current working directory as a wiki
on port 8080. Point your browser to http://localhost:8080/ to use it. This
redirects you to http://localhost:8080/view/index the first page you'll
create, most likely.
See _oddmu_(5) for details about the page formatting.
If you request a page that doesn't exist, Oddmu tries to find a matching
If you request a page that doesn't exist, oddmu tries to find a matching
Markdown file by appending the extension ".md" to the page name. In the example
above, the page name requested is "index" and the file name Oddmu tries to read
is "index.md". If no such file exists, Oddmu offers you to create the page.
above, the page name requested is "index" and the file name oddmu tries to read
is "index.md". If no such file exists, oddmu offers you to create the page.
If your files don't provide their own title ("# title"), the file name (without
".md") is used for the page title.
@@ -37,90 +30,22 @@ feed items are based on links in bullet lists using the asterix
Subdirectories are created as necessary.
The wiki knows the following actions for a given page name and (optional)
directory:
- _/_ redirects to /view/index
- _/view/dir/_ redirects to /view/dir/index
- _/view/dir/name_ shows a page
- _/view/dir/name.md_ shows the source text of a page
- _/view/dir/name.rss_ shows the RSS feed for the pages linked
- _/diff/dir/name_ shows the last change to a page
- _/edit/dir/name_ shows a form to edit a page
- _/save/dir/name_ saves an edit
- _/add/dir/name_ shows a form to add to a page
- _/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
- _/search/dir/?q=term_ to search for a term
- _/archive/dir/name.zip_ to download a zip file of a directory
When calling the _save_ and _append_ action, the page name is taken from the URL
path and the page content is taken from the _body_ form parameter. To
illustrate, here's how to edit the "welcome" page using _curl_:
```
curl --form body="Did you bring a towel?" \
http://localhost:8080/save/welcome
```
When calling the _drop_ action, the query parameters used are _name_ for the
target filename, _file_ for the file to upload. If the query parameter
_maxwidth_ is set, an attempt is made to decode and resize the image. JPG, PNG
and HEIC files can be decoded. Only JPG and PNG files can be encoded, however.
If the target name ends in _.jpg_, the _quality_ query parameter is also taken
into account. To upload some thumbnails:
```
for f in *.jpg; do
curl --form name="$f" --form file=@"$f" --form maxwidth=100 \
http://localhost:8080/drop/
done
```
When calling the _search_ action, the search terms are taken from the query
parameter _q_.
```
curl 'http://localhost:8080/search/?q=towel'
```
The page name to act upon is optionally taken from the query parameter _id_. In
this case, the directory must also be part of the query parameter and not of the
URL path.
```
curl 'http://localhost:8080/view/?id=man/oddmu.1.txt'
```
The base name for the _archive_ action is used by the browser to save the
downloaded file. For Oddmu, only the directory is important. The following zips
the _man_ directory and saves it as _man.zip_.
```
curl --remote-name 'http://localhost:8080/archive/man/man.zip
```
See _oddmu_(5) for details about the page formatting.
# CONFIGURATION
The template files are the HTML files in the working directory:
"add.html", "diff.html", "edit.html", "search.html", "upload.html" and
"view.html". Feel free to change the templates and restart the server.
- _view.html_ shows a page
- _diff.html_ shows the last change to a page
- _edit.html_ shows a form to edit a page
- _add.html_ shows a form to add to a page
- _upload.html_ shows a form to upload a file
- _search.html_ shows the search results
- _static.html_ is used to generate a static site
- _feed.html_ is used to generate a RSS feed
The first change you should make is to replace the name and email
address in the footer of "view.html". Look for "Your Name" and
"example.org".
Please change the templates!
The first change you should make is to replace the name and email address in the
footer of _view.html_. Look for "Your Name" and "example.org".
The second change you should make is to replace the name, email address and
domain name in "feed.html". Look for "Your Name" and "example.org".
The second change you should make is to replace the name, email
address and domain name in "feed.html". Look for "Your Name" and
"example.org". This second template is used to generate the RSS feeds
(despite its ".html" extension).
See _oddmu-templates_(5) for more.
@@ -133,10 +58,8 @@ variable to either an IPv4 address or an IPv6 address. If ODDMU_ADDRESS is
unset, then the program listens on all available unicast addresses, both IPv4
and IPv6. Here are a few example addresses:
```
ODDMU_ADDRESS=127.0.0.1 # The loopback IPv4 address.
ODDMU_ADDRESS=2001:db8::3:1 # An IPv6 address.
```
ODDMU_ADDRESS=127.0.0.1 # The loopback IPv4 address.
ODDMU_ADDRESS=2001:db8::3:1 # An IPv6 address.
See the Socket Activation section for an alternative method of listening which
supports Unix-domain sockets.
@@ -148,12 +71,6 @@ codes, e.g. "en" or "en,de,fr,pt".
You can enable webfinger to link fediverse accounts to their correct profile
pages by setting ODDMU_WEBFINGER to "1". See _oddmu_(5).
If you use secret subdirectories, you cannot rely on the web server to hide
those pages because some actions such as searching and archiving include
subdirectories. They act upon a whole tree of pages, not just a single page. The
ODDMU_FILTER can be used to exclude subdirectories from such tree actions. See
_oddmu-filter_(7) and _oddmu-apache_(5).
# Socket Activation
Instead of specifying ODDMU_ADDRESS or ODDMU_PORT, you can start the service
@@ -179,7 +96,7 @@ For an extra dose of security, consider using a Unix-domain socket.
# OPTIONS
Oddmu can be run on the command-line using various subcommands.
The oddmu program can be run on the command-line using various subcommands.
- to generate the HTML for a single page, see _oddmu-html_(1)
- to generate the HTML for the entire site, using Oddmu as a static site
@@ -220,7 +137,7 @@ on your point of view. See _oddmu-apache_(5).
# NOTES
Page names are filenames with ".md" appended. If your filesystem cannot handle
it, it can't be a page name. Filenames can contain slashes and Oddmu creates
it, it can't be a page name. Filenames can contain slashes and oddmu creates
subdirectories as necessary.
Files may not end with a tilde ('~') these are backup files. When saving pages
@@ -275,49 +192,31 @@ A hashtag consists of a number sign ('#') followed by Unicode letters, numbers
or the underscore ('\_'). Thus, a hashtag ends with punctuation or whitespace.
The page names, titles and hashtags are loaded into memory when the server
starts. If you have a lot of pages, this takes a lot of memory.
Oddmu watches the working directory and any subdirectories for changes made
directly. Thus, in theory, it's not necessary to restart it after making such
changes.
starts. If you have a lot of pages, this takes a lot of memory. If you change
the files while the wiki runs, changes to names (creating, renaming or deleting
files), titles or hashtags confuse Oddmu. Restart the program in order to
resolve this.
You cannot edit uploaded files. If you upload a file called "hello.txt" and
attempt to edit it by using "/edit/hello.txt" you create a page with the name
"hello.txt.md" instead.
In order to delete uploaded files via the web, create an empty file and upload
it. In order to delete a wiki page, save an empty page.
Note that some HTML file names are special: they act as templates. See
_oddmu-templates_(5) for their names and their use.
You cannot delete uploaded files via the web but you can delete regular wiki
pages by saving an empty page.
# SEE ALSO
- _oddmu_(5), about the markup syntax and how feeds are generated based on link
lists
- _oddmu-releases_(7), on what features are part of the latest release
- _oddmu-filter_(7), on how to treat subdirectories as separate sites
- _oddmu-search_(7), on how search works
- _oddmu-templates_(5), on how to write the HTML templates
If you run Oddmu as a web server:
- _oddmu-apache_(5), on how to set up Apache as a reverse proxy
- _oddmu-nginx_(5), on how to set up freenginx as a reverse proxy
- _oddmu_(5), about the markup syntax and how feeds are generated based on link lists
- _oddmu.service_(5), on how to run the service under systemd
If you run Oddmu as a static site generator or pages offline and sync them with
Oddmu running as a webserver:
- _oddmu-apache_(5), on how to set up a web server such as Apache
- _oddmu-html_(1), on how to render a page from the command-line
- _oddmu-list_(1), on how to list pages and titles from the command-line
- _oddmu-missing_(1), on how to find broken local links from the command-line
- _oddmu-notify_(1), on updating index, changes and hashtag pages from the
command-line
- _oddmu-replace_(1), on how to search and replace text from the command-line
- _oddmu-search_(1), on how to run a search from the command-line
- _oddmu-search_(7), on how search works
- _oddmu-static_(1), on generating a static site from the command-line
- _oddmu-version_(1), on how to get all the build information from the binary
- _oddmu-templates_(5), on how to write the HTML templates
# AUTHORS

View File

@@ -1,11 +1,11 @@
.\" Generated by scdoc 1.11.3
.\" Generated by scdoc 1.11.2
.\" Complete documentation for this program is not available as a GNU info page
.ie \n(.g .ds Aq \(aq
.el .ds Aq '
.nh
.ad l
.\" Begin generated content:
.TH "ODDMU" "5" "2024-02-17" "File Formats Manual"
.TH "ODDMU" "5" "2023-11-12" "File Formats Manual"
.PP
.SH NAME
.PP

View File

@@ -1,11 +1,11 @@
.\" Generated by scdoc 1.11.3
.\" Generated by scdoc 1.11.2
.\" Complete documentation for this program is not available as a GNU info page
.ie \n(.g .ds Aq \(aq
.el .ds Aq '
.nh
.ad l
.\" Begin generated content:
.TH "ODDMU.SERVICE" "5" "2024-02-17"
.TH "ODDMU.SERVICE" "5" "2024-01-17"
.PP
.SH NAME
.PP

View File

@@ -1,62 +0,0 @@
package main
import (
"github.com/stretchr/testify/assert"
"io/fs"
"os"
"path/filepath"
"strings"
"testing"
)
func TestManPages(t *testing.T) {
b, err := os.ReadFile("man/oddmu.1.txt")
main := string(b)
assert.NoError(t, err)
filepath.Walk("man", func(path string, info fs.FileInfo, err error) error {
if err != nil {
return err
}
if strings.HasSuffix(path, ".txt") &&
path != "man/oddmu.1.txt" {
s := strings.TrimPrefix(path, "man/")
s = strings.TrimSuffix(s, ".txt")
i := strings.LastIndex(s, ".")
ref := "_" + s[:i] + "_(" + s[i+1:] + ")"
assert.Contains(t, main, ref, ref)
}
return nil
})
}
func TestReadme(t *testing.T) {
b, err := os.ReadFile("README.md")
main := string(b)
assert.NoError(t, err)
filepath.Walk("man", func(path string, info fs.FileInfo, err error) error {
if err != nil {
return err
}
if strings.HasSuffix(path, ".txt") {
s := strings.TrimPrefix(path, "man/")
s = strings.TrimSuffix(s, ".txt")
i := strings.LastIndex(s, ".")
ref := "[" + s[:i] + "(" + s[i+1:] + ")]"
assert.Contains(t, main, ref, ref)
}
return nil
})
filepath.Walk(".", func(path string, info fs.FileInfo, err error) error {
if err != nil {
return err
}
if strings.HasSuffix(path, ".go") &&
!strings.HasSuffix(path, "_test.go") &&
!strings.HasSuffix(path, "_cmd.go") {
s := strings.TrimPrefix(path, "./")
ref := "`" + s + "`"
assert.Contains(t, main, ref, ref)
}
return nil
})
}

View File

@@ -39,19 +39,15 @@ func missingCli(w io.Writer, args []string) subcommands.ExitStatus {
if err != nil {
return err
}
// skip hidden directories and files
if path != "." && strings.HasPrefix(filepath.Base(path), ".") {
if info.IsDir() {
return filepath.SkipDir
} else {
return nil
}
filename := path
if info.IsDir() || strings.HasPrefix(filename, ".") {
return nil
}
if strings.HasSuffix(path, ".md") {
name := filepath.ToSlash(strings.TrimSuffix(path, ".md"))
if strings.HasSuffix(filename, ".md") {
name := strings.TrimSuffix(filename, ".md")
names[name] = true
} else {
names[path] = false
names[filename] = false
}
return nil
})
@@ -66,13 +62,13 @@ func missingCli(w io.Writer, args []string) subcommands.ExitStatus {
}
p, err := loadPage(name)
if err != nil {
fmt.Fprintf(os.Stderr, "Loading %s: %s\n", p.Name, err)
fmt.Fprintf(os.Stderr, "Loading %s: %s\n", name, err)
return subcommands.ExitFailure
}
for _, link := range p.links() {
u, err := url.Parse(link)
if err != nil {
fmt.Fprintln(os.Stderr, p.Name, err)
fmt.Fprintln(os.Stderr, name, err)
return subcommands.ExitFailure
}
if u.Scheme == "" && u.Path != "" && !strings.HasPrefix(u.Path, "/") {
@@ -94,7 +90,7 @@ func missingCli(w io.Writer, args []string) subcommands.ExitStatus {
fmt.Fprintln(w, "Page\tMissing")
found = true
}
fmt.Fprintf(w, "%s\t%s\n", p.Name, link)
fmt.Fprintf(w, "%s\t%s\n", name, link)
}
}
}

74
page.go
View File

@@ -16,7 +16,7 @@ import (
// Page is a struct containing information about a single page. Title
// is the title extracted from the page content using titleRegexp.
// Name is the path without extension (so a path of "foo.md"
// Name is the filename without extension (so a filename of "foo.md"
// results in the Name "foo"). Body is the Markdown content of the
// page and Html is the rendered HTML for that Markdown. Score is a
// number indicating how well the page matched for a search query.
@@ -47,7 +47,8 @@ func unsafeBytes(bytes []byte) template.HTML {
return template.HTML(bytes)
}
// nameEscape returns the page name safe for use in URLs. That is, percent escaping is used except for the slashes.
// nameEscape returns the page name safe for use in URLs. That is,
// percent escaping is used except for the slashes.
func nameEscape(s string) string {
parts := strings.Split(s, "/")
for i, part := range parts {
@@ -56,60 +57,57 @@ func nameEscape(s string) string {
return strings.Join(parts, "/")
}
// save saves a Page. The path is based on the Page.Name and gets the ".md" extension. Page.Body is saved, without any
// carriage return characters ("\r"). Page.Title and Page.Html are not saved. There is no caching. Before removing or
// writing a file, the old copy is renamed to a backup, appending "~". Errors are not logged but returned.
// save saves a Page. The filename is based on the Page.Name and gets
// the ".md" extension. Page.Body is saved, without any carriage
// return characters ("\r"). Page.Title and Page.Html are not saved.
// There is no caching. Before removing or writing a file, the old
// copy is renamed to a backup, appending "~". There is no error
// checking for this.
func (p *Page) save() error {
fp := filepath.FromSlash(p.Name + ".md")
watches.ignore(fp)
filename := p.Name + ".md"
s := bytes.ReplaceAll(p.Body, []byte{'\r'}, []byte{})
if len(s) == 0 {
log.Println("Delete", p.Name)
index.remove(p)
return os.Rename(fp, fp+"~")
p.removeFromIndex()
return os.Rename(filename, filename+"~")
}
p.Body = s
index.update(p)
d := filepath.Dir(fp)
p.updateIndex()
d := filepath.Dir(filename)
if d != "." {
err := os.MkdirAll(d, 0755)
if err != nil {
log.Printf("Creating directory %s failed: %s", d, err)
return err
}
}
err := backup(fp)
if err != nil {
return err
}
return os.WriteFile(fp, s, 0644)
backup(filename)
return os.WriteFile(filename, s, 0644)
}
// backup a file by renaming (!) it unless the existing backup is less than an hour old. A backup gets a tilde appended
// to it ("~"). This is true even if the file refers to a binary file like "image.png" and most applications don't know
// what to do with a file called "image.png~". This expects a file path. Use filepath.FromSlash(path) if necessary.
func backup(fp string) error {
_, err := os.Stat(fp)
if err != nil {
return nil
}
bp := fp + "~"
fi, err := os.Stat(bp)
// what to do with a file called "image.png~".
func backup(filename string) error {
backup := filename + "~"
fi, err := os.Stat(backup)
if err != nil || time.Since(fi.ModTime()).Minutes() >= 60 {
return os.Rename(fp, bp)
return os.Rename(filename, backup)
}
return nil
}
// loadPage loads a Page given a name. The path loaded is that Page.Name with the ".md" extension. The Page.Title is set
// to the Page.Name (and possibly changed, later). The Page.Body is set to the file content. The Page.Html remains
// undefined (there is no caching).
func loadPage(path string) (*Page, error) {
path = strings.TrimPrefix(path, "./") // result of a filepath.TreeWalk starting with "."
body, err := os.ReadFile(filepath.FromSlash(path + ".md"))
// loadPage loads a Page given a name. The filename loaded is that
// Page.Name with the ".md" extension. The Page.Title is set to the
// Page.Name (and possibly changed, later). The Page.Body is set to
// the file content. The Page.Html remains undefined (there is no
// caching).
func loadPage(name string) (*Page, error) {
filename := name + ".md"
body, err := os.ReadFile(filename)
if err != nil {
return nil, err
}
return &Page{Title: path, Name: path, Body: body, Language: ""}, nil
return &Page{Title: name, Name: name, Body: body, Language: ""}, nil
}
// handleTitle extracts the title from a Page and sets Page.Title, if any. If replace is true, the page title is also
@@ -156,16 +154,6 @@ func (p *Page) Dir() string {
return d + "/"
}
// Base returns the basename of the page name: no directory and no extension. This is used to create the upload link
// in "view.html", for example.
func (p *Page) Base() string {
n := filepath.Base(p.Name)
if n == "." {
return ""
}
return n
}
// Today returns the date, as a string, for use in templates.
func (p *Page) Today() string {
return time.Now().Format(time.DateOnly)

View File

@@ -68,7 +68,7 @@ func wikiParser() (*parser.Parser, *[]string) {
fn, hashtags := hashtag()
parser.RegisterInline('#', fn)
if useWebfinger {
parser.RegisterInline('@', accountLink)
parser.RegisterInline('@', account)
}
return parser, hashtags
}

View File

@@ -59,16 +59,7 @@ func replaceCli(w io.Writer, isConfirmed bool, isRegexp bool, args []string) sub
if err != nil {
return err
}
// skip hidden directories and files
if path != "." && strings.HasPrefix(filepath.Base(path), ".") {
if info.IsDir() {
return filepath.SkipDir
} else {
return nil
}
}
// skipp all but page files
if !strings.HasSuffix(path, ".md") {
if info.IsDir() || strings.HasPrefix(path, ".") || !strings.HasSuffix(path, ".md") {
return nil
}
body, err := os.ReadFile(path)

View File

@@ -3,7 +3,6 @@ package main
import (
"log"
"net/http"
"os"
"path"
"regexp"
"slices"
@@ -94,17 +93,15 @@ const itemsPerPage = 20
// size is 20. Specify either the page number to return, or that all the results should be returned. Only ask for all
// results if runtime is not an issue, like on the command line. The boolean return value indicates whether there are
// more results.
func search(q, dir, filter string, page int, all bool) ([]*Page, bool) {
func search(q string, dir string, page int, all bool) ([]*Page, bool) {
if len(q) == 0 {
return make([]*Page, 0), false
}
names := index.search(q) // hashtags or all names
names = filterPath(names, dir, filter)
names = filterPrefix(names, dir)
predicates, terms := predicatesAndTokens(q)
names = filterNames(names, predicates)
index.RLock()
slices.SortFunc(names, sortNames(terms))
index.RUnlock()
names, keepFirst := prependQueryPage(names, dir, q)
from := itemsPerPage * (page - 1)
to := from + itemsPerPage - 1
@@ -116,27 +113,16 @@ func search(q, dir, filter string, page int, all bool) ([]*Page, bool) {
return items, more
}
// filterPath filters the names by prefix and by a regular expression. A prefix of "." means that all the names are
// returned, since this is what path.Dir returns for "no directory".
//
// The regular expression can be used to ensure that search does not descend into subdirectories unless the search
// already starts there. Given the pages a, public/b and secret/c and ODDMU_FILTER=^secret/ then if search starts in the
// root directory /, search does not enter secret/, but if search starts in secret/, search does search the pages in
// secret/ it us up to the web server to ensure access to secret/ is limited. More specifically: the page names must
// match the prefix, always; if prefix also matches the filter, this means the page names are all part of a "separate
// site"; if the prefix does not match the filter, then the page names must also not match the filter since only the
// "main site" is shown. If the filter is empty, all prefixes and all page names match, so no problem.
func filterPath(names []string, prefix, filter string) []string {
re, err := regexp.Compile(filter)
if err != nil {
log.Println("ODDMU_FILTER does not compile:", filter, err)
return []string{}
// filterPrefix filters the names by prefix. A prefix of "." means
// that all the names are returned, since this is what path.Dir
// returns for "no directory".
func filterPrefix(names []string, prefix string) []string {
if prefix == "." {
return names
}
matches := re.MatchString(prefix)
r := make([]string, 0)
for _, name := range names {
if strings.HasPrefix(name, prefix) &&
(matches || !re.MatchString(name)) {
if strings.HasPrefix(name, prefix) {
r = append(r, name)
}
}
@@ -239,20 +225,18 @@ func prependQueryPage(names []string, dir, q string) ([]string, bool) {
return names, false
}
// searchHandler presents a search result. It uses the query string in the form parameter "q" and the template
// "search.html". For each page found, the HTML is just an extract of the actual body. Search is limited to a directory
// and its subdirectories.
//
// A filter can be defined using the environment variable ODDMU_FILTER. It is passed on to search.
// searchHandler presents a search result. It uses the query string in
// the form parameter "q" and the template "search.html". For each
// page found, the HTML is just an extract of the actual body.
// Search is limited to a directory and its subdirectories.
func searchHandler(w http.ResponseWriter, r *http.Request, dir string) {
q := r.FormValue("q")
page, err := strconv.Atoi(r.FormValue("page"))
if err != nil {
page = 1
}
filter := os.Getenv("ODDMU_FILTER")
items, more := search(q, dir, filter, page, false)
items, more := search(q, dir, page, false)
s := &Search{Query: q, Dir: dir, Items: items, Previous: page - 1, Page: page, Next: page + 1,
Results: len(items) > 0, More: more}
renderTemplate(w, dir, "search", s)
renderTemplate(w, "search", s)
}

View File

@@ -6,7 +6,8 @@
<meta name="viewport" content="width=device-width">
<title>Search for {{.Query}}</title>
<style>
html { max-width: 70ch; padding: 2ch; margin: auto; color: #111; background-color: #ffe; }
html { max-width: 70ch; padding: 2ch; margin: auto; color: #ddd; background-color: #222; }
a { color: #8cf } a:visited { color: #dbf } a:hover { color: #fff }
body { hyphens: auto; }
header a { margin-right: 1ch; }
form { display: inline-block; }

View File

@@ -48,10 +48,9 @@ func searchCli(w io.Writer, dir string, n int, all, extract bool, quiet bool, ar
if err != nil {
return subcommands.ExitFailure
}
index.reset()
index.load()
q := strings.Join(args, " ")
items, more := search(q, dir, "", n, true)
items, more := search(q, dir, n, true)
if !quiet {
fmt.Fprint(os.Stderr, "Search for ", q)
if !all {

View File

@@ -10,10 +10,10 @@ import (
func TestSortNames(t *testing.T) {
index.Lock()
defer index.Unlock()
for _, s := range []string{"Alex", "Berta", "Chris", "2015-06-14", "2023-09-26"} {
index.titles[s] = s
}
index.Unlock()
terms := []string{"Z"}
fn := sortNames(terms)
assert.Equal(t, 1, fn("Berta", "Alex"), "B is after A")
@@ -56,10 +56,6 @@ func TestPrependMatches(t *testing.T) {
}
func TestSearch(t *testing.T) {
// working in the main directory
index.reset()
index.load()
data := url.Values{}
data.Set("q", "oddµ")
@@ -71,83 +67,12 @@ func TestSearch(t *testing.T) {
assert.NotContains(t, body, "Welcome")
}
func TestSearchFilter(t *testing.T) {
names := []string{"a", "public/b", "secret/c"}
f := filterPath(names, "", "")
assert.Equal(t, names, f)
f = filterPath(names, "public/", "")
assert.Equal(t, []string{"public/b"}, f)
f = filterPath(names, "secret/", "")
assert.Equal(t, []string{"secret/c"}, f)
// critically, this no longer returns c
f = filterPath(names, "", "^secret/")
assert.Equal(t, []string{"a", "public/b"}, f)
// unchanged
f = filterPath(names, "public/", "^secret/")
assert.Equal(t, []string{"public/b"}, f)
// unchanged
f = filterPath(names, "secret/", "^secret/")
assert.Equal(t, []string{"secret/c"}, f)
}
func TestSearchFilterLong(t *testing.T) {
cleanup(t, "testdata/filter")
p := &Page{Name: "testdata/filter/one", Body: []byte(`# One
One day, I heard you say
Just one more day and I'd know
But that was last spring`)}
p.save()
p = &Page{Name: "testdata/filter/public/two", Body: []byte(`# Two
Oh, the two of us
Have often seen this forest
But this bird is new`)}
p.save()
p = &Page{Name: "testdata/filter/secret/three", Body: []byte(`# Three
Three years have gone by
And we're good, we live, we breathe
But we don't say it`)}
p.save()
// normal search works
items, _ := search("spring", "testdata/", "", 1, false)
assert.Equal(t, len(items), 1)
assert.Equal(t, "One", items[0].Title)
// not found because it's in /secret and we start at /
items, _ = search("year", "testdata/", "^testdata/filter/secret/", 1, false)
assert.Equal(t, 0, len(items))
// only found two because the third one is in /secret and we start at /
items, _ = search("but", "testdata/", "^testdata/filter/secret/", 1, false)
assert.Equal(t, 2, len(items))
assert.Equal(t, "One", items[0].Title)
assert.Equal(t, "Two", items[1].Title)
// starting in the public/ directory, we find only one page
items, _ = search("but", "testdata/filter/public/", "^testdata/filter/secret/", 1, false)
assert.Equal(t, 1, len(items))
assert.Equal(t, "Two", items[0].Title)
// starting in the secret/ directory, we find only one page
items, _ = search("but", "testdata/filter/secret/", "^testdata/filter/secret/", 1, false)
assert.Equal(t, 1, len(items))
assert.Contains(t, "Three", items[0].Title)
}
func TestSearchDir(t *testing.T) {
cleanup(t, "testdata/dir")
p := &Page{Name: "testdata/dir/dice", Body: []byte(`# Dice
A tiny drum roll
Dice rolling bouncing stopping
Dice rolling bouncing stopping
Where is lady luck?`)}
p.save()
@@ -168,21 +93,17 @@ Where is lady luck?`)}
}
func TestTitleSearch(t *testing.T) {
// working in the main directory
index.reset()
index.load()
items, more := search("title:readme", "", "", 1, false)
items, more := search("title:readme", "", 1, false)
assert.Equal(t, 0, len(items), "no page found")
assert.False(t, more)
items, more = search("title:wel", "", "", 1, false) // README also contains "wel"
items, more = search("title:wel", "", 1, false) // README also contains "wel"
assert.Equal(t, 1, len(items), "one page found")
assert.Equal(t, "index", items[0].Name, "Welcome to Oddµ")
assert.Greater(t, items[0].Score, 0, "matches result in a score")
assert.False(t, more)
items, more = search("wel", "", "", 1, false)
items, more = search("wel", "", 1, false)
assert.Greater(t, len(items), 1, "two pages found")
assert.False(t, more)
}
@@ -196,12 +117,12 @@ Was it 2015
We met in the park?`)}
p.save()
items, _ := search("blog:false", "", "", 1, false)
items, _ := search("blog:false", "", 1, false)
for _, item := range items {
assert.NotEqual(t, "Back then", item.Title, item.Name)
}
items, _ = search("blog:true", "", "", 1, false)
items, _ = search("blog:true", "", 1, false)
assert.Equal(t, 1, len(items), "one blog page found")
assert.Equal(t, "Back then", items[0].Title, items[0].Name)
}
@@ -221,7 +142,7 @@ A quick sip too quick
#Haiku`)}
p.save()
items, _ := search("#Haiku", "testdata/hashtag", "", 1, false)
items, _ := search("#Haiku", "testdata/hashtag", 1, false)
assert.Equal(t, 2, len(items), "two pages found")
assert.Equal(t, "Haikus", items[0].Title, items[0].Name)
assert.Equal(t, "Tea", items[1].Title, items[1].Name)
@@ -253,18 +174,18 @@ func TestSearchPagination(t *testing.T) {
p.save()
}
items, more := search("secretA", "", "", 1, false)
items, more := search("secretA", "", 1, false)
assert.Equal(t, 1, len(items), "one page found, %v", items)
assert.Equal(t, "testdata/pagination/A", items[0].Name)
assert.False(t, more)
items, more = search("secretX", "", "", 1, false)
items, more = search("secretX", "", 1, false)
assert.Equal(t, itemsPerPage, len(items))
assert.Equal(t, "testdata/pagination/A", items[0].Name)
assert.Equal(t, "testdata/pagination/T", items[itemsPerPage-1].Name)
assert.True(t, more)
items, more = search("secretX", "", "", 2, false)
items, more = search("secretX", "", 2, false)
assert.Equal(t, 6, len(items))
assert.Equal(t, "testdata/pagination/U", items[0].Name)
assert.Equal(t, "testdata/pagination/Z", items[5].Name)

View File

@@ -6,7 +6,8 @@
<meta name="viewport" content="width=device-width">
<title>{{.Title}}</title>
<style>
html { max-width: 65ch; padding: 1ch; margin: auto; color: #111; background-color: #ffe; }
html { max-width: 65ch; padding: 1ch; margin: auto; color: #ddd; background-color: #222; }
a { color: #8cf } a:visited { color: #dbf } a:hover { color: #fff }
body { hyphens: auto; }
header a { margin-right: 1ch; }
form { display: inline-block; }
@@ -23,7 +24,7 @@ img { max-width: 100%; }
</main>
<footer>
<address>
Comments? Send mail to Your Name <<a href="mailto:you@example.org">you@example.org</a>>
Send text via the long-range comms to Ashivom Bandaralum &lt;<a href="mailto:jupiter@transjovian.org">jupiter@transjovian.org</a>&gt;
</address>
</footer>
</body>

View File

@@ -9,6 +9,7 @@ import (
"github.com/gomarkdown/markdown/ast"
"github.com/gomarkdown/markdown/html"
"github.com/google/subcommands"
"html/template"
"io/fs"
"net/url"
"os"
@@ -42,17 +43,25 @@ func (cmd *staticCmd) Execute(_ context.Context, f *flag.FlagSet, _ ...interface
// staticCli generates a static site in the designated directory. The quiet flag is used to suppress output when running
// tests.
func staticCli(dir string, quiet bool) subcommands.ExitStatus {
loadLanguages()
loadTemplates()
n := 0
err := filepath.Walk(".", func(path string, info fs.FileInfo, err error) error {
err := os.Mkdir(dir, 0755)
if err != nil {
fmt.Println(err)
return subcommands.ExitFailure
}
initAccounts()
if (!quiet) {
fmt.Printf("Loaded %d languages\n", loadLanguages())
}
templates := loadTemplates()
n := 0;
err = filepath.Walk(".", func(path string, info fs.FileInfo, err error) error {
n++
if !quiet && (n < 100 || n < 1000 && n%10 == 0 || n%100 == 0) {
if (!quiet && (n < 100 || n < 1000 && n % 10 == 0 || n % 100 == 0)) {
fmt.Fprintf(os.Stdout, "\r%d", n)
}
return staticFile(path, dir, info, err)
return staticFile(path, dir, info, templates, err)
})
if !quiet {
if (!quiet) {
fmt.Printf("\r%d\n", n)
}
if err != nil {
@@ -64,38 +73,33 @@ func staticCli(dir string, quiet bool) subcommands.ExitStatus {
// staticFile is used to walk the file trees and do the right thing for the destination directory: create
// subdirectories, link files, render HTML files.
func staticFile(path, dir string, info fs.FileInfo, err error) error {
func staticFile(path, dir string, info fs.FileInfo, templates *template.Template, err error) error {
if err != nil {
return err
}
// skip hidden directories and files
if path != "." && strings.HasPrefix(filepath.Base(path), ".") {
if info.IsDir() {
return filepath.SkipDir
} else {
return nil
}
}
// skip backup files, avoid recursion
if strings.HasSuffix(path, "~") || strings.HasPrefix(path, dir) {
filename := path
// skip "hidden" files and backup files, avoid recursion
if strings.HasPrefix(filename, ".") ||
strings.HasSuffix(filename, "~") ||
strings.HasPrefix(filename, dir) {
return nil
}
// recreate subdirectories
if info.IsDir() {
return os.Mkdir(filepath.Join(dir, path), 0755)
return os.Mkdir(filepath.Join(dir, filename), 0755)
}
// render pages
if strings.HasSuffix(path, ".md") {
return staticPage(path, dir)
if strings.HasSuffix(filename, ".md") {
return staticPage(filename, dir, templates)
}
// remaining files are linked
return os.Link(path, filepath.Join(dir, path))
return os.Link(filename, filepath.Join(dir, filename))
}
// staticPage takes the filename of a page (ending in ".md") and generates a static HTML page.
func staticPage(path, dir string) error {
name := strings.TrimSuffix(path, ".md")
p, err := loadPage(filepath.ToSlash(name))
func staticPage(filename, dir string, templates *template.Template) error {
name := strings.TrimSuffix(filename, ".md")
p, err := loadPage(name)
if err != nil {
fmt.Fprintf(os.Stderr, "Cannot load %s: %s\n", name, err)
return err
@@ -114,7 +118,7 @@ func staticPage(path, dir string) error {
p.Html = unsafeBytes(maybeUnsafeHTML)
p.Language = language(p.plainText())
p.Hashtags = *hashtags
return p.write(filepath.Join(dir, name+".html"))
return p.write(filepath.Join(dir, name+".html"), templates)
}
// staticLinks checks a node and if it is a link to a local page, it appends ".html" to the link destination.
@@ -142,14 +146,14 @@ func staticLinks(node ast.Node, entering bool) ast.WalkStatus {
return ast.GoToNext
}
func (p *Page) write(destination string) error {
func (p *Page) write(destination string, templates *template.Template) error {
t := "static.html"
f, err := os.Create(destination)
if err != nil {
fmt.Fprintf(os.Stderr, "Cannot create %s.html: %s\n", destination, err)
return err
}
err = templates.template[t].Execute(f, p)
err = templates.ExecuteTemplate(f, t, p)
if err != nil {
fmt.Fprintf(os.Stderr, "Cannot execute %s template for %s: %s\n", t, destination, err)
return err

View File

@@ -1,114 +0,0 @@
package main
import (
"html/template"
"io/fs"
"log"
"net/http"
"path"
"path/filepath"
"slices"
"strings"
"sync"
)
// templateFiles are the various HTML template files used. These files must exist in the root directory for Oddmu to be
// able to generate HTML output. This always requires a template.
var templateFiles = []string{"edit.html", "add.html", "view.html",
"diff.html", "search.html", "static.html", "upload.html", "feed.html"}
// templateStore controls access to map of parsed HTML templates. Make sure to lock and unlock as appropriate. See
// renderTemplate and loadTemplates.
type templateStore struct {
sync.RWMutex
// template is a map of parsed HTML templates. The key is their path name. By default, the map only contains
// top-level templates like "view.html". Subdirectories may contain their own templates which override the
// templates in the root directory. If so, they are paths like "dir/view.html", not filepaths. Use
// filepath.ToSlash() if necessary.
template map[string]*template.Template
}
var templates templateStore
// loadTemplates loads the templates. If templates have already been loaded, return immediately.
func loadTemplates() {
if templates.template != nil {
return
}
templates.Lock()
defer templates.Unlock()
// walk the directory, load templates and add directories
templates.template = make(map[string]*template.Template)
filepath.Walk(".", loadTemplate)
log.Println(len(templates.template), "templates loaded")
}
// loadTemplate is used to walk the directory. It loads all the template files it finds, including the ones in
// subdirectories. This is called with templates already locked.
func loadTemplate(fp string, info fs.FileInfo, err error) error {
if err != nil {
return err
}
if strings.HasSuffix(fp, ".html") &&
slices.Contains(templateFiles, filepath.Base(fp)) {
t, err := template.ParseFiles(fp)
if err != nil {
log.Println("Cannot parse template:", fp, err)
// ignore error
} else {
// log.Println("Parse template:", path)
templates.template[filepath.ToSlash(fp)] = t
}
}
return nil
}
// updateTemplate checks whether this is a valid template file and if so, reloads it.
func updateTemplate(fp string) {
if strings.HasSuffix(fp, ".html") &&
slices.Contains(templateFiles, filepath.Base(fp)) {
t, err := template.ParseFiles(fp)
if err != nil {
log.Println("Template:", fp, err)
} else {
templates.Lock()
defer templates.Unlock()
templates.template[filepath.ToSlash(fp)] = t
log.Println("Parse template:", fp)
}
}
}
// removeTemplate removes a template unless it's a root template because that would result in the site being unusable.
func removeTemplate(fp string) {
if slices.Contains(templateFiles, filepath.Base(fp)) &&
filepath.Dir(fp) != "." {
templates.Lock()
defer templates.Unlock()
delete(templates.template, filepath.ToSlash(fp))
log.Println("Discard template:", fp)
}
}
// renderTemplate is the helper that is used to render the templates with data.
// A template in the same directory is preferred, if it exists.
func renderTemplate(w http.ResponseWriter, dir, tmpl string, data any) {
loadTemplates()
base := tmpl + ".html"
templates.RLock()
defer templates.RUnlock()
t := templates.template[path.Join(dir, base)]
if t == nil {
t = templates.template[base]
}
if t == nil {
log.Println("Template not found:", base)
http.Error(w, "Template not found", http.StatusInternalServerError)
return
}
err := t.Execute(w, data)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}

View File

@@ -1,50 +0,0 @@
package main
import (
"bytes"
"github.com/stretchr/testify/assert"
"mime/multipart"
"testing"
)
func TestTemplates(t *testing.T) {
cleanup(t, "testdata/templates")
// save a file to create the directory
p := &Page{Name: "testdata/templates/snow", Body: []byte(`# Snow
A blob on the grass
Covered in needles and dust
Memories of cold
`)}
p.save()
assert.Contains(t,
assert.HTTPBody(makeHandler(viewHandler, true), "GET", "/view/testdata/templates/snow", nil),
"Skip navigation")
// save a new view handler
html := "<body><h1>{{.Title}}</h1>{{.Html}}"
form := new(bytes.Buffer)
writer := multipart.NewWriter(form)
field, err := writer.CreateFormField("name")
assert.NoError(t, err)
field.Write([]byte("view.html"))
file, err := writer.CreateFormFile("file", "test.html")
assert.NoError(t, err)
n, err := file.Write([]byte(html))
assert.NoError(t, err)
assert.Equal(t, len(html), n)
writer.Close()
HTTPUploadLocation(t, makeHandler(dropHandler, false), "/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),
html)
// verify that it works
body := assert.HTTPBody(makeHandler(viewHandler, true), "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, true), "GET", "/view/index", nil),
"Skip navigation")
}

View File

@@ -6,15 +6,17 @@
<meta name="viewport" content="width=device-width">
<title>Upload File</title>
<style>
html { max-width: 70ch; padding: 2ch; margin: auto; color: #111; background-color: #ffe; }
html { max-width: 70ch; padding: 2ch; margin: auto; color: #ddd; background-color: #222; }
a { color: #8cf } a:visited { color: #dbf } a:hover { color: #fff }
body { hyphens: auto; }
textarea, input, button { color: #222; background-color: #ddd; border: 1px solid #eee; }
form, textarea { width: 100%; }
label { display: inline-block; width: 20ch }
.last { max-width: 20% }
</style>
</head>
<body lang="en">
<h1>Upload Files</h1>
<h1>Upload File</h1>
{{if ne .Last ""}}
<p>Previous upload: <a href="/view/{{.Last}}">{{.Last}}</a></p>
{{if .Image}}
@@ -23,24 +25,23 @@ label { display: inline-block; width: 20ch }
{{end}}
<form action="/drop/{{.Dir}}" method="POST" enctype="multipart/form-data">
<p>When uploading pictures from a phone, its filename is going to be something cryptic like IMG_1234.JPG.
Please provide your own filename. End the base name with "-1" to auto-increment.
Use <tt>.jpg</tt> or <tt>.png</tt> as the extension.
Please provide your own filename. End the filename with "-1" to auto-increment.
<p><label for="text">Filename to use:</label>
<input id="text" name="name" value="{{.Name}}" type="text" placeholder="image-1.jpg" autofocus required>
<p>If the uploaded file is a picture from a phone, it is going to be too big for your site.
Sadly, resizing only works when uploading <tt>.jpeg</tt>, <tt>.heic</tt> and <tt>.png</tt> files.
Sadly, resizing only works for JPG and PNG files. Luckily, most pictures from a phone camera are JPG images.
Feel free to specify a max width of 1200 pixels, for example.
<p><label for="maxwidth">Max width:</label>
<input id="maxwidth" name="maxwidth" value="{{.MaxWidth}}" type="number" min="10" placeholder="1200">
<p>If the filename you provided above ends in <tt>.jpg</tt>, you can specify a quality.
<p>If the uploaded file is a JPEG-encoded picture, like most pictures from a phone, you can specify a quality.
Typically, a quality of 60 is not too bad and a quality of 90 is more than enough.
<p><label for="quality">Quality:</label>
<input id="quality" name="quality" value="{{.Quality}}" type="number" min="1" max="99" placeholder="75">
<p>Finally, pick the files or photos to upload.
Picture metadata is only removed if the pictures gets resized.
<p>Finally, pick the file or photo to upload.
Picture metadata is only removed if the picture gets resized.
Providing a new max width is recommended for all pictures.
<p><label for="file">Pick files to upload:</label>
<input type="file" name="file" required multiple>
<p><label for="file">Pick file to upload:</label>
<input type="file" name="file" required>
<p><input type="submit" value="Save">
<a href="/view/index"><button type="button">Cancel</button></a></p>
</form>

View File

@@ -1,14 +1,12 @@
package main
// The imaging library uses image.Decode internally. This function can use all image decoders available at that time.
// This is why we import goheif for side effects: HEIC files are read correctly.
import (
_ "github.com/bashdrew/goheif"
"github.com/disintegration/imaging"
"github.com/edwvee/exiffix"
"github.com/anthonynsimon/bild/imgio"
"github.com/anthonynsimon/bild/transform"
"github.com/bashdrew/goheif"
"image/jpeg"
"image/png"
"io"
"log"
"net/http"
"net/url"
"os"
@@ -19,7 +17,7 @@ import (
"strings"
)
type upload struct {
type Upload struct {
Dir string
Name string
Last string
@@ -34,7 +32,7 @@ var lastRe = regexp.MustCompile(`^(.*)([0-9]+)(.*)$`)
// parameters are used to copy name, maxwidth and quality from the previous upload. If the previous name contains a
// number, this is incremented by one.
func uploadHandler(w http.ResponseWriter, r *http.Request, dir string) {
data := &upload{Dir: dir}
data := &Upload{Dir: dir}
maxwidth := r.FormValue("maxwidth")
if maxwidth != "" {
data.MaxWidth = maxwidth
@@ -43,170 +41,123 @@ func uploadHandler(w http.ResponseWriter, r *http.Request, dir string) {
if quality != "" {
data.Quality = quality
}
name := r.FormValue("filename")
if name != "" {
data.Name = name
} else if last := r.FormValue("last"); last != "" {
last := r.FormValue("last")
if last != "" {
ext := strings.ToLower(filepath.Ext(last))
switch ext {
case ".png", ".jpg", ".jpeg":
data.Image = true
}
data.Last = path.Join(dir, last)
data.Name, _ = next(last)
}
renderTemplate(w, dir, "upload", data)
}
// next returns the next name for a string matching lastRe. The last number in the given string is incremented by one
// ("a2b" → "a3b"). The second return value indicates whether such a replacement was made or not.
func next(s string) (string, bool) {
m := lastRe.FindStringSubmatch(s)
if m != nil {
n, err := strconv.Atoi(m[2])
if err == nil {
return m[1] + strconv.Itoa(n+1) + m[3], true
m := lastRe.FindStringSubmatch(last)
if m != nil {
n, err := strconv.Atoi(m[2])
if err == nil {
data.Name = m[1] + strconv.Itoa(n+1) + m[3]
}
}
}
return s, false
renderTemplate(w, "upload", data)
}
// dropHandler takes the "name" form field and the "file" form file and saves the file under the given name. The browser
// is redirected to the view of that file. Some errors are for the users and some are for users and the admins. Those
// later errors are printed, too.
// is redirected to the view of that file.
func dropHandler(w http.ResponseWriter, r *http.Request, dir string) {
d := filepath.Dir(filepath.FromSlash(dir))
d := path.Dir(dir)
// ensure the directory exists
fi, err := os.Stat(d)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if !fi.IsDir() {
http.Error(w, "directory does not exist", http.StatusBadRequest)
http.Error(w, "file exists", http.StatusInternalServerError)
return
}
data := url.Values{}
name := r.FormValue("name")
data.Set("last", name)
filename := filepath.Base(name)
// no overwriting of hidden files or adding subdirectories
if strings.HasPrefix(filename, ".") || filepath.Dir(name) != "." {
http.Error(w, "no filename", http.StatusForbidden)
if filename == "." || filepath.Dir(name) != "." {
http.Error(w, "no filename", http.StatusInternalServerError)
return
}
// prepare for image encoding (saving) with the encoder based on the desired file name extensions
var format imaging.Format
quality := 75
file, _, err := r.FormFile("file")
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
defer file.Close()
backup(filename)
// create the new file
path := d + "/" + filename
dst, err := os.Create(path)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
defer dst.Close()
// if a resize was requested
maxwidth := r.FormValue("maxwidth")
mw := 0
if len(maxwidth) > 0 {
mw, err = strconv.Atoi(maxwidth)
mw, err := strconv.Atoi(maxwidth)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
data.Add("maxwidth", maxwidth)
// determine how the file will be written
ext := strings.ToLower(filepath.Ext(filename))
ext := strings.ToLower(filepath.Ext(path))
var encoder imgio.Encoder
switch ext {
case ".png":
format = imaging.PNG
encoder = imgio.PNGEncoder()
case ".jpg", ".jpeg":
q := r.FormValue("quality")
if len(q) > 0 {
quality, err = strconv.Atoi(q)
q := jpeg.DefaultQuality
quality := r.FormValue("quality")
if len(quality) > 0 {
q, err = strconv.Atoi(quality)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
data.Add("quality", q)
}
format = imaging.JPEG
default:
http.Error(w, "Resizing images requires a .png, .jpg or .jpeg extension for the filename", http.StatusBadRequest)
return
}
}
first := true
for _, fhs := range r.MultipartForm.File["file"] {
file, err := fhs.Open()
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
defer file.Close()
if !first {
s, ok := next(filename)
if ok {
filename = s
} else {
ext := filepath.Ext(s)
filename = s[:len(s)-len(ext)] + "-1" + ext
}
}
first = false
path := filepath.Join(d, filename)
watches.ignore(path)
err = backup(path)
if err != nil {
log.Println(err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
dst, err := os.Create(path)
if err != nil {
log.Println(err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
defer dst.Close()
if mw > 0 {
// do not use imaging.Decode(file, imaging.AutoOrientation(true)) because that only works for JPEG files
img, fmt, err := exiffix.Decode(file)
if err != nil {
http.Error(w, "The image could not be decoded (only PNG, JPG and HEIC formats are supported for resizing)", http.StatusBadRequest)
return
}
log.Println("Decoded", fmt, "file")
res := imaging.Resize(img, mw, 0, imaging.Lanczos) // preserve aspect ratio
// imaging functions don't return errors but empty images…
if !res.Rect.Empty() {
img = res
}
// images are always reencoded, so image quality goes down
err = imaging.Encode(dst, img, format, imaging.JPEGQuality(quality))
if err != nil {
log.Println(err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
} else {
// just copy the bytes
n, err := io.Copy(dst, file)
if err != nil {
log.Println(err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// if zero bytes were copied, delete the file instead
if n == 0 {
err := os.Remove(path)
if err != nil {
log.Println(err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
log.Println("Delete", path)
data.Add("quality", quality)
}
encoder = imgio.JPEGEncoder(q)
default:
http.Error(w, "Resizing images requires a .png, .jpg or .jpeg extension for the filename", http.StatusInternalServerError)
return
}
username, _, ok := r.BasicAuth()
if ok {
log.Println("Save", path, "by", username)
// try and decode the data in various formats
img, err := jpeg.Decode(file)
if err != nil {
img, err = png.Decode(file)
}
if err != nil {
img, err = goheif.Decode(file)
}
if err != nil {
http.Error(w, "The image could not be decoded (only PNG, JPG and HEIC formats are supported for resizing)", http.StatusInternalServerError)
}
rect := img.Bounds()
width := rect.Max.X - rect.Min.X
if width > mw {
height := (rect.Max.Y - rect.Min.Y) * mw / width
img = transform.Resize(img, mw, height, transform.Linear)
if err := imgio.Save(path, img, encoder); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
} else {
log.Println("Save", path)
http.Error(w, "The file is too small for this", http.StatusInternalServerError)
return
}
} else {
// just copy the bytes
if _, err := io.Copy(dst, file); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
updateTemplate(path)
}
data.Set("last", filename) // has no slashes
http.Redirect(w, r, "/upload/"+dir+"?"+data.Encode(), http.StatusFound)
http.Redirect(w, r, "/upload/"+d+"/?"+data.Encode(), http.StatusFound)
}

View File

@@ -2,7 +2,6 @@ package main
import (
"bytes"
"encoding/base64"
"github.com/stretchr/testify/assert"
"image"
"image/jpeg"
@@ -26,8 +25,7 @@ func TestUpload(t *testing.T) {
assert.NoError(t, err)
file, err := writer.CreateFormFile("file", "example.txt")
assert.NoError(t, err)
_, err = file.Write([]byte("Hello!"))
assert.NoError(t, err)
file.Write([]byte("Hello!"))
err = writer.Close()
assert.NoError(t, err)
HTTPUploadAndRedirectTo(t, makeHandler(dropHandler, false), "/drop/testdata/files/",
@@ -69,58 +67,6 @@ func TestUploadJpg(t *testing.T) {
writer.FormDataContentType(), form, "/upload/testdata/jpg/?last=ok.jpg")
}
func TestUploadHeic(t *testing.T) {
cleanup(t, "testdata/heic")
// for uploads, the directory is not created automatically
os.MkdirAll("testdata/heic", 0755)
form := new(bytes.Buffer)
writer := multipart.NewWriter(form)
field, _ := writer.CreateFormField("name")
field.Write([]byte("ok.jpg")) // target
file, _ := writer.CreateFormFile("file", "ok.heic") // source
// convert -size 1x1 canvas: heic:- | base64
imgBase64 := `
AAAAGGZ0eXBoZWljAAAAAG1pZjFoZWljAAABqm1ldGEAAAAAAAAAIWhkbHIAAAAAAAAAAHBpY3QA
AAAAAAAAAAAAAAAAAAAADnBpdG0AAAAAAAIAAAAQaWRhdAAAAAAAAQABAAAAOGlsb2MBAAAAREAA
AgABAAAAAAAAAcoAAQAAAAAAAAAtAAIAAQAAAAAAAAABAAAAAAAAAAgAAAA4aWluZgAAAAAAAgAA
ABVpbmZlAgAAAQABAABodmMxAAAAABVpbmZlAgAAAAACAABncmlkAAAAANVpcHJwAAAAs2lwY28A
AABzaHZjQwEDcAAAAAAAAAAAAB7wAPz9+PgAAA8DIAABABhAAQwB//8DcAAAAwCQAAADAAADAB66
AkAhAAEAJ0IBAQNwAAADAJAAAAMAAAMAHqAggQWW6q6a5sCAAAADAIAAAAMAhCIAAQAGRAHBc8GJ
AAAAFGlzcGUAAAAAAAAAQAAAAEAAAAAUaXNwZQAAAAAAAAABAAAAAQAAABBwaXhpAAAAAAMICAgA
AAAaaXBtYQAAAAAAAAACAAECgQIAAgIDhAAAABppcmVmAAAAAAAAAA5kaW1nAAIAAQABAAAANW1k
YXQAAAApKAGvEyE1mvXho5qH3STtzcWnOxedwNIXAKNDaJNqz3uONoCHeUhi/HA=`
img, err := base64.StdEncoding.DecodeString(imgBase64)
assert.NoError(t, err)
file.Write(img)
writer.Close()
HTTPUploadAndRedirectTo(t, makeHandler(dropHandler, false), "/drop/testdata/heic/",
writer.FormDataContentType(), form, "/upload/testdata/heic/?last=ok.jpg")
}
func TestDeleteFile(t *testing.T) {
cleanup(t, "testdata/delete")
os.MkdirAll("testdata/delete", 0755)
assert.NoError(t, os.WriteFile("testdata/delete/nothing.txt", []byte(`# Nothing
I pause and look up
Look at the mountains you say
What happened just now?`), 0644))
// check that it worked
assert.FileExists(t, "testdata/delete/nothing.txt")
// delete it by upload a zero byte file
form := new(bytes.Buffer)
writer := multipart.NewWriter(form)
field, _ := writer.CreateFormField("name")
field.Write([]byte("nothing.txt"))
file, _ := writer.CreateFormFile("file", "test.txt")
file.Write([]byte(""))
writer.Close()
HTTPUploadAndRedirectTo(t, makeHandler(dropHandler, false), "/drop/testdata/delete/",
writer.FormDataContentType(), form, "/upload/testdata/delete/?last=nothing.txt")
// check that it worked
assert.NoFileExists(t, "testdata/delete/nothing.txt")
}
func TestUploadMultiple(t *testing.T) {
cleanup(t, "testdata/multi")
p := &Page{Name: "testdata/multi/culture", Body: []byte(`# Culture
@@ -132,7 +78,7 @@ But here: jasmin dreams`)}
// check location for upload
body := assert.HTTPBody(makeHandler(viewHandler, true), "GET", "/view/testdata/multi/culture", nil)
assert.Contains(t, body, `href="/upload/testdata/multi/?filename=culture-1.jpg"`)
assert.Contains(t, body, `href="/upload/testdata/multi/"`)
// check location for drop
body = assert.HTTPBody(makeHandler(uploadHandler, false), "GET", "/upload/testdata/multi/", nil)
@@ -169,8 +115,11 @@ But here: jasmin dreams`)}
}
func TestUploadDir(t *testing.T) {
cleanup(t, "testdata/dir")
p := &Page{Name: "testdata/dir/test", Body: []byte(`# Test
t.Cleanup(func() {
assert.NoError(t, os.Remove("test.md"))
assert.NoError(t, os.Remove("test.jpg"))
})
p := &Page{Name: "test", Body: []byte(`# Test
Eyes are an abyss
We stare into each other
@@ -178,12 +127,12 @@ There is no answer`)}
p.save()
// check location for upload
body := assert.HTTPBody(makeHandler(viewHandler, true), "GET", "/view/testdata/dir/test", nil)
assert.Contains(t, body, `href="/upload/testdata/dir/?filename=test-1.jpg"`)
body := assert.HTTPBody(makeHandler(viewHandler, true), "GET", "/view/test", nil)
assert.Contains(t, body, `href="/upload/"`)
// check location for drop
body = assert.HTTPBody(makeHandler(uploadHandler, false), "GET", "/upload/testdata/dir/", nil)
assert.Contains(t, body, `action="/drop/testdata/dir/"`)
body = assert.HTTPBody(makeHandler(uploadHandler, false), "GET", "/upload/", nil)
assert.Contains(t, body, `action="/drop/"`)
// actually do the upload
form := new(bytes.Buffer)
@@ -194,63 +143,14 @@ 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), "/drop/",
writer.FormDataContentType(), form)
url, _ := url.Parse(location)
assert.Equal(t, "/upload/testdata/dir/", url.Path, "Redirect to upload location")
assert.Equal(t, "/upload/", url.Path, "Redirect to upload location")
values := url.Query()
assert.Equal(t, "test.jpg", values.Get("last"))
// check the result page
body = assert.HTTPBody(makeHandler(uploadHandler, false), "GET", url.Path, values)
assert.Contains(t, body, `src="/view/testdata/dir/test.jpg"`)
}
func TestUploadTwoInOne(t *testing.T) {
cleanup(t, "testdata/two")
os.MkdirAll("testdata/two", 0755)
form := new(bytes.Buffer)
writer := multipart.NewWriter(form)
field, _ := writer.CreateFormField("name")
field.Write([]byte("2024-02-19-hike-1.jpg"))
file1, _ := writer.CreateFormFile("file", "one.jpg")
img1 := image.NewRGBA(image.Rect(0, 0, 10, 10))
jpeg.Encode(file1, img1, &jpeg.Options{Quality: 90})
file2, _ := writer.CreateFormFile("file", "two.jpg")
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/",
writer.FormDataContentType(), form)
url, _ := url.Parse(location)
assert.Equal(t, "/upload/testdata/two/", url.Path, "Redirect to upload location")
values := url.Query()
assert.Equal(t, "2024-02-19-hike-2.jpg", values.Get("last"))
// check the files
assert.FileExists(t, "testdata/two/2024-02-19-hike-1.jpg")
assert.FileExists(t, "testdata/two/2024-02-19-hike-2.jpg")
}
func TestUploadTwoInOneAgain(t *testing.T) {
cleanup(t, "testdata/zwei")
os.MkdirAll("testdata/zwei", 0755)
form := new(bytes.Buffer)
writer := multipart.NewWriter(form)
field, _ := writer.CreateFormField("name")
field.Write([]byte("image.jpg")) // cannot be incremented!
file1, _ := writer.CreateFormFile("file", "one.jpg")
img1 := image.NewRGBA(image.Rect(0, 0, 10, 10))
jpeg.Encode(file1, img1, &jpeg.Options{Quality: 90})
file2, _ := writer.CreateFormFile("file", "two.jpg")
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/",
writer.FormDataContentType(), form)
url, _ := url.Parse(location)
assert.Equal(t, "/upload/testdata/zwei/", url.Path, "Redirect to upload location")
values := url.Query()
assert.Equal(t, "image-1.jpg", values.Get("last"))
// check the files
assert.FileExists(t, "testdata/zwei/image.jpg")
assert.FileExists(t, "testdata/zwei/image-1.jpg")
assert.Contains(t, body, `src="/view/test.jpg"`)
}

View File

@@ -1,50 +0,0 @@
package main
import (
"context"
"flag"
"fmt"
"github.com/google/subcommands"
"io"
"os"
"runtime/debug"
"strings"
)
type versionCmd struct {
full bool
}
func (cmd *versionCmd) SetFlags(f *flag.FlagSet) {
f.BoolVar(&cmd.full, "full", false, "show all the debug information")
}
func (*versionCmd) Name() string { return "version" }
func (*versionCmd) Synopsis() string { return "report build information" }
func (*versionCmd) Usage() string {
return `version [-full]:
Report the exact version control commit this is built from, or the
full debug information about this build.
`
}
func (cmd *versionCmd) Execute(_ context.Context, f *flag.FlagSet, _ ...interface{}) subcommands.ExitStatus {
return versionCli(os.Stdout, cmd.full, f.Args())
}
func versionCli(w io.Writer, full bool, args []string) subcommands.ExitStatus {
info, ok := debug.ReadBuildInfo()
if !ok {
fmt.Println("This binary contains no debug info.")
} else if full {
fmt.Println(info)
} else {
fmt.Println(info.Path)
for _, setting := range info.Settings {
if strings.HasPrefix(setting.Key, "vcs") {
fmt.Printf("%s=%s\n", setting.Key, setting.Value)
}
}
}
return subcommands.ExitSuccess
}

View File

@@ -1,15 +0,0 @@
package main
import (
"bytes"
"github.com/google/subcommands"
"github.com/stretchr/testify/assert"
"testing"
)
func TestVersionCmd(t *testing.T) {
b := new(bytes.Buffer)
s := versionCli(b, false, nil)
assert.Equal(t, subcommands.ExitSuccess, s)
assert.Contains(t, "vcs.revision", b.String())
}

137
view.go
View File

@@ -1,126 +1,89 @@
package main
import (
"io"
"net/http"
"os"
urlpath "path"
"path/filepath"
"path"
"strings"
"time"
)
// rootHandler just redirects to /view/index. The root handler handles requests to the root path, and implicity all
// unhandled request. Thus, if the URL path is not "/", return a 404 NOT FOUND response.
// rootHandler just redirects to /view/index.
func rootHandler(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" {
http.NotFound(w, r)
} else {
http.Redirect(w, r, "/view/index", http.StatusFound)
}
http.Redirect(w, r, "/view/index", http.StatusFound)
}
// viewHandler serves pages. If the requested URL ends in ".rss" and the corresponding file ending with ".md" exists, a
// feed is generated and the "feed.html" template is used (it is used to generate a RSS 2.0 feed, even if the extension
// is ".html"). If the requested URL maps to a page name, the corresponding file (by appending ".md") is loaded and
// served using the "view.html" template. If the requested URL maps to an existing file, it is served (you can therefore
// request the ".md" files directly). If the requested URL maps to a directory, the browser is redirected to the index
// page. If none of the above, the browser is redirected to an edit page.
//
// Uploading files ending in ".rss" does not prevent RSS feed generation.
// viewHandler serves pages. If the requested URL maps to an existing file, it is served. If the requested URL maps to a
// directory, the browser is redirected to the index page. If the requested URL ends in ".rss" and the corresponding
// file ending with ".md" exists, a feed is generated and the "feed.html" template is used (it is used to generate a RSS
// 2.0 feed, no matter what the template's extension is). If the requested URL maps to a page name, the corresponding
// file (ending in ".md") is loaded and served using the "view.html" template. If none of the above, the browser is
// redirected to an edit page.
//
// Caching: a 304 NOT MODIFIED is returned if the request has an If-Modified-Since header that matches the file's
// modification time, truncated to one second. Truncation is required because the file's modtime has sub-second
// precision and the HTTP timestamp for the Last-Modified header has not.
func viewHandler(w http.ResponseWriter, r *http.Request, path string) {
const (
unknown = iota
file
page
rss
dir
)
t := unknown
if strings.HasSuffix(path, ".rss") {
path = path[:len(path)-4]
t = rss
func viewHandler(w http.ResponseWriter, r *http.Request, name string) {
file := true
rss := false
if name == "" {
name = "."
}
fn := name
fi, err := os.Stat(fn)
if err != nil {
file = false
if strings.HasSuffix(fn, ".rss") {
rss = true
name = fn[0 : len(fn)-4]
fn = name
}
fn += ".md"
fi, err = os.Stat(fn)
} else if fi.IsDir() {
http.Redirect(w, r, path.Join("/view", name, "index"), http.StatusFound)
return
}
fp := filepath.FromSlash(path)
fi, err := os.Stat(fp + ".md")
if err == nil {
if fi.IsDir() {
t = dir // directory ending in ".md"
} else if t == unknown {
t = page
}
// otherwise t == rss
} else {
if t == rss {
http.Error(w, err.Error(), http.StatusNotFound)
return
}
if fp == "" {
fp = "." // make sure Stat works
}
fi, err = os.Stat(fp)
if err == nil {
if fi.IsDir() {
t = dir
} else {
t = file
h, ok := r.Header["If-Modified-Since"]
if ok {
ti, err := http.ParseTime(h[0])
if err == nil && !fi.ModTime().Truncate(time.Second).After(ti) {
w.WriteHeader(http.StatusNotModified)
return
}
}
w.Header().Set("Last-Modified", fi.ModTime().UTC().Format(http.TimeFormat))
}
// if nothing was found, offer to create it
if t == unknown {
http.Redirect(w, r, "/edit/"+path, http.StatusFound)
return
}
// directories are redirected to the index page
if t == dir {
http.Redirect(w, r, urlpath.Join("/view", path, "index"), http.StatusFound)
return
}
// if the page has not been modified, return (file, rss or page)
h, ok := r.Header["If-Modified-Since"]
if ok {
ti, err := http.ParseTime(h[0])
if err == nil && !fi.ModTime().Truncate(time.Second).After(ti) {
w.WriteHeader(http.StatusNotModified)
return
}
}
// if only the headers were requested, return
w.Header().Set("Last-Modified", fi.ModTime().UTC().Format(http.TimeFormat))
if r.Method == http.MethodHead {
w.WriteHeader(http.StatusOK)
return
}
if t == file {
file, err := os.Open(fp)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
if err == nil {
return
}
_, err = io.Copy(w, file)
http.Redirect(w, r, "/edit/"+name, http.StatusFound)
}
if file {
body, err := os.ReadFile(fn)
if err != nil {
// This is an internal error because os.Stat
// says there is a file. Non-existent files
// are treated like pages.
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Write(body)
return
}
p, err := loadPage(path)
p, err := loadPage(name)
if err != nil {
http.Redirect(w, r, "/edit/"+path, http.StatusFound)
http.Redirect(w, r, "/edit/"+name, http.StatusFound)
return
}
p.handleTitle(true)
if t == rss {
if rss {
it := feed(p, fi.ModTime())
w.Write([]byte(`<?xml version="1.0" encoding="UTF-8"?>`))
renderTemplate(w, p.Dir(), "feed", it)
renderTemplate(w, "feed", it)
return
}
p.renderHtml()
renderTemplate(w, p.Dir(), "view", p)
renderTemplate(w, "view", p)
}

View File

@@ -6,7 +6,9 @@
<meta name="viewport" content="width=device-width">
<title>{{.Title}}</title>
<style>
html { max-width: 70ch; padding: 1ch; margin: auto; color: #111; background-color: #ffe; }
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; }
body { hyphens: auto; }
header a { margin-right: 1ch; }
form { display: inline-block; }
@@ -19,16 +21,15 @@ img { max-width: 100%; }
<body>
<header>
<a href="#main">Skip navigation</a>
<a href="index">Home</a>
<a href="/view/index">Home</a>
<a href="/edit/{{.Name}}" accesskey="e">Edit</a>
<a href="/add/{{.Name}}" accesskey="a">Add</a>
<a href="/diff/{{.Name}}" accesskey="d">Diff</a>
<a href="/archive/{{.Dir}}data.zip" accesskey="z">Zip</a>
<a href="/upload/{{.Dir}}?filename={{.Base}}-1.jpg" accesskey="u">Upload</a>
<a href="/upload/{{.Dir}}" 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>
<input type="submit" value="Go"/>
</form>
</header>
<main id="main">
@@ -37,7 +38,7 @@ img { max-width: 100%; }
</main>
<footer>
<address>
Comments? Send mail to Your Name <<a href="mailto:you@example.org">you@example.org</a>>
Send text via the long-range comms to Ashivom Bandaralum &lt;<a href="mailto:jupiter@transjovian.org">jupiter@transjovian.org</a>&gt;
</address>
</footer>
</body>

View File

@@ -5,6 +5,7 @@ import (
"net/http"
"net/url"
"os"
"regexp"
"testing"
)
@@ -14,42 +15,22 @@ 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, true), "GET", "/view/index", nil),
"Welcome to Oddµ")
assert.Regexp(t, regexp.MustCompile("Welcome to Oddµ"),
assert.HTTPBody(makeHandler(viewHandler, true), "GET", "/view/index", nil))
}
func TestViewHandlerDir(t *testing.T) {
cleanup(t, "testdata/dir")
HTTPRedirectTo(t, makeHandler(viewHandler, true), "GET", "/view/", nil, "/view/index")
HTTPRedirectTo(t, makeHandler(viewHandler, true), "GET", "/view/testdata", nil, "/view/testdata/index")
HTTPRedirectTo(t, makeHandler(viewHandler, true), "GET", "/view/testdata/", nil, "/view/testdata/index")
assert.NoError(t, os.Mkdir("testdata/dir", 0755))
HTTPRedirectTo(t, makeHandler(viewHandler, true), "GET", "/view/testdata/dir", nil, "/view/testdata/dir/index")
HTTPRedirectTo(t, makeHandler(viewHandler, true), "GET", "/view/testdata/dir/", nil, "/view/testdata/dir/index")
assert.NoError(t, os.Mkdir("testdata/dir/dir", 0755))
HTTPRedirectTo(t, makeHandler(viewHandler, true), "GET", "/view/testdata/dir", nil, "/view/testdata/dir/index")
HTTPRedirectTo(t, makeHandler(viewHandler, true), "GET", "/view/testdata/dir/", nil, "/view/testdata/dir/index")
HTTPRedirectTo(t, makeHandler(viewHandler, true), "GET", "/view/testdata/dir/dir", nil, "/view/testdata/dir/dir/index")
HTTPRedirectTo(t, makeHandler(viewHandler, true), "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, true), "GET", "/view/testdata/dir/dir", nil), "<h1>Blackbird</h1>")
assert.Contains(t, assert.HTTPBody(makeHandler(viewHandler, true), "GET", "/view/testdata/dir/dir.md", nil), "# Blackbird")
HTTPRedirectTo(t, makeHandler(viewHandler, true), "GET", "/view/testdata/dir/dir/", nil, "/view/testdata/dir/dir/index")
HTTPRedirectTo(t, makeHandler(viewHandler, true), "GET", "/view/man", nil, "/view/man/index")
HTTPRedirectTo(t, makeHandler(viewHandler, true), "GET", "/view/man/", nil, "/view/man/index")
}
// relies on index.md in the current directory!
func TestViewHandlerWithId(t *testing.T) {
data := make(url.Values)
data.Set("id", "index")
assert.Contains(t,
assert.HTTPBody(makeHandler(viewHandler, true), "GET", "/view/", data),
"Welcome to Oddµ")
assert.Regexp(t, regexp.MustCompile("Welcome to Oddµ"),
assert.HTTPBody(makeHandler(viewHandler, true), "GET", "/view/", data))
}
func TestPageTitleWithAmp(t *testing.T) {
@@ -58,16 +39,14 @@ func TestPageTitleWithAmp(t *testing.T) {
p := &Page{Name: "testdata/amp/Rock & Roll", Body: []byte("Dancing")}
p.save()
assert.Contains(t,
assert.HTTPBody(makeHandler(viewHandler, true), "GET", "/view/testdata/amp/Rock%20%26%20Roll", nil),
"Rock &amp; Roll")
assert.Regexp(t, regexp.MustCompile("Rock &amp; Roll"),
assert.HTTPBody(makeHandler(viewHandler, true), "GET", "/view/testdata/amp/Rock%20%26%20Roll", nil))
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, true), "GET", "/view/testdata/amp/Rock%20%26%20Roll", nil),
"Sex &amp; Drugs")
assert.Regexp(t, regexp.MustCompile("Sex &amp; Drugs"),
assert.HTTPBody(makeHandler(viewHandler, true), "GET", "/view/testdata/amp/Rock%20%26%20Roll", nil))
}
func TestPageTitleWithQuestionMark(t *testing.T) {
@@ -97,13 +76,7 @@ In the autumn chill
HTTPStatusCodeIfModifiedSince(t, h, "/view/testdata/file-mod/now.txt", fi.ModTime())
}
func TestForbidden(t *testing.T) {
assert.HTTPStatusCode(t, makeHandler(viewHandler, true), "GET", "/view/", nil, http.StatusFound)
assert.HTTPStatusCode(t, makeHandler(viewHandler, true), "GET", "/view/.", nil, http.StatusForbidden)
assert.HTTPStatusCode(t, makeHandler(viewHandler, true), "GET", "/view/.htaccess", nil, http.StatusForbidden)
assert.HTTPStatusCode(t, makeHandler(viewHandler, true), "GET", "/view/.git/description", nil, http.StatusForbidden)
}
// wipes testdata
func TestPageLastModified(t *testing.T) {
cleanup(t, "testdata/page-mod")
p := &Page{Name: "testdata/page-mod/now", Body: []byte(`

221
watch.go
View File

@@ -1,221 +0,0 @@
package main
import (
"github.com/fsnotify/fsnotify"
"io/fs"
"log"
"os"
"path/filepath"
"slices"
"strings"
"sync"
"time"
)
// watchStore controls access to the maps used by the filesystem watches. Make sure to lock and unlock as appropriate.
// The maps are used to control a sort of queue for files that need reloading (if a template) or reindexing (if a page).
// File system notifications add files to the queue in order to handle changes made without Oddmu, while Oddmu is
// running.
type watchStore struct {
sync.RWMutex
// files contains the filenames that have been queued for reloading (if a template) or reindexing (if a page)
// and the exact time at which they have been added. When the same file is added multiple times, such as when
// the watchStore function sees multiple Write events for the same file, the time keeps getting updated so that
// when the watchTimer runs, it only acts on files that haven't been updated in the last second.
files map[string]time.Time
// ignores contains the files that some code intends to change, knowing that subsequent writes events would
// result in file system notifications that would end up adding the filenames to the queue for reloading (if a
// template) or reindexing (if a page). When Oddmu is making the changes, it can ignore the corresponding
// notifications by the file system. Those notifications are consequences of Oddmu doing its job. In other
// words, Oddmu does not rely on file system notification even it is Oddmu doing the changes. This avoids a 1s
// when changing templates, for example.
ignores map[string]time.Time
// watcher is the pointer to the actual watcher doing the file system watching. It watches a set of paths.
// Whenever Oddmu creates a new subdirectory, it adds the path for this subdirectory to the watcher.
watcher *fsnotify.Watcher
}
var watches watchStore
func init() {
watches.ignores = make(map[string]time.Time)
watches.files = make(map[string]time.Time)
}
// install initializes watches and installs watchers for all directories and subdirectories.
func (w *watchStore) install() (int, error) {
// create a watcher for the root directory and never close it
var err error
w.watcher, err = fsnotify.NewWatcher()
if err != nil {
log.Println("Creating a watcher for file changes:", err)
return 0, err
}
go w.watch()
err = filepath.Walk(".", w.add)
if err != nil {
return 0, err
}
return len(w.watcher.WatchList()), nil
}
// add installs a watch for every directory that isn't hidden. Note that the root directory (".") is not skipped.
func (w *watchStore) add(path string, info fs.FileInfo, err error) error {
if err != nil {
return err
}
if info.IsDir() {
if path != "." && strings.HasPrefix(filepath.Base(path), ".") {
return filepath.SkipDir
}
err := w.watcher.Add(path)
if err != nil {
log.Println("Cannot add watch:", path)
return err
}
// log.Println("Watching", path)
}
return nil
}
// watch reloads templates that have changed and reindexes fils that have changed. Since there can be multiple writes to
// a file, there's a 1s delay before a file is actually handled. The reason is that writing a file can cause multiple
// Write events and we don't want to keep reloading the template while it is being written. Instead, each Write event
// adds an entry to the files map, or updates the file's time, and starts a go routine. Example: If a file gets three
// consecutive Write events, the first two go routine invocations won't do anything, since the time kept getting
// updated. Only the last invocation will act upon the event.
func (w *watchStore) watch() {
for {
select {
case err, ok := <-w.watcher.Errors:
if !ok {
return
}
log.Println("Watcher:", err)
case e, ok := <-w.watcher.Events:
if !ok {
return
}
w.watchHandle(e)
}
}
}
// watchHandle is called for every fsnotify.Event. It handles template updates, page updates (both on a 1s timer), and
// the creation of pages and directories (immediately). Files and directories starting with a dot are skipped.
// Incidentally, this also prevents rsync updates from generating activity ("stat ./.index.md.tTfPFg: no such file or
// directory"). Note the painful details: If moving a file into a watched directory, a Create event is received. If a
// new file is created in a watched directory, a Create event and one or more Write events is received.
func (w *watchStore) watchHandle(e fsnotify.Event) {
path := strings.TrimPrefix(e.Name, "./")
if strings.HasPrefix(filepath.Base(path), ".") {
return
}
// log.Println(e)
w.Lock()
defer w.Unlock()
if e.Op.Has(fsnotify.Create|fsnotify.Write) &&
(strings.HasSuffix(path, ".html") &&
slices.Contains(templateFiles, filepath.Base(path)) ||
strings.HasSuffix(path, ".md")) {
w.files[path] = time.Now()
timer := time.NewTimer(time.Second)
go func() {
<-timer.C
w.Lock()
defer w.Unlock()
w.watchTimer(path)
}()
} else if e.Op.Has(fsnotify.Rename | fsnotify.Remove) {
w.watchDoRemove(path)
} else if e.Op.Has(fsnotify.Create) &&
!slices.Contains(w.watcher.WatchList(), path) {
fi, err := os.Stat(path)
if err != nil {
log.Println(err)
} else if fi.IsDir() {
log.Println("Add watch for", path)
w.watcher.Add(path)
}
}
}
// watchTimer checks if the file hasn't been updated in 1s and if so, it calls watchDoUpdate. If another write has
// updated the file, do nothing because another watchTimer will run at the appropriate time and check again.
func (w *watchStore) watchTimer(path string) {
t, ok := w.files[path]
if ok && t.Add(time.Second).Before(time.Now().Add(time.Nanosecond)) {
delete(w.files, path)
w.watchDoUpdate(path)
}
}
// Do the right thing right now. For Create events such as directories being created or files being moved into a watched
// directory, this is the right thing to do. When a file is being written to, watchHandle will have started a timer and
// will call this function after 1s of no more writes. If, however, the path is in the ignores map, do nothing.
func (w *watchStore) watchDoUpdate(path string) {
_, ignored := w.ignores[path]
if ignored {
return
} else if strings.HasSuffix(path, ".html") {
updateTemplate(path)
} else if strings.HasSuffix(path, ".md") {
p, err := loadPage(path[:len(path)-3]) // page name without ".md"
if err != nil {
log.Println("Cannot load page", path)
} else {
log.Println("Update index for", path)
index.update(p)
}
} else if !slices.Contains(w.watcher.WatchList(), path) {
fi, err := os.Stat(path)
if err != nil {
log.Println(err)
return
}
if fi.IsDir() {
log.Println("Add watch for", path)
w.watcher.Add(path)
}
}
}
// watchDoRemove removes files from the index or discards templates. If the path in question is in the ignores map, do
// nothing.
func (w *watchStore) watchDoRemove(path string) {
_, ignored := w.ignores[path]
if ignored {
return
} else if strings.HasSuffix(path, ".html") {
removeTemplate(path)
} else if strings.HasSuffix(path, ".md") {
_, err := os.Stat(path)
if err == nil {
log.Println("Cannot remove existing page from the index", path)
} else {
log.Println("Deindex", path)
index.deletePageName(path[:len(path)-3]) // page name without ".md"
}
}
}
// ignore is before code that is known suspected save files and trigger watchHandle eventhough the code already handles
// this. This is achieved by adding the path to the ignores map for 1s.
func (w *watchStore) ignore(path string) {
w.Lock()
defer w.Unlock()
w.ignores[path] = time.Now()
timer := time.NewTimer(time.Second)
go func() {
<-timer.C
w.Lock()
defer w.Unlock()
t := w.ignores[path]
if t.Add(time.Second).Before(time.Now().Add(time.Nanosecond)) {
delete(w.ignores, path)
}
}()
}

View File

@@ -1,89 +0,0 @@
package main
import (
"github.com/stretchr/testify/assert"
"os"
"testing"
"time"
)
func TestWatchedPageUpdate(t *testing.T) {
dir := "testdata/watched-page"
path := dir + "/haiku.md"
cleanup(t, dir)
index.load()
watches.install()
assert.NoError(t, os.MkdirAll(dir, 0755))
time.Sleep(time.Millisecond)
assert.Contains(t, watches.watcher.WatchList(), dir)
haiku := []byte(`# Pine cones
Soft steps on the trail
Up and up in single file
Who ate half a cone?`)
assert.NoError(t, os.WriteFile(path, haiku, 0644))
time.Sleep(time.Millisecond)
watches.RLock()
assert.Contains(t, watches.files, path)
watches.RUnlock()
watches.Lock()
watches.files[path] = watches.files[path].Add(-2 * time.Second)
watches.Unlock()
watches.watchTimer(path)
index.RLock()
assert.Contains(t, index.titles, path[:len(path)-3])
index.RUnlock()
}
func TestWatchedTemplateUpdate(t *testing.T) {
dir := "testdata/watched-template"
name := dir + "/raclette"
path := dir + "/view.html"
cleanup(t, dir)
index.load()
watches.install()
assert.NoError(t, os.MkdirAll(dir, 0755))
time.Sleep(time.Millisecond)
assert.Contains(t, watches.watcher.WatchList(), dir)
p := &Page{Name: name, Body: []byte(`# Raclette
The heat element
glows red and the cheese bubbles
the smell is everywhere
`)}
p.save()
assert.Contains(t,
assert.HTTPBody(makeHandler(viewHandler, true), "GET", "/view/testdata/watched-template/raclette", nil),
"Skip navigation")
// save a new view handler directly
assert.NoError(t,
os.WriteFile(path,
[]byte("<body><h1>{{.Title}}</h1>{{.Html}}"),
0644))
time.Sleep(time.Millisecond)
watches.RLock()
assert.Contains(t, watches.files, path)
watches.RUnlock()
watches.Lock()
watches.files[path] = watches.files[path].Add(-2 * time.Second)
watches.Unlock()
watches.watchTimer(path)
body := assert.HTTPBody(makeHandler(viewHandler, true), "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
}

80
wiki.go
View File

@@ -1,6 +1,3 @@
// Oddmu is a wiki web server and a static site generator.
//
// The types exported are the ones needed to write the templates.
package main
import (
@@ -8,6 +5,7 @@ import (
"flag"
"fmt"
"github.com/google/subcommands"
"html/template"
"io/fs"
"log"
"net"
@@ -27,19 +25,22 @@ var validPath = regexp.MustCompile("^/([^/]+)/(.*)$")
// instead.
var titleRegexp = regexp.MustCompile("(?m)^#\\s*(.*)\n+")
// renderTemplate is the helper that is used render the templates with data. If the templates cannot be found, that's
// fatal.
func renderTemplate(w http.ResponseWriter, tmpl string, data any) {
templates := loadTemplates()
err := templates.ExecuteTemplate(w, tmpl+".html", data)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
// makeHandler returns a handler that uses the URL path without the first path element as its argument, e.g. if the URL
// path is /edit/foo/bar, the editHandler is called with "foo/bar" as its argument. This uses the second group from the
// validPath regular expression. The boolean argument indicates whether the following path is required. When false, a
// URL like /upload/ is OK. The argument can also be provided using a form parameter, i.e. call /edit/?id=foo/bar.
func makeHandler(fn func(http.ResponseWriter, *http.Request, string), required bool) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// no hidden files or directories
for _, segment := range strings.Split(r.URL.Path, "/") {
if strings.HasPrefix(segment, ".") {
http.Error(w, "can neither confirm nor deny the existence of this resource", http.StatusForbidden)
return
}
}
m := validPath.FindStringSubmatch(r.URL.Path)
if m != nil && (!required || len(m[2]) > 0) {
fn(w, r, m[2])
@@ -51,13 +52,6 @@ func makeHandler(fn func(http.ResponseWriter, *http.Request, string), required b
return
}
id := r.Form.Get("id")
// no hidden files or directories
for _, segment := range strings.Split(id, "/") {
if strings.HasPrefix(segment, ".") {
http.Error(w, "can neither confirm nor deny the existence of this resource", http.StatusForbidden)
return
}
}
if m != nil {
fn(w, r, id)
return
@@ -126,43 +120,20 @@ func scheduleLoadLanguages() {
log.Printf("Loaded %d languages", n)
}
// scheduleInstallWatcher calls watches.install and prints some messages before and after. For testing, call watch.init
// directly and skip the messages.
func scheduleInstallWatcher() {
log.Print("Installing watcher")
n, err := watches.install()
if err == nil {
if n == 1 {
log.Println("Installed watchers for one directory")
} else {
log.Printf("Installed watchers for %d directories", n)
}
} else {
log.Printf("Installing watcher failed: %s", err)
// loadTemplates loads the templates. These aren't always required. If the templates are required and cannot be loaded,
// this a fatal error and the program exits.
func loadTemplates() *template.Template {
templates, err := template.ParseFiles("edit.html", "add.html", "view.html",
"diff.html", "search.html", "static.html", "upload.html", "feed.html")
if err != nil {
log.Println("Templates:", err)
os.Exit(1)
}
return templates
}
// serve starts the web server using [http.Serve]. The listener is determined via [getListener]. The various handlers
// are created using [makeHandler] if their path starts with an action segment. For example, the URL path "/view/index"
// is understood to contain the "view" action and so [viewHandler] is called with the argument "index". The one handler
// that doesn't need this is [rootHandler].
//
// The handlers often come in pairs. One handler to show the user interface and one handler to make the change:
// - [editHandler] shows the edit form and [saveHandler] saves changes to a page
// - [addHandler] shows the add form and [appendHandler] appends the addition to a page
// - [uploadHandler] shows the upload form and [dropHandler] saves the uploaded files
//
// Some handlers only do something and the links or forms to call them is expected to be part of the view template:
// - [archiveHandler] zips up the current directory
// - [diffHandler] shows the changes made in the last 60min to a page
// - [searchHandler] shows search results
//
// At the same time as the server starts up, pages are indexed via [scheduleLoadIndex], languages are loaded via
// [scheduleLoadLanguages] and the current directory and its subdirectories is watched for changes using watchers
// installed via [scheduleInstallWatcher].
func serve() {
http.HandleFunc("/", rootHandler)
http.HandleFunc("/archive/", makeHandler(archiveHandler, true))
http.HandleFunc("/view/", makeHandler(viewHandler, true))
http.HandleFunc("/diff/", makeHandler(diffHandler, true))
http.HandleFunc("/edit/", makeHandler(editHandler, true))
@@ -174,7 +145,7 @@ func serve() {
http.HandleFunc("/search/", makeHandler(searchHandler, false))
go scheduleLoadIndex()
go scheduleLoadLanguages()
go scheduleInstallWatcher()
initAccounts()
listener, err := getListener()
if listener == nil {
log.Println(err)
@@ -194,20 +165,17 @@ func commands() {
subcommands.Register(subcommands.CommandsCommand(), "")
subcommands.Register(&htmlCmd{}, "")
subcommands.Register(&listCmd{}, "")
subcommands.Register(&staticCmd{}, "")
subcommands.Register(&searchCmd{}, "")
subcommands.Register(&replaceCmd{}, "")
subcommands.Register(&missingCmd{}, "")
subcommands.Register(&notifyCmd{}, "")
subcommands.Register(&replaceCmd{}, "")
subcommands.Register(&searchCmd{}, "")
subcommands.Register(&staticCmd{}, "")
subcommands.Register(&versionCmd{}, "")
flag.Parse()
ctx := context.Background()
os.Exit(int(subcommands.Execute(ctx)))
}
// main runs [serve] if called without arguments and it runs [commands] if called with arguments.
// The first argument is the subcommand.
func main() {
if len(os.Args) == 1 {
serve()