48 Commits

Author SHA1 Message Date
Alex Schroeder
153a179d92 Search results are based on page titles only 2023-09-16 23:36:17 +02:00
Alex Schroeder
d9797aac75 Add pagination of the results
Sorting and scoring still requires the loading of all the found pages,
but the summary is only computed for the pages on the page.
2023-09-16 16:14:59 +02:00
Alex Schroeder
005500457e Add wiki markup 2023-09-16 13:26:06 +02:00
Alex Schroeder
2635d5f852 Improve HTML template accessibility
Used the web accessibility evaluation tool (WAVE) at wave.webaim.org
and fixed some errors.
2023-09-16 09:59:30 +02:00
Alex Schroeder
a79f4558b6 Fix file extension check 2023-09-15 21:37:33 +02:00
Alex Schroeder
d1c2b8e27c go fmt 2023-09-15 16:03:52 +02:00
Alex Schroeder
dd939e2c86 HTML changes to the upload template 2023-09-15 16:03:38 +02:00
Alex Schroeder
475c7071ba Add image resizing 2023-09-15 14:13:20 +02:00
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
Alex Schroeder
92a52d2c97 Make some fields in the templates required 2023-09-14 16:21:14 +02:00
Alex Schroeder
7f0b371570 Remove UMask from service file 2023-09-14 16:20:54 +02:00
Alex Schroeder
a44d903775 Document limitations regarding uploads 2023-09-14 16:20:41 +02:00
Alex Schroeder
1ca8e6f3aa Removed useless div 2023-09-14 16:02:32 +02:00
Alex Schroeder
93197f94bf Add upload form and documentation 2023-09-14 16:02:27 +02:00
Alex Schroeder
a7861edbad Renamed uploadHandler to saveUploadHandler
It's annoying that we keep needing two URLs for everything. One to
server the UI and one to actually do it: edit/save (with page name in
the URL), add/append (that's a sketchy pair), upload/save (save again,
but this time without a trailing slash, ugh).
2023-09-14 15:43:12 +02:00
Alex Schroeder
d4090ab146 Add html command for the command-line 2023-09-14 15:36:46 +02:00
Alex Schroeder
4da0ba8d94 Allow file uploads 2023-09-14 15:36:24 +02:00
Alex Schroeder
941ceeaf6c Removed useless div in edit form 2023-09-14 15:34:58 +02:00
Alex Schroeder
c2cf2b121c Warn about backup files in the README 2023-09-14 15:34:38 +02:00
Alex Schroeder
383bb88218 Add a mutex to the search index 2023-09-14 10:05:38 +02:00
Alex Schroeder
1b8c590ced Ensure the Cancel button does not submit 2023-09-14 08:17:03 +02:00
Alex Schroeder
ab014a1ba8 go fmt 2023-09-14 07:58:41 +02:00
Alex Schroeder
cd661a2357 Use strings.Fields instead of splitting by space 2023-09-14 07:58:17 +02:00
Alex Schroeder
2060d323a6 Add name escaping for the HTML
Otherwise, questionmarks in page names aren't handled correctly.
2023-09-14 07:57:39 +02:00
Alex Schroeder
30df5fb9e1 Shortened TODO 2023-09-14 00:26:38 +02:00
Alex Schroeder
21ec558a2b Document the handling of questionmarks in pagenames 2023-09-14 00:26:10 +02:00
Alex Schroeder
22db61c73a Load languages and indexes later 2023-09-13 16:00:12 +02:00
Alex Schroeder
3bdd05f083 Go fmt 2023-09-13 14:42:46 +02:00
Alex Schroeder
154b6805c4 Use HTTP helpers for testing 2023-09-13 14:42:20 +02:00
Alex Schroeder
a3373fec6f Test hashtag search; use assertions 2023-09-12 22:27:58 +02:00
Alex Schroeder
ebaadc111a Explain hashtag search 2023-09-12 22:27:58 +02:00
Alex Schroeder
afa9907863 Don't show underscores in hashtag text 2023-09-12 22:27:58 +02:00
Alex Schroeder
b57afc17ca Fix whitespace issue for hashtags 2023-09-12 22:27:58 +02:00
Alex Schroeder
ad010249d6 Extend Markdown to link hashtags to searches
This requires the latest version of
https://github.com/gomarkdown/markdown – see
https://github.com/gomarkdown/markdown/issues/291
2023-09-12 22:25:21 +02:00
Alex Schroeder
b86eee7136 Make the language detection configurable 2023-09-12 14:25:39 +02:00
Alex Schroeder
55be27b2d1 Change the default permissions
Directories use 0755 instead of 0700 and files use 0644 instead of
0600. This is important when the Markdown files are being served by
the webserver, assuming that Oddmu is run by one user and the web
server is run by anoher.
2023-09-12 08:37:37 +02:00
Alex Schroeder
565a3b2831 Add tests for ampersands in filenames and titles 2023-09-11 18:04:44 +02:00
Alex Schroeder
302da8b212 Save an empty page to delete it 2023-09-11 17:52:00 +02:00
Alex Schroeder
3f69eadafc Add wiki_test.go 2023-09-11 17:51:45 +02:00
Alex Schroeder
78c640278d Serve static files 2023-09-11 16:02:43 +02:00
Alex Schroeder
285574d262 Remove Gemtext documentation from README 2023-09-11 15:58:14 +02:00
Alex Schroeder
80e2522f4a Use assert for page_test.go 2023-09-11 15:58:06 +02:00
Alex Schroeder
471cd3c6ec Add appending to pages 2023-09-11 00:27:11 +02:00
35 changed files with 1459 additions and 474 deletions

View File

@@ -25,4 +25,4 @@ test:
upload:
go build
rsync --itemize-changes --archive oddmu oddmu.service *.html README.md sibirocobombus.root:/home/oddmu/
ssh sibirocobombus.root "systemctl restart oddmu"
ssh sibirocobombus.root "systemctl restart oddmu; systemctl restart alex"

219
README.md
View File

@@ -29,11 +29,30 @@ Oddmu. 🙃
## Markdown
This wiki uses 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)`. The Markdown processor comes with a few extensions,
some of which are enable by default:
This wiki uses a [Markdown
library](https://github.com/gomarkdown/markdown) to generate the web
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:
* emphasis markers inside words are ignored
* tables are supported
@@ -46,10 +65,6 @@ some of which are enable by default:
* definition lists are supported
* MathJax is supported (but needs a separte setup)
See the section on
[extensions](https://github.com/gomarkdown/markdown#extensions) in the
Markdown library for information on the various extensions.
A table with footers and a columnspan:
```text
@@ -73,26 +88,57 @@ Internet
## Templates
Feel free to change the templates `view.html` and `edit.html` and
restart the server. Modifying the styles in the templates would be a
good start to get a feel for it.
The template files are the HTML files in the working directory:
`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`.
😄
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
learn more about HTML.
Modifying the styles in the templates would be another good start to
get a feel for it. See [Learn to style HTML using
CSS](https://developer.mozilla.org/en-US/docs/Learn/CSS) to learn more
about style sheets.
The templates can refer to the following properties of a page:
`{{.Title}}` is the page title. If the page doesn't provide its own
title, the page name is used.
`{{.Name}}` is the page name. The page name doesn't include the `.md`
extension.
`{{.Name}}` is the page name, escaped for use in URLs. More
specifically, it is URI escaped except for the slashes. The page name
doesn't include the `.md` extension.
`{{.Html}}` is the rendered Markdown, as HTML.
`{{printf "%s" .Body}}` is the Markdown, as a string (the data itself
is a byte array and that's why we need to call `printf`).
For the `search.html` template only:
`{{.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
refer to them, you need to use a `{{range .Items}}``{{end}}`
construct.
For search results, `{{.Html}}` is the rendered Markdown of a page
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`:
@@ -102,6 +148,11 @@ curl --form body="Did you bring a towel?" \
http://localhost:8080/save/welcome
```
The wiki uses the standard
[html/template](https://pkg.go.dev/html/template) library to do this.
There's more information on writing templates in the documentation for
the [text/template](https://pkg.go.dev/text/template) library.
## Non-English hyphenation
Automatic hyphenation by the browser requires two things: The style
@@ -110,12 +161,11 @@ and that element must have a `lang` set (usually a two letter language
code such as `de` for German). This happens in the template files,
such as `view.html` and `search.html`.
If have pages in different languages, the problem is that they all use
the same template and that's not good. In such cases, it might be
better to not specificy the `lang` attribute in the template. This
also disables hyphenation by the browser, unfortunately. It might
still be better than using English hyphenation patterns for
non-English languages.
Oddmu uses the [lingua](github.com/pemistahl/lingua-go) library to
detect languages. If you know that you're only going to use a small
number of languages or just a single language! you can set the
environment variable ODDMU_LANGUAGES to a comma-separated list of ISO
639-1 codes, e.g. "en" or "en,de,fr,pt".
## Building
@@ -142,6 +192,9 @@ If you ran it in the source directory, try
http://localhost:8080/view/README this serves the README file you're
currently reading.
You can change the port by setting the ODDMU_PORT environment
variable.
## Deploying it using systemd
As root, on your server:
@@ -151,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:
@@ -160,6 +213,7 @@ likely have to take care of:
ExecStart=/home/oddmu/oddmu
WorkingDirectory=/home/oddmu
Environment="ODDMU_PORT=8080"
Environment="ODDMU_LANGUAGES=en,de"
```
Install the service file and enable it:
@@ -207,27 +261,21 @@ MDCertificateAgreement accepted
ServerAdmin alex@alexschroeder.ch
ServerName transjovian.org
SSLEngine on
RewriteEngine on
RewriteRule ^/$ http://%{HTTP_HOST}:8080/view/index [redirect]
RewriteRule ^/(view|edit|save|search)/(.*) http://%{HTTP_HOST}:8080/$1/$2 [proxy]
ProxyPassMatch ^/(search|(view|edit|save|add|append|upload|drop)/(.*))?$ http://localhost:8080/$1
</VirtualHost>
```
First, it manages the domain, getting the necessary certificates. It
redirects regular HTTP traffic from port 80 to port 443. It turns on
the SSL engine for port 443. It redirects `/` to `/view/index` and any
path that starts with `/view/`, `/edit/`, `/save/` or `/search/` is
proxied to port 8080 where the Oddmu program can handle it.
the SSL engine for port 443. It proxies the requests for the wiki to
port 8080.
Thus, this is what happens:
* The user tells the browser to visit `http://transjovian.org` (on port 80)
* Apache redirects this to `http://transjovian.org/` by default (still on port 80)
* Our first virtual host redirects this to `https://transjovian.org/` (encrypted, on port 443)
* Our second virtual host redirects this to `https://transjovian.org/wiki/view/index` (still on port 443)
* This is proxied to `http://transjovian.org:8080/view/index` (no on port 8080, without encryption)
* The wiki converts `index.md` to HTML, adds it to the template, and serves it.
* The user tells the browser to visit `transjovian.org`
* The browser sends a request for `http://transjovian.org` (on port 80)
* Apache redirects this to `https://transjovian.org/` by default (now on port 443)
* This is proxied to `http://transjovian.org:8080/` (no encryption, on port 8080)
Restart the server, gracefully:
@@ -235,6 +283,11 @@ Restart the server, gracefully:
apachectl graceful
```
To serve both HTTP and HTTPS, don't redirect from the first virtual
host to the second instead just proxy to the wiki like you did for
the second virtual host: use a copy of the `ProxyPassMatch` directive
instead of `RewriteEngine on` and `RewriteRule`.
## Access
Access control is not part of the wiki. By default, the wiki is
@@ -265,12 +318,12 @@ To delete remove a user:
htpasswd -D .htpasswd berta
```
Modify your site configuration and protect the `/edit/` and `/save/`
URLs with a password by adding the following to your `<VirtualHost
*:443>` section:
Modify your site configuration and protect the `/edit/`, `/save/`,
`/add/`, `/append/`, `/upload/` and `/drop/` URLs with a password by
adding the following to your `<VirtualHost *:443>` section:
```apache
<LocationMatch "^/(edit|save)/">
<LocationMatch "^/(edit|save|add|append|upload|drop)/">
AuthType Basic
AuthName "Password Required"
AuthUserFile /home/oddmu/.htpasswd
@@ -295,10 +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/` 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: *
@@ -322,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>
```
@@ -343,53 +395,14 @@ that matches everything:
</Location>
```
## Customization (with recompilation)
## Virtual hosting
The Markdown parser can be customized and
[extensions](https://pkg.go.dev/github.com/gomarkdown/markdown/parser#Extensions)
can be added. There's an example in the
[usage](https://github.com/gomarkdown/markdown#usage) section. You'll
need to make changes to the `viewHandler` yourself.
### Render Gemtext
In a first approximation, Gemtext is valid Markdown except for the
rocket links (`=>`). Here's how to modify the `loadPage` so that a
`.gmi` file is loaded if no `.md` is found, and the rocket links are
translated into Markdown:
```go
func loadPage(name string) (*Page, error) {
filename := name + ".md"
body, err := os.ReadFile(filename)
if err == nil {
return &Page{Title: name, Name: name, Body: body}, nil
}
filename = name + ".gmi"
body, err = os.ReadFile(filename)
if err == nil {
re := regexp.MustCompile(`(?m)^=>\s*(\S+)\s+(.+)`)
body = []byte(re.ReplaceAllString(string(body), `* [$2]($1)`))
return &Page{Title: name, Name: name, Body: body}, nil
}
return nil, err
}
```
There is a small problem, however: By default, Markdown expects an
empty line before a list begins. The following change to `renderHtml`
uses the `NoEmptyLineBeforeBlock` extension for the parser:
```go
func (p* Page) renderHtml() {
// Here is where a new extension is added!
extensions := parser.CommonExtensions | parser.NoEmptyLineBeforeBlock
markdownParser := parser.NewWithExtensions(extensions)
maybeUnsafeHTML := markdown.ToHTML(p.Body, markdownParser, nil)
html := bluemonday.UGCPolicy().SanitizeBytes(maybeUnsafeHTML)
p.Html = template.HTML(html);
}
```
Virtual hosting in this context means that the program serves two
different sites for two different domains from the same machine. Oddmu
doesn't support that, but your webserver does. Therefore, start an
Oddmu instance for every domain name, each listening on a different
port. Then set up your web server such that ever domain acts as a
reverse proxy to a different Oddmu instance.
## Understanding search
@@ -426,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
@@ -435,6 +464,14 @@ The pages are indexed as the server starts and the index is kept in
memory. If you have a ton of pages, this surely wastes a lot of
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.

13
TODO.md
View File

@@ -1,15 +1,6 @@
Easily prepend or append text for use with a mobile browser. Like
comments.
Upload files.
Upload files should use path info so that we can use Apache to
restrict access to directories.
Automatically scale or process files.
Post by Delta Chat? That is, allow certain encrypted emails to post.
Convert the existing wiki.
Investigate how to run a multi-linugual wiki where an appropriate
version of a page is served based on language preferences of the user.
This is a low priority issue since it's probably only of interest for
corporate or governmental sites.

21
add.html Normal file
View File

@@ -0,0 +1,21 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="format-detection" content="telephone=no">
<meta name="viewport" content="width=device-width">
<title>Add to {{.Title}}</title>
<style>
html { max-width: 70ch; padding: 2ch; margin: auto; color: #111; background: #ffe; }
form, textarea { width: 100%; }
</style>
</head>
<body>
<h1>Adding to {{.Title}}</h1>
<form action="/append/{{.Name}}" method="POST">
<textarea name="body" rows="20" cols="80" placeholder="Text" lang="" autofocus required></textarea>
<p><input type="submit" value="Add">
<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")
})
}

39
commands.go Normal file
View 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")
}
}

15
concurrency_test.go Normal file
View File

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

View File

@@ -1,5 +1,5 @@
<!DOCTYPE html>
<html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="format-detection" content="telephone=no">
@@ -12,11 +12,12 @@ form, textarea { width: 100%; }
</head>
<body>
<h1>Editing {{.Title}}</h1>
<form action="/save/{{.Name}}" method="POST">
<div><textarea name="body" rows="20" cols="80">{{printf "%s" .Body}}</textarea></div>
<textarea name="body" rows="20" cols="80" placeholder="# Title
Text" lang="" autofocus>{{printf "%s" .Body}}</textarea>
<p><input type="submit" value="Save">
<a href="/view/{{.Name}}"><button>Cancel</button></a></p>
<a href="/view/{{.Name}}"><button type="button">Cancel</button></a></p>
</form>
</body>
</html>

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")
})
}

10
go.mod
View File

@@ -4,16 +4,22 @@ go 1.21.0
require (
github.com/dgryski/go-trigram v0.0.0-20160407183937-79ec494e1ad0
github.com/gomarkdown/markdown v0.0.0-20230716120725-531d2d74bc12
github.com/gomarkdown/markdown v0.0.0-20230912175223-14b07df9d538
github.com/microcosm-cc/bluemonday v1.0.25
github.com/pemistahl/lingua-go v1.4.0
github.com/stretchr/testify v1.8.4
)
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/pemistahl/lingua-go v1.4.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
)

45
go.sum
View File

@@ -1,24 +1,69 @@
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=
github.com/gomarkdown/markdown v0.0.0-20230912175223-14b07df9d538 h1:ePDpFu7l0QUV46/9A7icfL2wvIOzTJLCWh4RO2NECzE=
github.com/gomarkdown/markdown v0.0.0-20230912175223-14b07df9d538/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.8 h1:e6P7q2lk1O+qJJb4BtCQXlK8vWEO8V1ZeuEdJNOqZyg=
github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/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
View 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
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, 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")
})
}

58
languages.go Normal file
View File

@@ -0,0 +1,58 @@
package main
import (
"errors"
"github.com/pemistahl/lingua-go"
"os"
"strings"
)
// getLangauges returns the environment variable ODDMU_LANGUAGES or
// all languages.
func getLanguages() ([]lingua.Language, error) {
v := os.Getenv("ODDMU_LANGUAGES")
if v == "" {
return lingua.AllLanguages(), nil
}
codes := strings.Split(v, ",")
if len(codes) == 1 {
return nil, errors.New("detection unnecessary")
}
var langs []lingua.Language
for _, lang := range codes {
langs = append(langs, lingua.GetLanguageFromIsoCode639_1(lingua.GetIsoCode639_1FromValue(lang)))
}
return langs, nil
}
// detector is the LanguageDetector initialized at startup by loadLanguages.
var detector lingua.LanguageDetector
// loadLanguages initializes the detector using the languages returned
// by getLanguages and returns the number of languages loaded.
func loadLanguages() int {
langs, err := getLanguages()
if err == nil {
detector = lingua.NewLanguageDetectorBuilder().
FromLanguages(langs...).
WithPreloadedLanguageModels().
WithLowAccuracyMode().
Build()
} else {
detector = nil
}
return len(langs)
}
// language returns the language used for a string, as a lower case
// ISO 639-1 string, e.g. "en" or "de".
func language(s string) string {
if detector == nil {
return os.Getenv("ODDMU_LANGUAGES")
}
if language, ok := detector.DetectLanguageOf(s); ok {
return strings.ToLower(language.IsoCode639_1().String())
}
return ""
}

50
languages_test.go Normal file
View File

@@ -0,0 +1,50 @@
package main
import (
"github.com/stretchr/testify/assert"
"os"
"testing"
)
func TestAllLanguage(t *testing.T) {
os.Unsetenv("ODDMU_LANGUAGES")
loadLanguages()
l := language(`
My back hurts at night
My shoulders won't budge today
Winter bones I say`)
assert.Equal(t, "en", l)
}
func TestSomeLanguages(t *testing.T) {
os.Setenv("ODDMU_LANGUAGES", "en,de")
loadLanguages()
l := language(`
Kühle Morgenluft
Keine Amsel singt heute
Mensch im Dämmerlicht
`)
assert.Equal(t, "de", l)
}
func TestOneLanguages(t *testing.T) {
os.Setenv("ODDMU_LANGUAGES", "en")
loadLanguages()
l := language(`
Schwer wiegt die Luft hier
Atme ein, ermahn' ich mich
Erinnerungen
`)
assert.Equal(t, "en", l)
}
func TestWrongLanguages(t *testing.T) {
os.Setenv("ODDMU_LANGUAGES", "de,fr")
loadLanguages()
l := language(`
Something drifts down there
Head submerged oh god a man
Drowning as we stare
`)
assert.NotEqual(t, "en", l)
}

View File

@@ -17,7 +17,6 @@ Environment="ODDMU_PORT=8080"
ReadWritePaths=/home/oddmu
ProtectHostname=yes
RestrictSUIDSGID=yes
UMask=0077
RemoveIPC=yes
MemoryDenyWriteExecute=yes

112
page.go
View File

@@ -7,28 +7,13 @@ import (
"github.com/gomarkdown/markdown/ast"
"github.com/gomarkdown/markdown/parser"
"github.com/microcosm-cc/bluemonday"
"github.com/pemistahl/lingua-go"
"html/template"
"net/url"
"os"
"path/filepath"
"strings"
)
// languages is the list of languages the wiki understands. This is
// passed along to the template so that it can be added to the
// template which allows browsers to (maybe) do hyphenation correctly.
var languages = []lingua.Language{
lingua.English,
lingua.German,
}
// detector is built once based on the list languages.
var detector = lingua.NewLanguageDetectorBuilder().
FromLanguages(languages...).
WithPreloadedLanguageModels().
WithLowAccuracyMode().
Build()
// Page is a struct containing information about a single page. Title
// is the title extracted from the page content using titleRegexp.
// Name is the filename without extension (so a filename of "foo.md"
@@ -44,33 +29,51 @@ type Page struct {
Score int
}
// santize uses bluemonday to sanitize the HTML.
func sanitize(s string) template.HTML {
return template.HTML(bluemonday.UGCPolicy().Sanitize(s))
}
// santizeBytes uses bluemonday to sanitize the HTML.
func sanitizeBytes(bytes []byte) template.HTML {
return template.HTML(bluemonday.UGCPolicy().SanitizeBytes(bytes))
}
// nameEscape returns the page name safe for use in URLs. That is,
// percent escaping is used except for the slashes.
func nameEscape(s string) string {
parts := strings.Split(s, "/")
for i, part := range parts {
parts[i] = url.PathEscape(part)
}
return strings.Join(parts, "/")
}
// 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
p.updateIndex()
d := filepath.Dir(filename)
if d != "." {
err := os.MkdirAll(d, 0700)
err := os.MkdirAll(d, 0755)
if err != nil {
fmt.Printf("Creating directory %s failed", d)
return err
}
}
return os.WriteFile(filename, s, 0600)
_ = os.Rename(filename, filename+"~")
return os.WriteFile(filename, s, 0644)
}
// loadPage loads a Page given a name. The filename loaded is that
@@ -101,9 +104,57 @@ 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
n := len(data)
for i < n && !parser.IsSpace(data[i]) {
i++
}
if i == 0 {
return 0, nil
}
link := &ast.Link{
Destination: append([]byte("/search?q=%23"), data[1:i]...),
Title: data[0:i],
}
text := bytes.ReplaceAll(data[0:i], []byte("_"), []byte(" "))
ast.AppendChild(link, &ast.Text{Leaf: ast.Leaf{Literal: text}})
return i, link
}
// renderHtml renders the Page.Body to HTML and sets Page.Html.
func (p *Page) renderHtml() {
maybeUnsafeHTML := markdown.ToHTML(p.Body, nil, nil)
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)
p.Html = sanitizeBytes(maybeUnsafeHTML)
p.Language = language(p.plainText())
}
@@ -135,18 +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 language(s string) string {
if language, ok := detector.DetectLanguageOf(s); ok {
return strings.ToLower(language.IsoCode639_1().String())
func (p *Page) Dir() string {
d := filepath.Dir(p.Name)
if d == "." {
return ""
}
return ""
return d
}

View File

@@ -1,8 +1,9 @@
package main
import (
"github.com/stretchr/testify/assert"
"os"
"strings"
"regexp"
"testing"
)
@@ -12,19 +13,10 @@ My back aches for you
I sit, stare and type for hours
But yearn for blue sky`)}
p.handleTitle(false)
if p.Title != "Ache" {
t.Logf("The page title was not extracted correctly: %s", p.Title)
t.Fail()
}
if !strings.HasPrefix(string(p.Body), "# Ache") {
t.Logf("The page title was removed: %s", p.Body)
t.Fail()
}
assert.Equal(t, "Ache", p.Title)
assert.Regexp(t, regexp.MustCompile("^# Ache"), string(p.Body))
p.handleTitle(true)
if !strings.HasPrefix(string(p.Body), "My back") {
t.Logf("The page title was not removed: %s", p.Body)
t.Fail()
}
assert.Regexp(t, regexp.MustCompile("^My back"), string(p.Body))
}
func TestPagePlainText(t *testing.T) {
@@ -32,12 +24,8 @@ func TestPagePlainText(t *testing.T) {
The air will not come
To inhale is an effort
The summer heat kills`)}
s := p.plainText()
r := "Water The air will not come To inhale is an effort The summer heat kills"
if s != r {
t.Logf("The plain text version is wrong: %s", s)
t.Fail()
}
assert.Equal(t, r, p.plainText())
}
func TestPageHtml(t *testing.T) {
@@ -46,44 +34,74 @@ Silver leaves shine bright
They droop, boneless, weak and sad
A cruel sun stares down`)}
p.renderHtml()
s := string(p.Html)
r := `<h1>Sun</h1>
<p>Silver leaves shine bright
They droop, boneless, weak and sad
A cruel sun stares down</p>
`
if s != r {
t.Logf("The HTML is wrong: %s", s)
t.Fail()
}
assert.Equal(t, r, string(p.Html))
}
func TestPageHtmlHashtag(t *testing.T) {
p := &Page{Body: []byte(`# Comet
Stars flicker above
Too faint to focus, so far
I am cold, alone
#Haiku #Cold_Poets`)}
p.renderHtml()
r := `<h1>Comet</h1>
<p>Stars flicker above
Too faint to focus, so far
I am cold, alone</p>
<p><a href="/search?q=%23Haiku" rel="nofollow">#Haiku</a> <a href="/search?q=%23Cold_Poets" rel="nofollow">#Cold Poets</a></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")
if err != nil || string(o.Body) != string(p.Body) {
t.Logf("File in subdirectory not loaded: %s", p.Name)
t.Fail()
}
assert.NoError(t, err, "load page")
assert.Equal(t, p.Body, o.Body)
assert.FileExists(t, "testdata/moon.md")
// Saving an empty page deletes it.
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")
})
}
func TestLanguage(t *testing.T) {
l := language(`
My back hurts at night
My shoulders won't budge today
Winter bones I say`)
if l != "en" {
t.Logf("Language detected: %s", l)
t.Fail()
}
}

View File

@@ -18,10 +18,7 @@ func score(q string, s string) int {
score += len(m)
}
}
for _, v := range strings.Split(q, " ") {
if len(v) == 0 {
continue
}
for _, v := range strings.Fields(q) {
re, err := regexp.Compile(`(?is)(\pL?)(` + v + `)(\pL?)`)
if err != nil {
continue

View File

@@ -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 {

188
search.go
View File

@@ -2,10 +2,9 @@ package main
import (
"fmt"
trigram "github.com/dgryski/go-trigram"
"io/fs"
"path/filepath"
"net/http"
"slices"
"strconv"
"strings"
"unicode"
"unicode/utf8"
@@ -17,121 +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
}
// 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.
var index trigram.Index
// documents is a map, mapping document ids of the index to page
// names.
var documents map[trigram.DocID]string
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 := index.Add(strings.ToLower(string(p.Body)))
documents[id] = p.Name
return nil
}
func loadIndex() error {
index = make(trigram.Index)
documents = make(map[trigram.DocID]string)
err := filepath.Walk(".", indexAdd)
if err != nil {
fmt.Println("Indexing failed")
index = nil
documents = nil
}
return err
}
func (p *Page) updateIndex() {
var id trigram.DocID
for docId, name := range documents {
if name == p.Name {
id = docId
break
}
}
if id == 0 {
id = index.Add(strings.ToLower(string(p.Body)))
documents[id] = p.Name
} else {
o, err := loadPage(p.Name)
if err == nil {
index.Delete(strings.ToLower(string(o.Body)), id)
}
index.Insert(strings.ToLower(string(p.Body)), id)
}
}
// 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 {
if len(q) == 0 {
return make([]Page, 0)
}
words := strings.Split(strings.ToLower(q), " ")
var trigrams []trigram.T
for _, word := range words {
trigrams = trigram.Extract(word, trigrams)
}
ids := index.QueryTrigrams(trigrams)
items := make([]Page, len(ids))
for i, id := range ids {
name := documents[id]
p, err := loadPage(name)
if err != nil {
fmt.Printf("Error loading %s\n", name)
} else {
p.summarize(q)
items[i] = *p
}
}
fn := func(a, b Page) int {
// Sort by score
if a.Score < b.Score {
return 1
} else if a.Score > b.Score {
// 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 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 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.Title < b.Title {
if a < b {
return 1
} else if a.Title > b.Title {
} else if a > b {
return -1
} else {
return 0
}
}
// Otherwise sort ascending.
if a.Title < b.Title {
if a < b {
return -1
} else if a.Title > b.Title {
} else if a > b {
return 1
} else {
return 0
}
}
slices.SortFunc(items, fn)
}
// 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 {
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. 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), false, 0
}
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
}
to := from + itemsPerPage
if to > len(names) {
to = len(names)
}
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)
}

View File

@@ -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>

View File

@@ -1,102 +1,14 @@
package main
import (
"os"
"strings"
"github.com/stretchr/testify/assert"
"net/url"
"testing"
)
var name string = "test"
// TestIndex relies on README.md being indexed
func TestIndex(t *testing.T) {
_ = os.Remove(name + ".md")
loadIndex()
q := "Oddµ"
pages := search(q)
if len(pages) == 0 {
t.Log("Search found no result")
t.Fail()
}
for _, p := range pages {
if strings.Contains(p.Title, "<b>") {
t.Logf("Page %s contains <b>", p.Name)
t.Fail()
}
if !strings.Contains(string(p.Body), q) && !strings.Contains(string(p.Title), q) {
t.Logf("Page %s does not contain %s", p.Name, q)
t.Fail()
}
if p.Score == 0 {
t.Logf("Page %s has no score", p.Name)
t.Fail()
}
}
p := &Page{Name: name, Body: []byte("This is a test.")}
p.save()
pages = search("This is a test")
found := false
for _, p := range pages {
if p.Name == name {
found = true
break
}
}
if !found {
t.Logf("Page '%s' was not found", name)
t.Fail()
}
pages = search("this is a test")
found = false
for _, p := range pages {
if p.Name == name {
found = true
break
}
}
if !found {
t.Logf("Page '%s' was not found using the lower case text", name)
t.Fail()
}
pages = search("this test")
found = false
for _, p := range pages {
if p.Name == name {
found = true
break
}
}
if !found {
t.Logf("Page '%s' was not found using a query missing some words", name)
t.Fail()
}
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
}
}
if found {
t.Logf("Page '%s' was still found using the old content: %s", name, p.Body)
t.Fail()
}
pages = search("Guvf")
found = false
for _, p := range pages {
if p.Name == name {
found = true
break
}
}
if !found {
t.Logf("Page '%s' not found using the new content: %s", name, p.Body)
t.Fail()
}
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))
}

40
upload.html Normal file
View 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
View 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
View 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
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

@@ -1,5 +1,5 @@
<!DOCTYPE html>
<html>
<html lang="{{.Language}}">
<head>
<meta charset="utf-8">
<meta name="format-detection" content="telephone=no">
@@ -10,18 +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 this page</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
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")
})
}

133
wiki.go
View File

@@ -6,18 +6,19 @@ import (
"net/http"
"os"
"regexp"
"strings"
)
// Templates are parsed at startup.
var templates = template.Must(template.ParseFiles("edit.html", "view.html", "search.html"))
var templates = template.Must(
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 handler for "/edit/" 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("^/([^/]+)/(.+)$")
// 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("^/([^/]+)/(.*)$")
// titleRegexp is a regular expression matching a level 1 header line
// in a Markdown document. The first group matches the actual text and
@@ -34,70 +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 renders a text file, if the name ends in ".txt" and
// such a file exists. Otherwise, it loads the page. If this didn't
// work, the browser is redirected to an edit page. Otherwise, the
// "view.html" template is used to show the rendered HTML.
func viewHandler(w http.ResponseWriter, r *http.Request, name string) {
// Short cut for text files
if strings.HasSuffix(name, ".txt") {
body, err := os.ReadFile(name)
if err == nil {
w.Write(body)
return
}
}
// Attempt to load Markdown page; edit it if this fails
p, err := loadPage(name)
if err != nil {
http.Redirect(w, r, "/edit/"+name, http.StatusFound)
return
}
p.handleTitle(true)
p.renderHtml()
renderTemplate(w, "view", p)
}
// 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.
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)
}
// 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)
@@ -105,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 {
@@ -125,15 +62,49 @@ func getPort() string {
return port
}
func main() {
// 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 := index.load()
if err == nil {
fmt.Printf("Indexed %d pages\n", n)
} else {
fmt.Println("Indexing failed")
}
}
// scheduleLoadLanguages calls loadLanguages and prints some messages before
// and after. For testing, call loadLanguages directly and skip the
// messages.
func scheduleLoadLanguages() {
fmt.Print("Loading languages\n")
n := loadLanguages()
fmt.Printf("Loaded %d languages\n", n)
}
func serve() {
http.HandleFunc("/", rootHandler)
http.HandleFunc("/view/", makeHandler(viewHandler))
http.HandleFunc("/edit/", makeHandler(editHandler))
http.HandleFunc("/save/", makeHandler(saveHandler))
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)
fmt.Print("Indexing all pages\n")
loadIndex()
go scheduleLoadIndex()
go scheduleLoadLanguages()
port := getPort()
fmt.Printf("Serving a wiki on port %s\n", port)
http.ListenAndServe(":"+port, nil)
}
func main() {
if len(os.Args) == 1 {
serve()
} else {
commands()
}
}

67
wiki_test.go Normal file
View File

@@ -0,0 +1,67 @@
package main
import (
"bytes"
"github.com/stretchr/testify/assert"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
)
// HTTPHeaders is a helper that returns HTTP headers of the response. It returns
// nil if building a new request fails.
func HTTPHeaders(handler http.HandlerFunc, method, url string, values url.Values, header string) []string {
w := httptest.NewRecorder()
req, err := http.NewRequest(method, url+"?"+values.Encode(), nil)
if err != nil {
return nil
}
handler(w, req)
return w.Result().Header[header]
}
// 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.
func HTTPRedirectTo(t *testing.T, handler http.HandlerFunc, method, url string, values url.Values, destination string) bool {
w := httptest.NewRecorder()
var req *http.Request
var err error
if method == http.MethodPost {
body := strings.NewReader(values.Encode())
req, err = http.NewRequest(method, url, body)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
} else {
req, err = http.NewRequest(method, url+"?"+values.Encode(), nil)
}
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+"?"+values.Encode(), 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+"?"+values.Encode(), headers)
return isRedirectCode
}
// 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
}