forked from mirror/oddmu
Compare commits
22 Commits
v0.6
...
no-scoring
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
153a179d92 | ||
|
|
d9797aac75 | ||
|
|
005500457e | ||
|
|
2635d5f852 | ||
|
|
a79f4558b6 | ||
|
|
d1c2b8e27c | ||
|
|
dd939e2c86 | ||
|
|
475c7071ba | ||
|
|
16b475ea7f | ||
|
|
0a7eaa455a | ||
|
|
4cad4a988a | ||
|
|
cc58980ec0 | ||
|
|
068bc21eea | ||
|
|
f60cd09267 | ||
|
|
92a52d2c97 | ||
|
|
7f0b371570 | ||
|
|
a44d903775 | ||
|
|
1ca8e6f3aa | ||
|
|
93197f94bf | ||
|
|
a7861edbad | ||
|
|
d4090ab146 | ||
|
|
4da0ba8d94 |
93
README.md
93
README.md
@@ -31,10 +31,25 @@ Oddmu. 🙃
|
||||
|
||||
This wiki uses a [Markdown
|
||||
library](https://github.com/gomarkdown/markdown) to generate the web
|
||||
pages from Markdown. There is no additional wiki markup. Most
|
||||
importantly, double square brackets are not a link. If you're used to
|
||||
that, it'll be strange as you need to repeat the name: `[like
|
||||
this](like this)`.
|
||||
pages from Markdown. There are two extensions Oddmu adds to the
|
||||
library: local links and hashtags.
|
||||
|
||||
Local links use double square brackets `[[like this]]`. If you need to
|
||||
change the link text, you need to use regular Markdown. Don't forget
|
||||
to [percent-encode](https://en.wikipedia.org/wiki/Percent-encoding)
|
||||
the link target. Example: `[here](like%20this)`.
|
||||
|
||||
Hashtags link to searches for the hashtag. Hashtags are separate from
|
||||
titles because there is no space after the hash. Use the underscore to
|
||||
use hashtags consisting of multiple words.
|
||||
|
||||
```
|
||||
# Title
|
||||
|
||||
Text
|
||||
|
||||
#Tag #Another_Tag
|
||||
```
|
||||
|
||||
The Markdown processor comes with a few extensions, some of which are
|
||||
enable by default:
|
||||
@@ -71,25 +86,13 @@ Internet
|
||||
: Vector of transmission for pictures of cats
|
||||
```
|
||||
|
||||
There is another extension made: hashtags link to searches for the
|
||||
hashtag. Hashtags are separate from titles because there is no space
|
||||
after the hash. Use the underscore to use hashtags consisting of
|
||||
multiple words.
|
||||
|
||||
```
|
||||
# Title
|
||||
|
||||
Text
|
||||
|
||||
#Tag #Another_Tag
|
||||
```
|
||||
|
||||
## Templates
|
||||
|
||||
The template files are the HTML files in the working directory:
|
||||
`add.html`, `edit.html`, `search.html` and `view.html`. Feel free to
|
||||
change the templates and restart the server. The first change you
|
||||
should make is to replace the email address in `view.html`. 😄
|
||||
`add.html`, `edit.html`, `search.html`, `upload.html` and `view.html`.
|
||||
Feel free to change the templates and restart the server. The first
|
||||
change you should make is to replace the email address in `view.html`.
|
||||
😄
|
||||
|
||||
See [Structuring the web
|
||||
with HTML](https://developer.mozilla.org/en-US/docs/Learn/HTML) to
|
||||
@@ -116,7 +119,13 @@ is a byte array and that's why we need to call `printf`).
|
||||
|
||||
For the `search.html` template only:
|
||||
|
||||
`{{.Results}}` indicates if there were any search results.
|
||||
`{{.Previous}}`, `{{.Page}}`, `{{.Next}}` and `{{.Last}}` are the
|
||||
previous, current, next and last page number in the results since
|
||||
doing arithmetics in templates is hard. The first page number is 1.
|
||||
|
||||
`{{.More}}` indicates if there are any more search results.
|
||||
|
||||
`{{.Results}}` indicates if there were any search results at all.
|
||||
|
||||
`{{.Items}}` is an array of pages, each containing a search result. A
|
||||
search result is a page (with the properties seen above). Thus, to
|
||||
@@ -128,6 +137,8 @@ summary, as HTML.
|
||||
|
||||
`{{.Score}}` is a numerical score for search results.
|
||||
|
||||
The `upload.html` template cannot refer to anything.
|
||||
|
||||
When calling the `save` action, the page name is take from the URL and
|
||||
the page content is taken from the `body` form parameter. To
|
||||
illustrate, here's how to edit a page using `curl`:
|
||||
@@ -193,7 +204,7 @@ adduser --system --home /home/oddmu oddmu
|
||||
```
|
||||
|
||||
Copy all the files into `/home/oddmu` to your server: `oddmu`,
|
||||
`oddmu.service`, `view.html` and `edit.html`.
|
||||
`oddmu.service`, and all the template files ending in `.html`.
|
||||
|
||||
Edit the `oddmu.service` file. These are the three lines you most
|
||||
likely have to take care of:
|
||||
@@ -250,7 +261,7 @@ MDCertificateAgreement accepted
|
||||
ServerAdmin alex@alexschroeder.ch
|
||||
ServerName transjovian.org
|
||||
SSLEngine on
|
||||
ProxyPassMatch ^/(search|(view|edit|save|add|append)/(.*))?$ http://localhost:8080/$1
|
||||
ProxyPassMatch ^/(search|(view|edit|save|add|append|upload|drop)/(.*))?$ http://localhost:8080/$1
|
||||
</VirtualHost>
|
||||
```
|
||||
|
||||
@@ -308,11 +319,11 @@ htpasswd -D .htpasswd berta
|
||||
```
|
||||
|
||||
Modify your site configuration and protect the `/edit/`, `/save/`,
|
||||
`/add/` and `/append/` URLs with a password by adding the following to
|
||||
your `<VirtualHost *:443>` section:
|
||||
`/add/`, `/append/`, `/upload/` and `/drop/` URLs with a password by
|
||||
adding the following to your `<VirtualHost *:443>` section:
|
||||
|
||||
```apache
|
||||
<LocationMatch "^/(edit|save|add|append)/">
|
||||
<LocationMatch "^/(edit|save|add|append|upload|drop)/">
|
||||
AuthType Basic
|
||||
AuthName "Password Required"
|
||||
AuthUserFile /home/oddmu/.htpasswd
|
||||
@@ -337,9 +348,9 @@ 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/` or `/search/`. For
|
||||
example, create a file called `robots.txt` containing the following,
|
||||
tellin all robots that they're not welcome.
|
||||
`/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.
|
||||
|
||||
```text
|
||||
User-agent: *
|
||||
@@ -363,7 +374,7 @@ above.
|
||||
This requires a valid login by the user "alex" or "berta":
|
||||
|
||||
```apache
|
||||
<LocationMatch "^/(edit|save)/intetebi/">
|
||||
<LocationMatch "^/(edit|save|add|append|upload|drop)/intetebi/">
|
||||
Require user alex berta
|
||||
</LocationMatch>
|
||||
```
|
||||
@@ -428,6 +439,22 @@ and "rail", a search for "mail" returns a match because the trigrams
|
||||
"mai" and "ail" are found. In this situation, the result has a score
|
||||
of 0.
|
||||
|
||||
The sorting of all the pages, however, does not depend on scoring!
|
||||
Computing the score is expensive because the page must be loaded from
|
||||
disk. Therefore, results are sorted by title:
|
||||
|
||||
- If the page title contains the query string, it gets sorted first.
|
||||
- If the page title begins with a number, it is sorted descending.
|
||||
- All other pages follow, sorted ascending.
|
||||
|
||||
The effect is that first, the pages with matches in the page title are
|
||||
shown, and then all the others. Within these two groups, the most
|
||||
recent blog posts are shown first, if and only if the page title
|
||||
begins with an ISO date like 2023-09-16.
|
||||
|
||||
The score and highlighting of snippets is used to help visitors decide
|
||||
which links to click.
|
||||
|
||||
## Limitations
|
||||
|
||||
Page titles are filenames with `.md` appended. If your filesystem
|
||||
@@ -439,6 +466,12 @@ memory.
|
||||
|
||||
Files may not end with a tilde (`~`) – these are backup files.
|
||||
|
||||
You cannot edit uploaded files. If you upload a file called
|
||||
`hello.txt` and attempt to edit it by using `/edit/hello.txt` you will
|
||||
create a page with the name `hello.txt.md` instead.
|
||||
|
||||
You cannot delete uploaded files via the web.
|
||||
|
||||
## Bugs
|
||||
|
||||
If you spot any, [contact](https://alexschroeder.ch/wiki/Contact) me.
|
||||
|
||||
3
TODO.md
3
TODO.md
@@ -1,4 +1,5 @@
|
||||
Upload files.
|
||||
Upload files should use path info so that we can use Apache to
|
||||
restrict access to directories.
|
||||
|
||||
Automatically scale or process files.
|
||||
|
||||
|
||||
6
add.html
6
add.html
@@ -1,5 +1,5 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="format-detection" content="telephone=no">
|
||||
@@ -13,9 +13,9 @@ form, textarea { width: 100%; }
|
||||
<body>
|
||||
<h1>Adding to {{.Title}}</h1>
|
||||
<form action="/append/{{.Name}}" method="POST">
|
||||
<div><textarea name="body" rows="20" cols="80" placeholder="Text" autofocus></textarea></div>
|
||||
<textarea name="body" rows="20" cols="80" placeholder="Text" lang="" 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
37
add_append.go
Normal 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
36
add_append_test.go
Normal 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("It’s not barbecue"),
|
||||
assert.HTTPBody(makeHandler(viewHandler, true), "GET", "/view/testdata/fire", nil))
|
||||
|
||||
t.Cleanup(func() {
|
||||
_ = os.RemoveAll("testdata")
|
||||
})
|
||||
}
|
||||
39
commands.go
Normal file
39
commands.go
Normal file
@@ -0,0 +1,39 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
)
|
||||
|
||||
func commands() {
|
||||
if len(os.Args) == 3 && os.Args[1] == "html" {
|
||||
p, err := loadPage(os.Args[2])
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
} else {
|
||||
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, more, _ := search(q, 1)
|
||||
fmt.Printf("Search %s: %d results\n", q, len(items))
|
||||
for _, p := range items {
|
||||
fmt.Printf("* %s (%d)\n", p.Title, p.Score)
|
||||
}
|
||||
if more {
|
||||
fmt.Printf("There are more results\n")
|
||||
}
|
||||
}
|
||||
} 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("html PAGENAME\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")
|
||||
}
|
||||
}
|
||||
@@ -7,8 +7,9 @@ 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)
|
||||
pages, _, _ := search(q, 1)
|
||||
assert.Zero(t, len(pages))
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="format-detection" content="telephone=no">
|
||||
@@ -15,7 +15,7 @@ form, textarea { width: 100%; }
|
||||
<form action="/save/{{.Name}}" method="POST">
|
||||
<textarea name="body" rows="20" cols="80" placeholder="# Title
|
||||
|
||||
Text" autofocus>{{printf "%s" .Body}}</textarea>
|
||||
Text" lang="" autofocus>{{printf "%s" .Body}}</textarea>
|
||||
<p><input type="submit" value="Save">
|
||||
<a href="/view/{{.Name}}"><button type="button">Cancel</button></a></p>
|
||||
</form>
|
||||
|
||||
32
edit_save.go
Normal file
32
edit_save.go
Normal 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
27
edit_save_test.go
Normal 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")
|
||||
})
|
||||
}
|
||||
2
go.mod
2
go.mod
@@ -11,12 +11,14 @@ require (
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/anthonynsimon/bild v0.13.0 // indirect
|
||||
github.com/aymerick/douceur v0.2.0 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/gorilla/css v1.0.0 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/shopspring/decimal v1.3.1 // indirect
|
||||
golang.org/x/exp v0.0.0-20221106115401-f9659909a136 // indirect
|
||||
golang.org/x/image v0.0.0-20190703141733-d6a02ce849c9 // indirect
|
||||
golang.org/x/net v0.12.0 // indirect
|
||||
google.golang.org/protobuf v1.31.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
|
||||
31
go.sum
31
go.sum
@@ -1,9 +1,18 @@
|
||||
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/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
|
||||
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
|
||||
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.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dgryski/go-trigram v0.0.0-20160407183937-79ec494e1ad0 h1:b+7JSiBM+hnLQjP/lXztks5hnLt1PS46hktG9VOJgzo=
|
||||
github.com/dgryski/go-trigram v0.0.0-20160407183937-79ec494e1ad0/go.mod h1:qzKC/DpcxK67zaSHdCmIv3L9WJViHVinYXN2S7l3RM8=
|
||||
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-20230716120725-531d2d74bc12 h1:uK3X/2mt4tbSGoHvbLBHUny7CKiuwUip3MArtukol4E=
|
||||
github.com/gomarkdown/markdown v0.0.0-20230716120725-531d2d74bc12/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA=
|
||||
@@ -14,25 +23,47 @@ 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/gorilla/css v1.0.0 h1:BQqNyPTi50JCFMTw/b67hByjMVXZRwGha6wxVGkeihY=
|
||||
github.com/gorilla/css v1.0.0/go.mod h1:Dn721qIggHpt4+EFCcTLTU/vk5ySda2ReITrtgBl60c=
|
||||
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
|
||||
github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=
|
||||
github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ=
|
||||
github.com/microcosm-cc/bluemonday v1.0.25 h1:4NEwSfiJ+Wva0VxN5B8OwMicaJvD8r9tlJWm9rtloEg=
|
||||
github.com/microcosm-cc/bluemonday v1.0.25/go.mod h1:ZIOjCQp1OrzBBPIJmfX4qDYFuhU02nx4bn030ixfHLE=
|
||||
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/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/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g=
|
||||
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/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
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-20221106115401-f9659909a136 h1:Fq7F/w7MAa1KJ5bt2aJ62ihqp9HDcRuyILskkpIAurw=
|
||||
golang.org/x/exp v0.0.0-20221106115401-f9659909a136/go.mod h1:CxIveKay+FTh1D0yPZemJVgC/95VzuuOLq5Qi4xnoYc=
|
||||
golang.org/x/image v0.0.0-20190703141733-d6a02ce849c9 h1:uc17S921SPw5F2gJo7slQ3aqvr2RwpL7eb3+DZncu3s=
|
||||
golang.org/x/image v0.0.0-20190703141733-d6a02ce849c9/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||
golang.org/x/net v0.12.0 h1:cfawfvKITfUsFCeJIHJrbSxpeu/E81khclypR0GVT50=
|
||||
golang.org/x/net v0.12.0/go.mod h1:zEVYFnQC7m/vmpQFELhcD1EWkZlX69l4oqgmer6hfKA=
|
||||
golang.org/x/sys v0.0.0-20181205085412-a5c9d58dba9a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
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 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
||||
123
index.go
Normal file
123
index.go
Normal file
@@ -0,0 +1,123 @@
|
||||
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
|
||||
|
||||
// names is a map, mapping page names to titles.
|
||||
titles map[string]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
|
||||
idx.titles = 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
|
||||
}
|
||||
p.handleTitle(false)
|
||||
id := idx.index.Add(strings.ToLower(string(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.index = make(trigram.Index)
|
||||
idx.documents = make(map[trigram.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
|
||||
}
|
||||
|
||||
// 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)
|
||||
o.handleTitle(false)
|
||||
delete(index.titles, o.Title)
|
||||
}
|
||||
index.index.Insert(strings.ToLower(string(p.Body)), id)
|
||||
p.handleTitle(false)
|
||||
index.titles[p.Name] = p.Title
|
||||
}
|
||||
}
|
||||
|
||||
// searchDocuments searches the index for a string. This requires the
|
||||
// index to be locked.
|
||||
func searchDocuments(q string) []string {
|
||||
words := strings.Fields(strings.ToLower(q))
|
||||
var trigrams []trigram.T
|
||||
for _, word := range words {
|
||||
trigrams = trigram.Extract(word, trigrams)
|
||||
}
|
||||
ids := index.index.QueryTrigrams(trigrams)
|
||||
names := make([]string, len(ids))
|
||||
for i, id := range ids {
|
||||
names[i] = index.documents[id]
|
||||
}
|
||||
return names
|
||||
}
|
||||
97
index_test.go
Normal file
97
index_test.go
Normal 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, 1)
|
||||
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, "Score %d for %s", p.Score, p.Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchHashtag(t *testing.T) {
|
||||
index.load()
|
||||
q := "#Another_Tag"
|
||||
pages, _, _ := search(q, 1)
|
||||
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", 1)
|
||||
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", 1)
|
||||
found = false
|
||||
for _, p := range pages {
|
||||
if p.Name == name {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
assert.True(t, found)
|
||||
|
||||
// Find some words
|
||||
pages, _, _ = search("this test", 1)
|
||||
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", 1)
|
||||
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", 1)
|
||||
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,7 +17,6 @@ Environment="ODDMU_PORT=8080"
|
||||
ReadWritePaths=/home/oddmu
|
||||
ProtectHostname=yes
|
||||
RestrictSUIDSGID=yes
|
||||
UMask=0077
|
||||
RemoveIPC=yes
|
||||
MemoryDenyWriteExecute=yes
|
||||
|
||||
|
||||
51
page.go
51
page.go
@@ -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)
|
||||
}
|
||||
|
||||
@@ -101,6 +104,30 @@ func (p *Page) handleTitle(replace bool) {
|
||||
}
|
||||
}
|
||||
|
||||
// wikiLink returns an inline parser function. This indirection is
|
||||
// required because we want to call the previous definition in case
|
||||
// this is not a wikiLink.
|
||||
func wikiLink(p *parser.Parser, fn func(p *parser.Parser, data []byte, offset int) (int, ast.Node)) func(p *parser.Parser, data []byte, offset int) (int, ast.Node) {
|
||||
return func (p *parser.Parser, original []byte, offset int) (int, ast.Node) {
|
||||
data := original[offset:]
|
||||
n := len(data)
|
||||
// minimum: [[X]]
|
||||
if n < 5 || data[1] != '[' {
|
||||
return fn(p, original, offset)
|
||||
}
|
||||
i := 2
|
||||
for i+1 < n && data[i] != ']' && data[i+1] != ']' {
|
||||
i++
|
||||
}
|
||||
text := data[2:i+1]
|
||||
link := &ast.Link{
|
||||
Destination: []byte(url.PathEscape(string(text))),
|
||||
}
|
||||
ast.AppendChild(link, &ast.Text{Leaf: ast.Leaf{Literal: text}})
|
||||
return i+3, link
|
||||
}
|
||||
}
|
||||
|
||||
func hashtag(p *parser.Parser, data []byte, offset int) (int, ast.Node) {
|
||||
data = data[offset:]
|
||||
i := 0
|
||||
@@ -123,6 +150,8 @@ func hashtag(p *parser.Parser, data []byte, offset int) (int, ast.Node) {
|
||||
// renderHtml renders the Page.Body to HTML and sets Page.Html.
|
||||
func (p *Page) renderHtml() {
|
||||
parser := parser.New()
|
||||
prev := parser.RegisterInline('[', nil)
|
||||
parser.RegisterInline('[', wikiLink(parser, prev))
|
||||
parser.RegisterInline('#', hashtag)
|
||||
maybeUnsafeHTML := markdown.ToHTML(p.Body, parser, nil)
|
||||
p.Name = nameEscape(p.Name)
|
||||
@@ -157,11 +186,23 @@ func (p *Page) plainText() string {
|
||||
return string(text)
|
||||
}
|
||||
|
||||
// summarize for query string q sets Page.Html to an extract.
|
||||
func (p *Page) summarize(q string) {
|
||||
// score sets Page.Title and computes Page.Score.
|
||||
func (p *Page) score(q string) {
|
||||
p.handleTitle(true)
|
||||
p.Score = score(q, string(p.Body)) + score(q, p.Title)
|
||||
}
|
||||
|
||||
// summarize sets Page.Html to an extract and sets Page.Language.
|
||||
func (p *Page) summarize(q string) {
|
||||
t := p.plainText()
|
||||
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
|
||||
}
|
||||
|
||||
24
page_test.go
24
page_test.go
@@ -62,14 +62,32 @@ I am cold, alone</p>
|
||||
assert.Equal(t, r, string(p.Html))
|
||||
}
|
||||
|
||||
func TestPageHtmlWikiLink(t *testing.T) {
|
||||
p := &Page{Body: []byte(`# Photos and Books
|
||||
Blue and green and black
|
||||
Sky and grass and [ragged cliffs](cliffs)
|
||||
Our [[time together]]
|
||||
`)}
|
||||
p.renderHtml()
|
||||
r := `<h1>Photos and Books</h1>
|
||||
|
||||
<p>Blue and green and black
|
||||
Sky and grass and <a href="cliffs" rel="nofollow">ragged cliffs</a>
|
||||
Our <a href="time%20together" rel="nofollow">time together</a></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 +97,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")
|
||||
})
|
||||
|
||||
@@ -94,7 +94,7 @@ func TestScorePageAndMarkup(t *testing.T) {
|
||||
s := `The Transjovian Council accepts new members. If you think we'd be a good fit, apply for an account. Contact [Alex Schroeder](https://alexschroeder.ch/wiki/Contact). Mail is best. Encrypted mail is best. [Delta Chat](https://delta.chat/de/) is a messenger app that uses encrypted mail. It's the bestest best.`
|
||||
p := &Page{Title: "Test", Name: "Test", Body: []byte(s)}
|
||||
q := "wiki"
|
||||
p.summarize(q)
|
||||
p.score(q)
|
||||
// "wiki" is not visible in the plain text but the score is no affected:
|
||||
// - wiki, all, whole, beginning, end (5)
|
||||
if p.Score != 5 {
|
||||
|
||||
215
search.go
215
search.go
@@ -2,12 +2,10 @@ package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
trigram "github.com/dgryski/go-trigram"
|
||||
"io/fs"
|
||||
"path/filepath"
|
||||
"net/http"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
)
|
||||
@@ -18,157 +16,110 @@ import (
|
||||
// a search result, Body and Html are simple extracts.
|
||||
type Search struct {
|
||||
Query string
|
||||
Items []Page
|
||||
Items []*Page
|
||||
Previous int
|
||||
Page int
|
||||
Next int
|
||||
Last int
|
||||
More bool
|
||||
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 {
|
||||
return 1
|
||||
} else if a.Score > b.Score {
|
||||
return -1
|
||||
}
|
||||
// If the score is the same and both page names start
|
||||
// with a number (like an ISO date), sort descending.
|
||||
ra, _ := utf8.DecodeRuneInString(a.Title)
|
||||
rb, _ := utf8.DecodeRuneInString(b.Title)
|
||||
if unicode.IsNumber(ra) && unicode.IsNumber(rb) {
|
||||
if a.Title < b.Title {
|
||||
return 1
|
||||
} else if a.Title > b.Title {
|
||||
// sortNames returns a sort function that sorts in three stages: 1.
|
||||
// whether the query string matches the page title; 2. descending if
|
||||
// the page titles start with a digit; 3. otherwise ascending.
|
||||
// Access to the index requires a read lock!
|
||||
func sortNames(q string) func (a, b string) int {
|
||||
return func (a, b string) int {
|
||||
// If only one page contains the query string, it
|
||||
// takes precedence.
|
||||
ia := strings.Contains(index.titles[a], q)
|
||||
ib := strings.Contains(index.titles[b], q)
|
||||
if (ia && !ib) {
|
||||
return -1
|
||||
} else if (!ia && ib) {
|
||||
return 1
|
||||
}
|
||||
// If both page names start with a number (like an ISO date),
|
||||
// sort descending.
|
||||
ra, _ := utf8.DecodeRuneInString(a)
|
||||
rb, _ := utf8.DecodeRuneInString(b)
|
||||
if unicode.IsNumber(ra) && unicode.IsNumber(rb) {
|
||||
if a < b {
|
||||
return 1
|
||||
} else if a > b {
|
||||
return -1
|
||||
} else {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
// Otherwise sort ascending.
|
||||
if a < b {
|
||||
return -1
|
||||
} else if a > b {
|
||||
return 1
|
||||
} else {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
// Otherwise sort ascending.
|
||||
if a.Title < b.Title {
|
||||
return -1
|
||||
} else if a.Title > b.Title {
|
||||
return 1
|
||||
} else {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
// loadAndSummarize loads the pages named and summarizes them for the
|
||||
// query give.
|
||||
func loadAndSummarize(names []string, q string) []Page {
|
||||
// Load and summarize the items.
|
||||
items := make([]Page, len(names))
|
||||
// load the pages named.
|
||||
func load(names []string) []*Page {
|
||||
items := make([]*Page, len(names))
|
||||
for i, name := range names {
|
||||
p, err := loadPage(name)
|
||||
if err != nil {
|
||||
fmt.Printf("Error loading %s\n", name)
|
||||
} else {
|
||||
p.summarize(q)
|
||||
items[i] = *p
|
||||
items[i] = p
|
||||
}
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
// itemsPerPage says how many items to print on a page of search
|
||||
// results.
|
||||
const itemsPerPage = 20
|
||||
|
||||
// search returns a sorted []Page where each page contains an extract
|
||||
// of the actual Page.Body in its Page.Html.
|
||||
func search(q string) []Page {
|
||||
// of the actual Page.Body in its Page.Html. Page size is 20. The
|
||||
// boolean return value indicates whether there are more results.
|
||||
func search(q string, page int) ([]*Page, bool, int) {
|
||||
if len(q) == 0 {
|
||||
return make([]Page, 0)
|
||||
return make([]*Page, 0), false, 0
|
||||
}
|
||||
words := strings.Fields(strings.ToLower(q))
|
||||
var trigrams []trigram.T
|
||||
for _, word := range words {
|
||||
trigrams = trigram.Extract(word, trigrams)
|
||||
index.RLock()
|
||||
names := searchDocuments(q)
|
||||
slices.SortFunc(names, sortNames(q))
|
||||
index.RUnlock()
|
||||
from := itemsPerPage*(page-1)
|
||||
if from > len(names) {
|
||||
return make([]*Page, 0), false, 0
|
||||
}
|
||||
// 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]
|
||||
to := from + itemsPerPage
|
||||
if to > len(names) {
|
||||
to = len(names)
|
||||
}
|
||||
idx.RUnlock()
|
||||
items := loadAndSummarize(names, q)
|
||||
slices.SortFunc(items, sortItems)
|
||||
return items
|
||||
items := load(names[from:to])
|
||||
for _, p := range items {
|
||||
p.score(q)
|
||||
p.summarize(q)
|
||||
}
|
||||
return items, to < len(names), len(names)/itemsPerPage+1
|
||||
}
|
||||
|
||||
// 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")
|
||||
page, err := strconv.Atoi(r.FormValue("page"))
|
||||
if err != nil {
|
||||
page = 1
|
||||
}
|
||||
items, more, last := search(q, page)
|
||||
s := &Search{Query: q, Items: items, Previous: page-1, Page: page, Next: page+1, Last: last,
|
||||
Results: len(items) > 0, More: more}
|
||||
renderTemplate(w, "search", s)
|
||||
}
|
||||
|
||||
17
search.html
17
search.html
@@ -1,5 +1,5 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="format-detection" content="telephone=no">
|
||||
@@ -10,23 +10,32 @@ html { max-width: 65ch; padding: 2ch; margin: auto; color: #111; background: #ff
|
||||
body { hyphens: auto; }
|
||||
header a { margin-right: 1ch; }
|
||||
form { display: inline-block; }
|
||||
input#search { width: 20ch; }
|
||||
button { background: #eee; color: inherit; border-radius: 4px; border-width: 1px; }
|
||||
img { max-width: 20%; }
|
||||
.result { font-size: larger }
|
||||
.score { font-size: smaller; opacity: 0.8; }
|
||||
</style>
|
||||
</head>
|
||||
<body lang="en">
|
||||
<body>
|
||||
<header>
|
||||
<a href="#main">Skip navigation</a>
|
||||
<a href="/view/index">Home</a>
|
||||
<form role="search" action="/search" method="GET">
|
||||
<input type="text" value="{{.Query}}" spellcheck="false" name="q" required>
|
||||
<button>Search</button>
|
||||
<label for="search">Search:</label>
|
||||
<input id="search" type="text" value="{{.Query}}" spellcheck="false" name="q" required>
|
||||
<button>Go</button>
|
||||
</form>
|
||||
</header>
|
||||
<main id="main">
|
||||
<h1>Search for {{.Query}}</h1>
|
||||
{{if .Results}}
|
||||
<p>
|
||||
{{if gt .Page 2}}<a href="/search?q={{.Query}}&page=1">First</a>{{end}}
|
||||
{{if gt .Page 1}}<a href="/search?q={{.Query}}&page={{.Previous}}">Previous</a>{{end}}
|
||||
Page {{.Page}}
|
||||
{{if .More}}<a href="/search?q={{.Query}}&page={{.Next}}">Next</a>{{end}}
|
||||
{{if lt .Next .Last}}<a href="/search?q={{.Query}}&page={{.Last}}">Last</a>{{end}}
|
||||
{{range .Items}}
|
||||
<article lang="{{.Language}}">
|
||||
<p><a class="result" href="/view/{{.Name}}">{{.Title}}</a>
|
||||
|
||||
@@ -2,96 +2,13 @@ package main
|
||||
|
||||
import (
|
||||
"github.com/stretchr/testify/assert"
|
||||
"os"
|
||||
"strings"
|
||||
"net/url"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 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")
|
||||
}
|
||||
|
||||
10
snippets.go
10
snippets.go
@@ -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:]
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
}
|
||||
|
||||
40
upload.html
Normal file
40
upload.html
Normal file
@@ -0,0 +1,40 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="format-detection" content="telephone=no">
|
||||
<meta name="viewport" content="width=device-width">
|
||||
<title>Upload File</title>
|
||||
<style>
|
||||
html { max-width: 70ch; padding: 2ch; margin: auto; color: #111; background: #ffe; }
|
||||
body { hyphens: auto; }
|
||||
form, textarea { width: 100%; }
|
||||
label { display: inline-block; width: 20ch }
|
||||
</style>
|
||||
</head>
|
||||
<body lang="en">
|
||||
<h1>Upload File</h1>
|
||||
<form action="/drop/{{.}}" 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.
|
||||
<p><label for="text">Filename to use:</label>
|
||||
<input id="text" name="name" type="text" placeholder="image.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 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" type="number" min="10" placeholder="1200">
|
||||
<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" type="number" min="1" max="99" placeholder="75">
|
||||
<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 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>
|
||||
</body>
|
||||
</html>
|
||||
112
upload_drop.go
Normal file
112
upload_drop.go
Normal file
@@ -0,0 +1,112 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"github.com/anthonynsimon/bild/imgio"
|
||||
"github.com/anthonynsimon/bild/transform"
|
||||
"image/jpeg"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// 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
|
||||
}
|
||||
name := r.FormValue("name")
|
||||
filename := filepath.Base(name)
|
||||
if filename == "." || filepath.Dir(name) != "." {
|
||||
http.Error(w, "no filename", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
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
|
||||
path := d + "/" + filename
|
||||
dst, err := os.Create(path)
|
||||
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
|
||||
}
|
||||
// if a resize was requested
|
||||
maxwidth := r.FormValue("maxwidth")
|
||||
if len(maxwidth) > 0 {
|
||||
mw, err := strconv.Atoi(maxwidth)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
ext := strings.ToLower(filepath.Ext(path))
|
||||
var encoder imgio.Encoder
|
||||
switch ext {
|
||||
case ".png":
|
||||
encoder = imgio.PNGEncoder()
|
||||
case ".jpg", ".jpeg":
|
||||
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.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
encoder = imgio.JPEGEncoder(q)
|
||||
default:
|
||||
http.Error(w, "only .png, .jpg, or .jpeg files are supported", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
img, err := imgio.Open(path)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
http.Redirect(w, r, "/view/"+path, http.StatusFound)
|
||||
}
|
||||
79
upload_drop_test.go
Normal file
79
upload_drop_test.go
Normal file
@@ -0,0 +1,79 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"image"
|
||||
"image/jpeg"
|
||||
"image/png"
|
||||
"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/testdata/", nil, 200)
|
||||
form := new(bytes.Buffer)
|
||||
writer := multipart.NewWriter(form)
|
||||
field, err := writer.CreateFormField("name")
|
||||
assert.NoError(t, err)
|
||||
_, err = field.Write([]byte("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/testdata/",
|
||||
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")
|
||||
})
|
||||
}
|
||||
|
||||
// wipes testdata
|
||||
func TestUploadPng(t *testing.T) {
|
||||
_ = os.RemoveAll("testdata")
|
||||
// for uploads, the directory is not created automatically
|
||||
os.MkdirAll("testdata", 0755)
|
||||
form := new(bytes.Buffer)
|
||||
writer := multipart.NewWriter(form)
|
||||
field, _ := writer.CreateFormField("name")
|
||||
field.Write([]byte("ok.png"))
|
||||
file, _ := writer.CreateFormFile("file", "ok.png")
|
||||
img := image.NewRGBA(image.Rect(0, 0, 20, 20))
|
||||
png.Encode(file, img)
|
||||
writer.Close()
|
||||
HTTPUploadAndRedirectTo(t, makeHandler(dropHandler, false), "/drop/testdata/",
|
||||
writer.FormDataContentType(), form, "/view/testdata/ok.png")
|
||||
t.Cleanup(func() {
|
||||
_ = os.RemoveAll("testdata")
|
||||
})
|
||||
}
|
||||
|
||||
// wipes testdata
|
||||
func TestUploadJpg(t *testing.T) {
|
||||
_ = os.RemoveAll("testdata")
|
||||
// for uploads, the directory is not created automatically
|
||||
os.MkdirAll("testdata", 0755)
|
||||
form := new(bytes.Buffer)
|
||||
writer := multipart.NewWriter(form)
|
||||
field, _ := writer.CreateFormField("name")
|
||||
field.Write([]byte("ok.jpg"))
|
||||
file, _ := writer.CreateFormFile("file", "ok.jpg")
|
||||
img := image.NewRGBA(image.Rect(0, 0, 20, 20))
|
||||
jpeg.Encode(file, img, &jpeg.Options{Quality: 90})
|
||||
writer.Close()
|
||||
HTTPUploadAndRedirectTo(t, makeHandler(dropHandler, false), "/drop/testdata/",
|
||||
writer.FormDataContentType(), form, "/view/testdata/ok.jpg")
|
||||
t.Cleanup(func() {
|
||||
_ = os.RemoveAll("testdata")
|
||||
})
|
||||
}
|
||||
32
view.go
Normal file
32
view.go
Normal 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)
|
||||
}
|
||||
12
view.html
12
view.html
@@ -1,5 +1,5 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<html lang="{{.Language}}">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="format-detection" content="telephone=no">
|
||||
@@ -10,19 +10,23 @@ html { max-width: 65ch; padding: 1ch; margin: auto; color: #111; background: #ff
|
||||
body { hyphens: auto; }
|
||||
header a { margin-right: 1ch; }
|
||||
form { display: inline-block; }
|
||||
input#search { width: 12ch; }
|
||||
button { background: #eee; color: inherit; border-radius: 4px; border-width: 1px; }
|
||||
footer { border-top: 1px solid #888 }
|
||||
img { max-width: 100%; }
|
||||
</style>
|
||||
</head>
|
||||
<body lang="{{.Language}}">
|
||||
<body>
|
||||
<header>
|
||||
<a href="#main">Skip navigation</a>
|
||||
<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>
|
||||
<label for="search">Search:</label>
|
||||
<input id="search" type="text" spellcheck="false" name="q" required>
|
||||
<button>Go</button>
|
||||
</form>
|
||||
</header>
|
||||
<main id="main">
|
||||
|
||||
54
view_test.go
Normal file
54
view_test.go
Normal 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 & 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 & 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")
|
||||
})
|
||||
}
|
||||
135
wiki.go
135
wiki.go
@@ -10,14 +10,15 @@ import (
|
||||
|
||||
// Templates are parsed at startup.
|
||||
var templates = template.Must(
|
||||
template.ParseFiles("edit.html", "add.html", "view.html", "search.html"))
|
||||
template.ParseFiles("edit.html", "add.html", "view.html",
|
||||
"search.html", "upload.html"))
|
||||
|
||||
// validPath is a regular expression where the second group matches a
|
||||
// page, so when the editHandler is called, a URL path of "/edit/foo"
|
||||
// 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
|
||||
@@ -34,100 +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)
|
||||
}
|
||||
|
||||
// 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)
|
||||
@@ -135,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 {
|
||||
@@ -155,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 {
|
||||
@@ -177,13 +84,15 @@ func scheduleLoadLanguages() {
|
||||
fmt.Printf("Loaded %d languages\n", n)
|
||||
}
|
||||
|
||||
func main() {
|
||||
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("/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()
|
||||
@@ -191,3 +100,11 @@ func main() {
|
||||
fmt.Printf("Serving a wiki on port %s\n", port)
|
||||
http.ListenAndServe(":"+port, nil)
|
||||
}
|
||||
|
||||
func main() {
|
||||
if len(os.Args) == 1 {
|
||||
serve()
|
||||
} else {
|
||||
commands()
|
||||
}
|
||||
}
|
||||
|
||||
124
wiki_test.go
124
wiki_test.go
@@ -1,13 +1,11 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"bytes"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
@@ -27,8 +25,7 @@ func HTTPHeaders(handler http.HandlerFunc, method, url string, values url.Values
|
||||
// HTTPRedirectTo checks that the request results in a redirect and it
|
||||
// checks the destination of the redirect. It returns whether the
|
||||
// request did in fact result in a redirect. Note: This method assumes
|
||||
// that POST requests ignore the query part of the URL which is often
|
||||
// true but not mandated by the standards.
|
||||
// that POST requests ignore the query part of the URL.
|
||||
func HTTPRedirectTo(t *testing.T, handler http.HandlerFunc, method, url string, values url.Values, destination string) bool {
|
||||
w := httptest.NewRecorder()
|
||||
var req *http.Request
|
||||
@@ -40,108 +37,31 @@ func HTTPRedirectTo(t *testing.T, handler http.HandlerFunc, method, url string,
|
||||
} else {
|
||||
req, err = http.NewRequest(method, url+"?"+values.Encode(), nil)
|
||||
}
|
||||
if err != nil {
|
||||
assert.Fail(t, fmt.Sprintf("Failed to build test request, got error: %s", err))
|
||||
}
|
||||
assert.NoError(t, err)
|
||||
handler(w, req)
|
||||
code := w.Code
|
||||
isRedirectCode := code >= http.StatusMultipleChoices && code <= http.StatusTemporaryRedirect
|
||||
if !isRedirectCode {
|
||||
assert.Fail(t, fmt.Sprintf("Expected HTTP redirect status code for %q but received %d", url+"?"+values.Encode(), code))
|
||||
}
|
||||
assert.True(t, isRedirectCode, "Expected HTTP redirect status code for %q but received %d", url+"?"+values.Encode(), code)
|
||||
headers := w.Result().Header["Location"]
|
||||
if len(headers) != 1 || headers[0] != destination {
|
||||
assert.Fail(t, fmt.Sprintf("Expected HTTP redirect location %s for %q but received %v", destination, url+"?"+values.Encode(), headers))
|
||||
}
|
||||
assert.True(t, len(headers) == 1 && headers[0] == destination,
|
||||
"Expected HTTP redirect location %s for %q but received %v", destination, url+"?"+values.Encode(), 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("It’s not barbecue"),
|
||||
assert.HTTPBody(makeHandler(viewHandler), "GET", "/view/testdata/fire", 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 & 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 & 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")
|
||||
})
|
||||
// HTTPUploadAndRedirectTo checks that the request results in a redirect and it
|
||||
// checks the destination of the redirect. It returns whether the
|
||||
// request did in fact result in a redirect.
|
||||
func HTTPUploadAndRedirectTo(t *testing.T, handler http.HandlerFunc, url, contentType string, body *bytes.Buffer, destination string) bool {
|
||||
w := httptest.NewRecorder()
|
||||
req, err := http.NewRequest("POST", url, body)
|
||||
req.Header.Set("Content-Type", contentType)
|
||||
assert.NoError(t, err)
|
||||
handler(w, req)
|
||||
code := w.Code
|
||||
isRedirectCode := code >= http.StatusMultipleChoices && code <= http.StatusTemporaryRedirect
|
||||
assert.True(t, isRedirectCode, "Expected HTTP redirect status code for %q but received %d", url, code)
|
||||
headers := w.Result().Header["Location"]
|
||||
assert.True(t, len(headers) == 1 && headers[0] == destination,
|
||||
"Expected HTTP redirect location %s for %q but received %v", destination, url, headers)
|
||||
return isRedirectCode
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user