6 Commits
v0.7 ... v0.8

Author SHA1 Message Date
Alex Schroeder
16b475ea7f Splitting files and refactoring it all 2023-09-15 12:26:45 +02:00
Alex Schroeder
0a7eaa455a Add Upload to the View template 2023-09-15 00:54:06 +02:00
Alex Schroeder
4cad4a988a Fix cancel button type for Add template 2023-09-15 00:53:52 +02:00
Alex Schroeder
cc58980ec0 Add command line search 2023-09-15 00:53:40 +02:00
Alex Schroeder
068bc21eea More tests for the snippets code 2023-09-15 00:50:30 +02:00
Alex Schroeder
f60cd09267 Write a backup file for page edits 2023-09-14 16:26:22 +02:00
24 changed files with 635 additions and 482 deletions

View File

@@ -253,7 +253,7 @@ MDCertificateAgreement accepted
ServerAdmin alex@alexschroeder.ch
ServerName transjovian.org
SSLEngine on
ProxyPassMatch ^/(search|upload|save|(view|edit|save|add|append)/(.*))?$ http://localhost:8080/$1
ProxyPassMatch ^/(search|(view|edit|save|add|append|upload|drop)/(.*))?$ http://localhost:8080/$1
</VirtualHost>
```
@@ -311,11 +311,11 @@ htpasswd -D .htpasswd berta
```
Modify your site configuration and protect the `/edit/`, `/save/`,
`/add/`, `/append/`, `/upload` and `/save` URLs with a password by
`/add/`, `/append/`, `/upload/` and `/drop/` URLs with a password by
adding the following to your `<VirtualHost *:443>` section:
```apache
<LocationMatch "^/(upload|save|(edit|save|add|append)/(.*))$">
<LocationMatch "^/(edit|save|add|append|upload|drop)/">
AuthType Basic
AuthName "Password Required"
AuthUserFile /home/oddmu/.htpasswd
@@ -340,7 +340,7 @@ webserver can read (world readable file, world readable and executable
directory). Populate it with files.
Make sure that none of the static files look like the wiki paths
`/view/`, `/edit/`, `/save/`, `/add/`, `/append/`, `/upload`, `/save`
`/view/`, `/edit/`, `/save/`, `/add/`, `/append/`, `/upload/`, `/drop/`
or `/search`. For example, create a file called `robots.txt`
containing the following, tellin all robots that they're not welcome.
@@ -366,7 +366,7 @@ above.
This requires a valid login by the user "alex" or "berta":
```apache
<LocationMatch "^/(edit|save|add|append)/intetebi/">
<LocationMatch "^/(edit|save|add|append|upload|drop)/intetebi/">
Require user alex berta
</LocationMatch>
```

View File

@@ -15,7 +15,7 @@ form, textarea { width: 100%; }
<form action="/append/{{.Name}}" method="POST">
<textarea name="body" rows="20" cols="80" placeholder="Text" autofocus required></textarea>
<p><input type="submit" value="Add">
<a href="/view/{{.Name}}"><button>Cancel</button></a></p>
<a href="/view/{{.Name}}"><button type="button">Cancel</button></a></p>
</form>
</body>
</html>

37
add_append.go Normal file
View File

@@ -0,0 +1,37 @@
package main
import (
"net/http"
)
// addHandler uses the "add.html" template to present an empty edit
// page. What you type there is appended to the page using the
// appendHandler.
func addHandler(w http.ResponseWriter, r *http.Request, name string) {
p, err := loadPage(name)
if err != nil {
p = &Page{Title: name, Name: name}
} else {
p.handleTitle(false)
}
renderTemplate(w, "add", p)
}
// 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)
if err != nil {
p = &Page{Title: name, Name: name, Body: []byte(body)}
} else {
p.handleTitle(false)
p.Body = append(p.Body, []byte(body)...)
}
err = p.save()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/view/"+name, http.StatusFound)
}

36
add_append_test.go Normal file
View File

@@ -0,0 +1,36 @@
package main
import (
"github.com/stretchr/testify/assert"
"net/url"
"os"
"regexp"
"testing"
)
// wipes testdata
func TestAddAppend(t *testing.T) {
_ = os.RemoveAll("testdata")
index.load()
p := &Page{Name: "testdata/fire", Body: []byte(`# Fire
Orange sky above
Reflects a distant fire
It's not `)}
p.save()
data := url.Values{}
data.Set("body", "barbecue")
assert.Regexp(t, regexp.MustCompile("a distant fire"),
assert.HTTPBody(makeHandler(viewHandler, true), "GET", "/view/testdata/fire", nil))
assert.NotRegexp(t, regexp.MustCompile("a distant fire"),
assert.HTTPBody(makeHandler(addHandler, true), "GET", "/add/testdata/fire", nil))
HTTPRedirectTo(t, makeHandler(appendHandler, true), "POST", "/append/testdata/fire", data, "/view/testdata/fire")
assert.Regexp(t, regexp.MustCompile("Its not barbecue"),
assert.HTTPBody(makeHandler(viewHandler, true), "GET", "/view/testdata/fire", nil))
t.Cleanup(func() {
_ = os.RemoveAll("testdata")
})
}

View File

@@ -14,12 +14,23 @@ func commands() {
p.renderHtml();
fmt.Println(p.Html);
}
} else if len(os.Args) > 2 && os.Args[1] == "search" {
index.load()
for _, q := range os.Args[2:] {
items := search(q)
fmt.Printf("Search %s: %d results\n", q, len(items))
for _, p := range items {
fmt.Printf("* %s (%d)\n", p.Title, p.Score)
}
}
} else {
fmt.Printf("Unknown command: %v\n", os.Args[1:])
fmt.Print("Without any arguments, serves a wiki.\n")
fmt.Print(" Environment variable ODDMUSE_PORT controls the port.\n")
fmt.Print(" Environment variable ODDMUSE_LANGAUGES controls the languages detected.\n")
fmt.Print(" Environment variable ODDMUSE_PORT controls the port.\n")
fmt.Print(" Environment variable ODDMUSE_LANGAUGES controls the languages detected.\n")
fmt.Print("html PAGENAME\n")
fmt.Print(" Print the HTML of the page.\n")
fmt.Print(" Print the HTML of the page.\n")
fmt.Print("search TERMS\n")
fmt.Print(" Print the titles of the page with score.\n")
}
}

View File

@@ -7,7 +7,8 @@ import (
// Use go test -race to see whether this is a race condition.
func TestLoadAndSearch(t *testing.T) {
go loadIndex()
index.reset()
go index.load()
q := "Oddµ"
pages := search(q)
assert.Zero(t, len(pages))

32
edit_save.go Normal file
View File

@@ -0,0 +1,32 @@
package main
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.
func editHandler(w http.ResponseWriter, r *http.Request, name string) {
p, err := loadPage(name)
if err != nil {
p = &Page{Title: name, Name: name}
} else {
p.handleTitle(false)
}
renderTemplate(w, "edit", p)
}
// 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 {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/view/"+name, http.StatusFound)
}

27
edit_save_test.go Normal file
View File

@@ -0,0 +1,27 @@
package main
import (
"github.com/stretchr/testify/assert"
"net/url"
"os"
"regexp"
"testing"
)
// wipes testdata
func TestEditSave(t *testing.T) {
_ = os.RemoveAll("testdata")
data := url.Values{}
data.Set("body", "Hallo!")
HTTPRedirectTo(t, makeHandler(viewHandler, true), "GET", "/view/testdata/alex", nil, "/edit/testdata/alex")
assert.HTTPStatusCode(t, makeHandler(editHandler, true), "GET", "/edit/testdata/alex", nil, 200)
HTTPRedirectTo(t, makeHandler(saveHandler, true), "POST", "/save/testdata/alex", data, "/view/testdata/alex")
assert.Regexp(t, regexp.MustCompile("Hallo!"),
assert.HTTPBody(makeHandler(viewHandler, true), "GET", "/view/testdata/alex", nil))
t.Cleanup(func() {
_ = os.RemoveAll("testdata")
})
}

112
index.go Normal file
View File

@@ -0,0 +1,112 @@
package main
import (
trigram "github.com/dgryski/go-trigram"
"io/fs"
"path/filepath"
"strings"
"sync"
)
// Index contains the two maps used for search. Make sure to lock and
// unlock as appropriate.
type Index struct {
sync.RWMutex
// index is a struct containing the trigram index for search.
// It is generated at startup and updated after every page
// edit. The index is case-insensitive.
index trigram.Index
// documents is a map, mapping document ids of the index to
// page names.
documents map[trigram.DocID]string
}
// idx is the global Index per wiki.
var index Index
// reset resets the Index. This assumes that the index is locked!
func (idx *Index) reset() {
idx.index = nil
idx.documents = nil
}
// add reads a file and adds it to the index. This must happen while
// the idx is locked, which is true when called from loadIndex.
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
}
id := idx.index.Add(strings.ToLower(string(p.Body)))
idx.documents[id] = p.Name
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.index = make(trigram.Index)
idx.documents = make(map[trigram.DocID]string)
err := filepath.Walk(".", idx.add)
if err != nil {
idx.reset()
return 0, err
}
n := len(idx.documents)
return n, nil
}
// 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 trigram.DocID
// This function does not rely on files actually existing, so
// let's quickly find the document id.
for docId, name := range index.documents {
if name == p.Name {
id = docId
break
}
}
if id == 0 {
id = index.index.Add(strings.ToLower(string(p.Body)))
index.documents[id] = p.Name
} else {
o, err := loadPage(p.Name)
if err == nil {
index.index.Delete(strings.ToLower(string(o.Body)), id)
}
index.index.Insert(strings.ToLower(string(p.Body)), id)
}
}
func searchDocuments(q string) []string {
words := strings.Fields(strings.ToLower(q))
var trigrams []trigram.T
for _, word := range words {
trigrams = trigram.Extract(word, trigrams)
}
index.RLock()
ids := index.index.QueryTrigrams(trigrams)
names := make([]string, len(ids))
for i, id := range ids {
names[i] = index.documents[id]
}
index.RUnlock()
return names
}

97
index_test.go Normal file
View File

@@ -0,0 +1,97 @@
package main
import (
"github.com/stretchr/testify/assert"
"os"
"strings"
"testing"
)
// TestIndex relies on README.md being indexed
func TestIndex(t *testing.T) {
index.load()
q := "Oddµ"
pages := search(q)
assert.NotZero(t, len(pages))
for _, p := range pages {
assert.NotContains(t, p.Title, "<b>")
assert.True(t, strings.Contains(string(p.Body), q) || strings.Contains(string(p.Title), q))
assert.NotZero(t, p.Score)
}
}
func TestSearchHashtag(t *testing.T) {
index.load()
q := "#Another_Tag"
pages := search(q)
assert.NotZero(t, len(pages))
}
func TestIndexUpdates(t *testing.T) {
name := "test"
_ = os.Remove(name + ".md")
index.load()
p := &Page{Name: name, Body: []byte("This is a test.")}
p.save()
// Find the phrase
pages := search("This is a test")
found := false
for _, p := range pages {
if p.Name == name {
found = true
break
}
}
assert.True(t, found)
// Find the phrase, case insensitive
pages = search("this is a test")
found = false
for _, p := range pages {
if p.Name == name {
found = true
break
}
}
assert.True(t, found)
// Find some words
pages = search("this test")
found = false
for _, p := range pages {
if p.Name == name {
found = true
break
}
}
assert.True(t, found)
// Update the page and no longer find it with the old phrase
p = &Page{Name: name, Body: []byte("Guvf vf n grfg.")}
p.save()
pages = search("This is a test")
found = false
for _, p := range pages {
if p.Name == name {
found = true
break
}
}
assert.False(t, found)
// Find page using a new word
pages = search("Guvf")
found = false
for _, p := range pages {
if p.Name == name {
found = true
break
}
}
assert.True(t, found)
t.Cleanup(func() {
_ = os.Remove(name + ".md")
})
}

17
page.go
View File

@@ -51,13 +51,15 @@ func nameEscape(s string) string {
// 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"). The file permissions used are readable
// and writeable for the current user, i.e. u+rw or 0600. Page.Title
// and Page.Html are not saved no caching. There is no caching.
// 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 {
filename := p.Name + ".md"
s := bytes.ReplaceAll(p.Body, []byte{'\r'}, []byte{})
if len(s) == 0 {
_ = os.Rename(filename, filename + "~")
return os.Remove(filename)
}
p.Body = s
@@ -70,6 +72,7 @@ func (p *Page) save() error {
return err
}
}
_ = os.Rename(filename, filename + "~")
return os.WriteFile(filename, s, 0644)
}
@@ -165,3 +168,11 @@ func (p *Page) summarize(q string) {
p.Html = sanitize(snippets(q, t))
p.Language = language(t)
}
func (p *Page) Dir() string {
d := filepath.Dir(p.Name)
if d == "." {
return ""
}
return d
}

View File

@@ -62,14 +62,16 @@ I am cold, alone</p>
assert.Equal(t, r, string(p.Html))
}
// wipes testdata
func TestPageDir(t *testing.T) {
_ = os.RemoveAll("testdata")
loadIndex()
index.load()
p := &Page{Name: "testdata/moon", Body: []byte(`# Moon
From bed to bathroom
A slow shuffle in the dark
Moonlight floods the aisle`)}
p.save()
o, err := loadPage("testdata/moon")
assert.NoError(t, err, "load page")
assert.Equal(t, p.Body, o.Body)
@@ -79,6 +81,10 @@ Moonlight floods the aisle`)}
p = &Page{Name: "testdata/moon", Body: []byte("")}
p.save()
assert.NoFileExists(t, "testdata/moon.md")
// But the backup still exists.
assert.FileExists(t, "testdata/moon.md~")
t.Cleanup(func() {
_ = os.RemoveAll("testdata")
})

110
search.go
View File

@@ -2,12 +2,8 @@ package main
import (
"fmt"
trigram "github.com/dgryski/go-trigram"
"io/fs"
"path/filepath"
"net/http"
"slices"
"strings"
"sync"
"unicode"
"unicode/utf8"
)
@@ -22,85 +18,6 @@ type Search struct {
Results bool
}
// idx contains the two maps used for search. Make sure to lock and
// unlock as appropriate.
var idx = struct {
sync.RWMutex
// index is a struct containing the trigram index for search. It is
// generated at startup and updated after every page edit. The index
// is case-insensitive.
index trigram.Index
// documents is a map, mapping document ids of the index to page
// names.
documents map[trigram.DocID]string
}{}
// indexAdd reads a file and adds it to the index. This must happen
// while the idx is locked, which is true when called from loadIndex.
func indexAdd(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
}
id := idx.index.Add(strings.ToLower(string(p.Body)))
idx.documents[id] = p.Name
return nil
}
// loadIndex loads all the pages and indexes them. This takes a while.
// It returns the number of pages indexed.
func loadIndex() (int, error) {
idx.Lock()
defer idx.Unlock()
idx.index = make(trigram.Index)
idx.documents = make(map[trigram.DocID]string)
err := filepath.Walk(".", indexAdd)
if err != nil {
idx.index = nil
idx.documents = nil
return 0, err
}
n := len(idx.documents)
return n, nil
}
// 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() {
idx.Lock()
defer idx.Unlock()
var id trigram.DocID
// This function does not rely on files actually existing, so
// let's quickly find the document id.
for docId, name := range idx.documents {
if name == p.Name {
id = docId
break
}
}
if id == 0 {
id = idx.index.Add(strings.ToLower(string(p.Body)))
idx.documents[id] = p.Name
} else {
o, err := loadPage(p.Name)
if err == nil {
idx.index.Delete(strings.ToLower(string(o.Body)), id)
}
idx.index.Insert(strings.ToLower(string(p.Body)), id)
}
}
func sortItems(a, b Page) int {
// Sort by score
if a.Score < b.Score {
@@ -154,21 +71,18 @@ func search(q string) []Page {
if len(q) == 0 {
return make([]Page, 0)
}
words := strings.Fields(strings.ToLower(q))
var trigrams []trigram.T
for _, word := range words {
trigrams = trigram.Extract(word, trigrams)
}
// Keep the read lock for a short as possible. Make a list of
// the names we need to load and summarize.
idx.RLock()
ids := idx.index.QueryTrigrams(trigrams)
names := make([]string, len(ids))
for i, id := range ids {
names[i] = idx.documents[id]
}
idx.RUnlock()
names := searchDocuments(q)
items := loadAndSummarize(names, q)
slices.SortFunc(items, sortItems)
return items
}
// 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.
func searchHandler(w http.ResponseWriter, r *http.Request) {
q := r.FormValue("q")
items := search(q)
s := &Search{Query: q, Items: items, Results: len(items) > 0}
renderTemplate(w, "search", s)
}

View File

@@ -2,96 +2,13 @@ package main
import (
"github.com/stretchr/testify/assert"
"os"
"strings"
"testing"
"net/url"
)
// TestIndex relies on README.md being indexed
func TestIndex(t *testing.T) {
loadIndex()
q := "Oddµ"
pages := search(q)
assert.NotZero(t, len(pages))
for _, p := range pages {
assert.NotContains(t, p.Title, "<b>")
assert.True(t, strings.Contains(string(p.Body), q) || strings.Contains(string(p.Title), q))
assert.NotZero(t, p.Score)
}
}
func TestSearchHashtag(t *testing.T) {
loadIndex()
q := "#Another_Tag"
pages := search(q)
assert.NotZero(t, len(pages))
}
func TestIndexUpdates(t *testing.T) {
name := "test"
_ = os.Remove(name + ".md")
loadIndex()
p := &Page{Name: name, Body: []byte("This is a test.")}
p.save()
// Find the phrase
pages := search("This is a test")
found := false
for _, p := range pages {
if p.Name == name {
found = true
break
}
}
assert.True(t, found)
// Find the phrase, case insensitive
pages = search("this is a test")
found = false
for _, p := range pages {
if p.Name == name {
found = true
break
}
}
assert.True(t, found)
// Find some words
pages = search("this test")
found = false
for _, p := range pages {
if p.Name == name {
found = true
break
}
}
assert.True(t, found)
// Update the page and no longer find it with the old phrase
p = &Page{Name: name, Body: []byte("Guvf vf n grfg.")}
p.save()
pages = search("This is a test")
found = false
for _, p := range pages {
if p.Name == name {
found = true
break
}
}
assert.False(t, found)
// Find page using a new word
pages = search("Guvf")
found = false
for _, p := range pages {
if p.Name == name {
found = true
break
}
}
assert.True(t, found)
t.Cleanup(func() {
_ = os.Remove(name + ".md")
})
func TestSearch(t *testing.T) {
data := url.Values{}
data.Set("q", "oddµ")
assert.Contains(t,
assert.HTTPBody(searchHandler, "GET", "/search", data), "Welcome")
}

View File

@@ -56,6 +56,7 @@ func snippets(q string, s string) string {
}
jsnippet++
j = strings.Index(s, m[1])
wl := len(m[1])
if j > -1 {
// get the substring containing the start of
// the match, ending on word boundaries
@@ -69,12 +70,12 @@ func snippets(q string, s string) string {
} else {
start += from
}
to := j + snippetlen/2
to := j + wl + snippetlen/2
if to > len(s) {
to = len(s)
}
end := strings.LastIndex(s[:to], " ")
if end == -1 {
if end == -1 || end <= j + wl {
// OK, look for a longer word
end = strings.Index(s[to:], " ")
if end == -1 {
@@ -84,7 +85,10 @@ func snippets(q string, s string) string {
}
}
t = s[start:end]
res = res + t + " …"
res = res + t
if len(s) > end {
res = res + " …"
}
// truncate text to avoid rematching the same string.
s = s[end:]
}

View File

@@ -1,6 +1,7 @@
package main
import (
"github.com/stretchr/testify/assert"
"testing"
)
@@ -11,8 +12,34 @@ func TestSnippets(t *testing.T) {
q := "all is well"
r := snippets(q, s)
if r != h {
t.Logf("The snippets are wrong in 「%s」", r)
t.Fail()
}
assert.Equal(t, h, r)
}
func TestSnippetsLong(t *testing.T) {
s := `VWwXetig mty8fORN UNia4NFm SQsfyFHk BLDdgVnc AcvKP2fs q8KxPH1A IaCzFj96 J0S2fqca jp3ElV9f ULIZ1aMX GSUO1pLI p7vuJie8 kPfc0ONq EthfIUjm u74guCZ8 IiJYxlR6 5j5LlapY TGO98fOQ fO2RUb1g W8zaPa0v ps0haNzW OOeFwf1h 1N3td7zk 0OoMX8Ek aTd3Ciea 2T1aK9WH QbYfUojs nP59gqvR tqoEK3vJ zJ7JmRby qKReayLo 9BIwFgID 4Q4Tk3HH 1VLdDzSx q0hKUOKm vWkUXz9S 684uXanc gIaJNRFc gabtBO9A EhIh4VtT gJ3p9LYL jPVFqc65 QmMu8FUT vV0iphek 9Vvye5xS q7rJJyxa yHiIEMHA Ce8KLI1B FdbpdvWY qLk23poI aRoZ5LTu fWNL8rcj RpZyI052 HTxj28Q0 GiOjJ1UN iW7zrxBD QPpkiBVE nvOAkh7p c2prdKB8 9DAYvYo5 BPSN8wmO Q2oNZouQ zfEjm5aC lLMDotic hi585ip4 c7LYN3LZ xGmpN32s lcF83ipK 0IwvvEe1 tQxKHCCa u51OKNIE kdEsXUHG tTpUtwbG T6E4hMYv nVpbxCPH 0aACMPtu Oq945xMi wlPQHJ1e bROJU0e7 wdBjAYPt gjIaTuLu bicVsgYN L3a5NLwf 30zu9OHL qtDs1PJM OmTsSOZc v4eM7s8f MQlppFcY 6HTWrZPZ Raj94J30 kcSQPdTQ zsOhnhCQ sQDQkA3a uBP00Du8 qoq7syqj urFj9bqQ TV1EDcpC 4jKGRY27 vb3KgZQy EJillDeB UN4YYoLI hWgf1kqn o1B5s6Wm 98fQL4W0 PXaQeRc2 E45QBYtr od4CfqUo YsPizANv WFJj0nhM h7maM5WQ HuDYldsX qy1NLYCZ ZkvkuCxI hcD6Hyod sDiFWy4n tElzo9YK NNdt31gx NaeEtqmR MGwCCYWu y80zQlGX OAYoTGVY wYs20iOY j4eZDalG HDcd6eWZ Wvxqh0RI jykQ3bNt qRjxSxt6 4HjBIMK1 AIX5UEPr 1HQKp2ZH Fie3kxjb tzwmAigF QntpzTJO 9jQiDIDE LD0OlrSk 8PfSKmt4 MQBr2cK0 FLUQLq2h JfmjaCYv DqkdKyr8 ZtGnI5rj iqhACPMu UsY6ZIpT NjjgMBPV RW4YRcnZ Gyr9nest 9tIXI0km plugRQRv AlFpi0PJ DLcM8Zoq Auk5RBWs tMpfMMlU p6jGYq3Z rTIBTHVM zGFwFwQi j4O1AY21 BJnaiScY`
// match at the very beginning: the first 100 characters or less
assert.Equal(t,
"<b>VWwXetig</b> mty8fORN UNia4NFm SQsfyFHk BLDdgVnc AcvKP2fs q8KxPH1A IaCzFj96 J0S2fqca jp3ElV9f ULIZ1aMX …",
snippets("VWwXetig", s))
// the first 100 … the match, at most 50 (50 from the start of the match)
assert.Equal(t,
"VWwXetig mty8fORN UNia4NFm SQsfyFHk BLDdgVnc AcvKP2fs q8KxPH1A IaCzFj96 J0S2fqca jp3ElV9f ULIZ1aMX … <b>GSUO1pLI</b> p7vuJie8 kPfc0ONq EthfIUjm u74guCZ8 IiJYxlR6 …",
snippets("GSUO1pLI", s))
// the first 100 … less than 50, the match, at most 50
assert.Equal(t,
"VWwXetig mty8fORN UNia4NFm SQsfyFHk BLDdgVnc AcvKP2fs q8KxPH1A IaCzFj96 J0S2fqca jp3ElV9f ULIZ1aMX … GSUO1pLI p7vuJie8 <b>kPfc0ONq</b> EthfIUjm u74guCZ8 IiJYxlR6 5j5LlapY TGO98fOQ …",
snippets("kPfc0ONq", s))
// the first 100 … 50, the match, at most 50
assert.Equal(t,
"VWwXetig mty8fORN UNia4NFm SQsfyFHk BLDdgVnc AcvKP2fs q8KxPH1A IaCzFj96 J0S2fqca jp3ElV9f ULIZ1aMX … u74guCZ8 IiJYxlR6 5j5LlapY TGO98fOQ fO2RUb1g <b>W8zaPa0v</b> ps0haNzW OOeFwf1h 1N3td7zk 0OoMX8Ek aTd3Ciea …",
snippets("W8zaPa0v", s))
// match at the very end
assert.Equal(t,
"VWwXetig mty8fORN UNia4NFm SQsfyFHk BLDdgVnc AcvKP2fs q8KxPH1A IaCzFj96 J0S2fqca jp3ElV9f ULIZ1aMX … tMpfMMlU p6jGYq3Z rTIBTHVM zGFwFwQi j4O1AY21 <b>BJnaiScY</b>",
snippets("BJnaiScY", s))
// match near the end
assert.Equal(t,
"VWwXetig mty8fORN UNia4NFm SQsfyFHk BLDdgVnc AcvKP2fs q8KxPH1A IaCzFj96 J0S2fqca jp3ElV9f ULIZ1aMX … Auk5RBWs tMpfMMlU p6jGYq3Z rTIBTHVM zGFwFwQi <b>j4O1AY21</b> BJnaiScY",
snippets("j4O1AY21", s))
}

View File

@@ -12,7 +12,7 @@ form, textarea { width: 100%; }
</head>
<body>
<h1>Upload File</h1>
<form action="/save" method="POST" enctype="multipart/form-data">
<form action="/drop/{{.}}" method="POST" enctype="multipart/form-data">
<input type="text" name="name" placeholder="image.jpg" autofocus required>
<p><input type="file" name="file" required>
<p><input type="submit" value="Save">

56
upload_drop.go Normal file
View File

@@ -0,0 +1,56 @@
package main
import (
"io"
"net/http"
"os"
"path"
)
// uploadHandler uses the "upload.html" template to enable uploads.
// The file is saved using the saveUploadHandler.
func uploadHandler(w http.ResponseWriter, r *http.Request, dir string) {
renderTemplate(w, "upload", dir)
}
// 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.
func dropHandler(w http.ResponseWriter, r *http.Request, dir string) {
d := path.Dir(dir)
// ensure the directory exists
fi, err := os.Stat(d)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if !fi.IsDir() {
http.Error(w, "file exists", http.StatusInternalServerError)
return
}
filename := r.FormValue("name")
file, _, err := r.FormFile("file")
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
defer file.Close()
// backup an existing file with the same name
_, err = os.Stat(filename)
if err != nil {
os.Rename(filename, filename + "~")
}
// create the new file
dst, err := os.Create(d + "/" + filename)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
defer dst.Close()
if _, err := io.Copy(dst, file); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/view/"+filename, http.StatusFound)
}

36
upload_drop_test.go Normal file
View File

@@ -0,0 +1,36 @@
package main
import (
"bytes"
"github.com/stretchr/testify/assert"
"mime/multipart"
"os"
"regexp"
"testing"
)
// wipes testdata
func TestUpload(t *testing.T) {
_ = os.RemoveAll("testdata")
// for uploads, the directory is not created automatically
os.MkdirAll("testdata", 0755)
assert.HTTPStatusCode(t, makeHandler(uploadHandler, false), "GET", "/upload/", nil, 200)
form := new(bytes.Buffer)
writer := multipart.NewWriter(form)
field, err := writer.CreateFormField("name")
assert.NoError(t, err)
_, err = field.Write([]byte("testdata/ok.txt"))
assert.NoError(t, err)
file, err := writer.CreateFormFile("file", "example.txt");
assert.NoError(t, err)
file.Write([]byte("Hello!"))
err = writer.Close()
assert.NoError(t, err)
HTTPUploadAndRedirectTo(t, makeHandler(dropHandler, false), "/drop/",
writer.FormDataContentType(), form, "/view/testdata/ok.txt")
assert.Regexp(t, regexp.MustCompile("Hello!"),
assert.HTTPBody(makeHandler(viewHandler, true), "GET", "/view/testdata/ok.txt", nil))
t.Cleanup(func() {
_ = os.RemoveAll("testdata")
})
}

32
view.go Normal file
View File

@@ -0,0 +1,32 @@
package main
import(
"net/http"
"os"
)
// rootHandler just redirects to /view/index.
func rootHandler(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/view/index", http.StatusFound)
}
// viewHandler serves existing files (including markdown files with
// the .md extension). If the requested file does not exist, a page
// with the same name is loaded. This means adding the .md extension
// and using the "view.html" template to render the HTML. Both
// attempts fail, the browser is redirected to an edit page.
func viewHandler(w http.ResponseWriter, r *http.Request, name string) {
body, err := os.ReadFile(name)
if err == nil {
w.Write(body)
return
}
p, err := loadPage(name)
if err == nil {
p.handleTitle(true)
p.renderHtml()
renderTemplate(w, "view", p)
return
}
http.Redirect(w, r, "/edit/"+name, http.StatusFound)
}

View File

@@ -20,6 +20,7 @@ img { max-width: 100%; }
<a href="/view/index">Home</a>
<a href="/edit/{{.Name}}">Edit</a>
<a href="/add/{{.Name}}">Add</a>
<a href="/upload/{{.Dir}}">Upload</a>
<form role="search" action="/search" method="GET">
<input type="text" spellcheck="false" name="q" required>
<button>Search</button>

54
view_test.go Normal file
View File

@@ -0,0 +1,54 @@
package main
import (
"github.com/stretchr/testify/assert"
"os"
"regexp"
"testing"
)
func TestRootHandler(t *testing.T) {
HTTPRedirectTo(t, rootHandler, "GET", "/", nil, "/view/index")
}
// relies on index.md in the current directory!
func TestViewHandler(t *testing.T) {
assert.Regexp(t, regexp.MustCompile("Welcome to Oddµ"),
assert.HTTPBody(makeHandler(viewHandler, true), "GET", "/view/index", nil))
}
// wipes testdata
func TestPageTitleWithAmp(t *testing.T) {
_ = os.RemoveAll("testdata")
p := &Page{Name: "testdata/Rock & Roll", Body: []byte("Dancing")}
p.save()
assert.Regexp(t, regexp.MustCompile("Rock &amp; Roll"),
assert.HTTPBody(makeHandler(viewHandler, true), "GET", "/view/testdata/Rock%20%26%20Roll", nil))
p = &Page{Name: "testdata/Rock & Roll", Body: []byte("# Sex & Drugs & Rock'n'Roll\nOh no!")}
p.save()
assert.Regexp(t, regexp.MustCompile("Sex &amp; Drugs"),
assert.HTTPBody(makeHandler(viewHandler, true), "GET", "/view/testdata/Rock%20%26%20Roll", nil))
t.Cleanup(func() {
_ = os.RemoveAll("testdata")
})
}
func TestPageTitleWithQuestionMark(t *testing.T) {
_ = os.RemoveAll("testdata")
p := &Page{Name: "testdata/How about no?", Body: []byte("No means no")}
p.save()
body := assert.HTTPBody(makeHandler(viewHandler, true), "GET", "/view/testdata/How%20about%20no%3F", nil)
assert.Contains(t, body, "No means no")
assert.Contains(t, body, "<a href=\"/edit/testdata/How%20about%20no%3F\">Edit</a>")
t.Cleanup(func() {
_ = os.RemoveAll("testdata")
})
}

171
wiki.go
View File

@@ -3,10 +3,8 @@ package main
import (
"fmt"
"html/template"
"io"
"net/http"
"os"
"path/filepath"
"regexp"
)
@@ -20,7 +18,7 @@ var templates = template.Must(
// results in the editHandler being called with title "foo". The
// regular expression doesn't define the handlers (this happens in the
// main function).
var validPath = regexp.MustCompile("^/([^/]+)/(.+)$")
var validPath = regexp.MustCompile("^/([^/]+)/(.*)$")
// titleRegexp is a regular expression matching a level 1 header line
// in a Markdown document. The first group matches the actual text and
@@ -37,145 +35,16 @@ func renderTemplate(w http.ResponseWriter, tmpl string, data any) {
}
}
// rootHandler just redirects to /view/index.
func rootHandler(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/view/index", http.StatusFound)
}
// viewHandler serves existing files (including markdown files with
// the .md extension). If the requested file does not exist, a page
// with the same name is loaded. This means adding the .md extension
// and using the "view.html" template to render the HTML. Both
// attempts fail, the browser is redirected to an edit page.
func viewHandler(w http.ResponseWriter, r *http.Request, name string) {
body, err := os.ReadFile(name)
if err == nil {
w.Write(body)
return
}
p, err := loadPage(name)
if err == nil {
p.handleTitle(true)
p.renderHtml()
renderTemplate(w, "view", p)
return
}
http.Redirect(w, r, "/edit/"+name, http.StatusFound)
}
// 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 {
p = &Page{Title: name, Name: name}
} else {
p.handleTitle(false)
}
renderTemplate(w, "edit", p)
}
// 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 {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/view/"+name, http.StatusFound)
}
// addHandler uses the "add.html" template to present an empty edit
// page. What you type there is appended to the page using the
// appendHandler.
func addHandler(w http.ResponseWriter, r *http.Request, name string) {
p, err := loadPage(name)
if err != nil {
p = &Page{Title: name, Name: name}
} else {
p.handleTitle(false)
}
renderTemplate(w, "add", p)
}
// 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)
if err != nil {
p = &Page{Title: name, Name: name, Body: []byte(body)}
} else {
p.handleTitle(false)
p.Body = append(p.Body, []byte(body)...)
}
err = p.save()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/view/"+name, http.StatusFound)
}
// uploadHandler uses the "upload.html" template to enable uploads.
// The file is saved using the saveUploadHandler.
func uploadHandler(w http.ResponseWriter, r *http.Request) {
renderTemplate(w, "upload", nil)
}
// saveUploadHandler 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.
func saveUploadHandler(w http.ResponseWriter, r *http.Request) {
filename := r.FormValue("name")
file, _, err := r.FormFile("file")
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
defer file.Close()
// backup an existing file with the same name
_, err = os.Stat(filename)
if err != nil {
os.Rename(filename, filename + "~")
}
// create the directory, if necessary
d := filepath.Dir(filename)
if d != "." {
err := os.MkdirAll(d, 0755)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
}
// create the new file
dst, err := os.Create(filename)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
defer dst.Close()
if _, err := io.Copy(dst, file); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/view/"+filename, http.StatusFound)
}
// 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.
func makeHandler(fn func(http.ResponseWriter, *http.Request, string)) http.HandlerFunc {
// expression. The boolean argument indicates whether the following
// path is required. When false, a URL /upload/ is OK.
func makeHandler(fn func(http.ResponseWriter, *http.Request, string), required bool) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
m := validPath.FindStringSubmatch(r.URL.Path)
if m != nil {
if m != nil && (!required || len(m[2]) > 0) {
fn(w, r, m[2])
} else {
http.NotFound(w, r)
@@ -183,16 +52,6 @@ func makeHandler(fn func(http.ResponseWriter, *http.Request, string)) http.Handl
}
}
// 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.
func searchHandler(w http.ResponseWriter, r *http.Request) {
q := r.FormValue("q")
items := search(q)
s := &Search{Query: q, Items: items, Results: len(items) > 0}
renderTemplate(w, "search", s)
}
// getPort returns the environment variable ODDMU_PORT or the default
// port, "8080".
func getPort() string {
@@ -203,12 +62,12 @@ func getPort() string {
return port
}
// scheduleLoadIndex calls loadIndex and prints some messages before
// and after. For testing, call loadIndex directly and skip the
// scheduleLoadIndex calls index.load and prints some messages before
// and after. For testing, call index.load directly and skip the
// messages.
func scheduleLoadIndex() {
fmt.Print("Indexing pages\n")
n, err := loadIndex()
n, err := index.load()
if err == nil {
fmt.Printf("Indexed %d pages\n", n)
} else {
@@ -227,13 +86,13 @@ func scheduleLoadLanguages() {
func serve() {
http.HandleFunc("/", rootHandler)
http.HandleFunc("/view/", makeHandler(viewHandler))
http.HandleFunc("/edit/", makeHandler(editHandler))
http.HandleFunc("/save/", makeHandler(saveHandler))
http.HandleFunc("/add/", makeHandler(addHandler))
http.HandleFunc("/append/", makeHandler(appendHandler))
http.HandleFunc("/upload", uploadHandler)
http.HandleFunc("/save", saveUploadHandler)
http.HandleFunc("/view/", makeHandler(viewHandler, true))
http.HandleFunc("/edit/", makeHandler(editHandler, true))
http.HandleFunc("/save/", makeHandler(saveHandler, true))
http.HandleFunc("/add/", makeHandler(addHandler, true))
http.HandleFunc("/append/", makeHandler(appendHandler, true))
http.HandleFunc("/upload/", makeHandler(uploadHandler, false))
http.HandleFunc("/drop/", makeHandler(dropHandler, false))
http.HandleFunc("/search", searchHandler)
go scheduleLoadIndex()
go scheduleLoadLanguages()

View File

@@ -3,12 +3,9 @@ package main
import (
"bytes"
"github.com/stretchr/testify/assert"
"mime/multipart"
"net/http"
"net/http/httptest"
"net/url"
"os"
"regexp"
"strings"
"testing"
)
@@ -68,117 +65,3 @@ func HTTPUploadAndRedirectTo(t *testing.T, handler http.HandlerFunc, url, conten
"Expected HTTP redirect location %s for %q but received %v", destination, url, headers)
return isRedirectCode
}
func TestRootHandler(t *testing.T) {
HTTPRedirectTo(t, rootHandler, "GET", "/", nil, "/view/index")
}
// relies on index.md in the current directory!
func TestViewHandler(t *testing.T) {
assert.Regexp(t, regexp.MustCompile("Welcome to Oddµ"),
assert.HTTPBody(makeHandler(viewHandler), "GET", "/view/index", nil))
}
// wipes testdata
func TestEditSave(t *testing.T) {
_ = os.RemoveAll("testdata")
data := url.Values{}
data.Set("body", "Hallo!")
HTTPRedirectTo(t, makeHandler(viewHandler), "GET", "/view/testdata/alex", nil, "/edit/testdata/alex")
assert.HTTPStatusCode(t, makeHandler(editHandler), "GET", "/edit/testdata/alex", nil, 200)
HTTPRedirectTo(t, makeHandler(saveHandler), "POST", "/save/testdata/alex", data, "/view/testdata/alex")
assert.Regexp(t, regexp.MustCompile("Hallo!"),
assert.HTTPBody(makeHandler(viewHandler), "GET", "/view/testdata/alex", nil))
t.Cleanup(func() {
_ = os.RemoveAll("testdata")
})
}
// wipes testdata
func TestAddAppend(t *testing.T) {
_ = os.RemoveAll("testdata")
p := &Page{Name: "testdata/fire", Body: []byte(`# Fire
Orange sky above
Reflects a distant fire
It's not `)}
p.save()
data := url.Values{}
data.Set("body", "barbecue")
assert.Regexp(t, regexp.MustCompile("a distant fire"),
assert.HTTPBody(makeHandler(viewHandler), "GET", "/view/testdata/fire", nil))
assert.NotRegexp(t, regexp.MustCompile("a distant fire"),
assert.HTTPBody(makeHandler(addHandler), "GET", "/add/testdata/fire", nil))
HTTPRedirectTo(t, makeHandler(appendHandler), "POST", "/append/testdata/fire", data, "/view/testdata/fire")
assert.Regexp(t, regexp.MustCompile("Its not barbecue"),
assert.HTTPBody(makeHandler(viewHandler), "GET", "/view/testdata/fire", nil))
t.Cleanup(func() {
_ = os.RemoveAll("testdata")
})
}
// wipes testdata
func TestUpload(t *testing.T) {
_ = os.RemoveAll("testdata")
assert.HTTPStatusCode(t, uploadHandler, "GET", "/upload", nil, 200)
form := new(bytes.Buffer)
writer := multipart.NewWriter(form)
field, err := writer.CreateFormField("name")
assert.NoError(t, err)
_, err = field.Write([]byte("testdata/ok.txt"))
assert.NoError(t, err)
file, err := writer.CreateFormFile("file", "example.txt");
assert.NoError(t, err)
file.Write([]byte("Hello!"))
err = writer.Close()
assert.NoError(t, err)
t.Log(writer.FormDataContentType())
HTTPUploadAndRedirectTo(t, saveUploadHandler, "/upload", writer.FormDataContentType(), form, "/view/testdata/ok.txt")
assert.Regexp(t, regexp.MustCompile("Hello!"),
assert.HTTPBody(makeHandler(viewHandler), "GET", "/view/testdata/ok.txt", nil))
t.Cleanup(func() {
_ = os.RemoveAll("testdata")
})
}
// wipes testdata
func TestPageTitleWithAmp(t *testing.T) {
_ = os.RemoveAll("testdata")
p := &Page{Name: "testdata/Rock & Roll", Body: []byte("Dancing")}
p.save()
assert.Regexp(t, regexp.MustCompile("Rock &amp; Roll"),
assert.HTTPBody(makeHandler(viewHandler), "GET", "/view/testdata/Rock%20%26%20Roll", nil))
p = &Page{Name: "testdata/Rock & Roll", Body: []byte("# Sex & Drugs & Rock'n'Roll\nOh no!")}
p.save()
assert.Regexp(t, regexp.MustCompile("Sex &amp; Drugs"),
assert.HTTPBody(makeHandler(viewHandler), "GET", "/view/testdata/Rock%20%26%20Roll", nil))
t.Cleanup(func() {
_ = os.RemoveAll("testdata")
})
}
func TestPageTitleWithQuestionMark(t *testing.T) {
_ = os.RemoveAll("testdata")
p := &Page{Name: "testdata/How about no?", Body: []byte("No means no")}
p.save()
body := assert.HTTPBody(makeHandler(viewHandler), "GET", "/view/testdata/How%20about%20no%3F", nil)
assert.Contains(t, body, "No means no")
assert.Contains(t, body, "<a href=\"/edit/testdata/How%20about%20no%3F\">Edit</a>")
t.Cleanup(func() {
_ = os.RemoveAll("testdata")
})
}