forked from mirror/oddmu
Compare commits
78 Commits
v0.5
...
trigram-se
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1cc6771d58 | ||
|
|
8183d39eb3 | ||
|
|
ef3a9d5e9b | ||
|
|
58ba30e1b4 | ||
|
|
806ee6d270 | ||
|
|
2d2439c0c3 | ||
|
|
f549dd9ea6 | ||
|
|
e5dcd068d2 | ||
|
|
aa516bbcc0 | ||
|
|
be4c1ba4e5 | ||
|
|
d8138e92c4 | ||
|
|
09ea5da1e5 | ||
|
|
95d3573b10 | ||
|
|
876a170899 | ||
|
|
22337d93c4 | ||
|
|
2fa7a8855b | ||
|
|
528ae1c54b | ||
|
|
17b519071f | ||
|
|
ca59a1ae5f | ||
|
|
fbe105bef8 | ||
|
|
1f07ad867a | ||
|
|
34b2afad94 | ||
|
|
b274e6ba55 | ||
|
|
856f1ac235 | ||
|
|
58a2f8b841 | ||
|
|
b87302b683 | ||
|
|
243dd66317 | ||
|
|
3c1dfce4ac | ||
|
|
8319a6438f | ||
|
|
9ee2af6093 | ||
|
|
153a179d92 | ||
|
|
d9797aac75 | ||
|
|
005500457e | ||
|
|
2635d5f852 | ||
|
|
a79f4558b6 | ||
|
|
d1c2b8e27c | ||
|
|
dd939e2c86 | ||
|
|
475c7071ba | ||
|
|
16b475ea7f | ||
|
|
0a7eaa455a | ||
|
|
4cad4a988a | ||
|
|
cc58980ec0 | ||
|
|
068bc21eea | ||
|
|
f60cd09267 | ||
|
|
92a52d2c97 | ||
|
|
7f0b371570 | ||
|
|
a44d903775 | ||
|
|
1ca8e6f3aa | ||
|
|
93197f94bf | ||
|
|
a7861edbad | ||
|
|
d4090ab146 | ||
|
|
4da0ba8d94 | ||
|
|
941ceeaf6c | ||
|
|
c2cf2b121c | ||
|
|
383bb88218 | ||
|
|
1b8c590ced | ||
|
|
ab014a1ba8 | ||
|
|
cd661a2357 | ||
|
|
2060d323a6 | ||
|
|
30df5fb9e1 | ||
|
|
21ec558a2b | ||
|
|
22db61c73a | ||
|
|
3bdd05f083 | ||
|
|
154b6805c4 | ||
|
|
a3373fec6f | ||
|
|
ebaadc111a | ||
|
|
afa9907863 | ||
|
|
b57afc17ca | ||
|
|
ad010249d6 | ||
|
|
b86eee7136 | ||
|
|
55be27b2d1 | ||
|
|
565a3b2831 | ||
|
|
302da8b212 | ||
|
|
3f69eadafc | ||
|
|
78c640278d | ||
|
|
285574d262 | ||
|
|
80e2522f4a | ||
|
|
471cd3c6ec |
24
Makefile
24
Makefile
@@ -10,11 +10,17 @@ help:
|
||||
@echo make test
|
||||
@echo " runs the tests"
|
||||
@echo
|
||||
@echo make upload
|
||||
@echo " this is how I upgrade my server"
|
||||
@echo make docs
|
||||
@echo " create man pages from text files"
|
||||
@echo
|
||||
@echo go build
|
||||
@echo " just build it"
|
||||
@echo
|
||||
@echo make install
|
||||
@echo " install the files to ~/.local"
|
||||
@echo
|
||||
@echo make upload
|
||||
@echo " this is how I upgrade my server"
|
||||
|
||||
run:
|
||||
go run .
|
||||
@@ -24,5 +30,15 @@ test:
|
||||
|
||||
upload:
|
||||
go build
|
||||
rsync --itemize-changes --archive oddmu oddmu.service *.html README.md sibirocobombus.root:/home/oddmu/
|
||||
ssh sibirocobombus.root "systemctl restart oddmu"
|
||||
rsync --itemize-changes --archive oddmu sibirocobombus.root:/home/oddmu/
|
||||
ssh sibirocobombus.root "systemctl restart oddmu; systemctl restart alex"
|
||||
@echo Changes to the template files need careful consideration
|
||||
|
||||
docs:
|
||||
cd man; make
|
||||
|
||||
install:
|
||||
make docs
|
||||
for n in 1 5 7; do install -D -t $$HOME/.local/share/man/man$$n man/*.$$n; done
|
||||
go build
|
||||
install -D -t $$HOME/.local/bin oddmu
|
||||
|
||||
419
README.md
419
README.md
@@ -13,109 +13,17 @@ repository that results from these discussions.
|
||||
The wiki lists no recent changes. The expectation is that the people
|
||||
that care were involved in the discussions beforehand.
|
||||
|
||||
The wiki also produces no feed. The assumption is that announcements
|
||||
are made on social media: blogs, news aggregators, discussion forums,
|
||||
the fediverse, but humans. There is no need for bots.
|
||||
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 `[[like this]]` and hashtags `#Like_This`.
|
||||
|
||||
As you'll see below, the idea is that the webserver handles as many
|
||||
tasks as possible. It logs requests, does rate limiting, handles
|
||||
encryption, gets the certificates, and so on. The web server acts as a
|
||||
reverse proxy and the wiki ends up being a content management system
|
||||
with almost no structure – or endless malleability, depending on your
|
||||
point of view.
|
||||
This wiki uses the [lingua](github.com/pemistahl/lingua-go) library to
|
||||
detect languages in order to get hyphenation right.
|
||||
|
||||
And last but not least: µ is the letter mu, so Oddµ is usually written
|
||||
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:
|
||||
|
||||
* emphasis markers inside words are ignored
|
||||
* tables are supported
|
||||
* fenced code blocks are supported
|
||||
* autolinking of "naked" URLs are supported
|
||||
* strikethrough using two tildes is supported (`~~like this~~`)
|
||||
* it is strict about prefix heading rules
|
||||
* you can specify an id for headings (`{#id}`)
|
||||
* trailing backslashes turn into line breaks
|
||||
* 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
|
||||
Name | Age
|
||||
--------|------
|
||||
Bob ||
|
||||
Alice | 23
|
||||
========|======
|
||||
Total | 23
|
||||
```
|
||||
|
||||
A definition list:
|
||||
|
||||
```text
|
||||
Cat
|
||||
: Fluffy animal everyone likes
|
||||
|
||||
Internet
|
||||
: Vector of transmission for pictures of cats
|
||||
```
|
||||
|
||||
## 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 first change you should make is to replace the email address in
|
||||
`view.html`. 😄
|
||||
|
||||
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.
|
||||
|
||||
`{{.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`).
|
||||
|
||||
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`:
|
||||
|
||||
```sh
|
||||
curl --form body="Did you bring a towel?" \
|
||||
http://localhost:8080/save/welcome
|
||||
```
|
||||
|
||||
## Non-English hyphenation
|
||||
|
||||
Automatic hyphenation by the browser requires two things: The style
|
||||
sheet must indicate `hyphen: auto` for an HTML element such as `body`,
|
||||
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.
|
||||
This wiki uses the standard
|
||||
[html/template](https://pkg.go.dev/html/template) library to generate
|
||||
HTML.
|
||||
|
||||
## Building
|
||||
|
||||
@@ -123,7 +31,7 @@ non-English languages.
|
||||
go build
|
||||
```
|
||||
|
||||
## Test
|
||||
## Running
|
||||
|
||||
The working directory is where pages are saved and where templates are
|
||||
loaded from. You need a copy of the template files in this directory.
|
||||
@@ -134,306 +42,7 @@ go run .
|
||||
```
|
||||
|
||||
The program serves the local directory as a wiki on port 8080. Point
|
||||
your browser to http://localhost:8080/ to get started. This is
|
||||
equivalent to http://localhost:8080/view/index – the first page
|
||||
you'll create, most likely.
|
||||
|
||||
If you ran it in the source directory, try
|
||||
http://localhost:8080/view/README – this serves the README file you're
|
||||
currently reading.
|
||||
|
||||
## Deploying it using systemd
|
||||
|
||||
As root, on your server:
|
||||
|
||||
```sh
|
||||
adduser --system --home /home/oddmu oddmu
|
||||
```
|
||||
|
||||
Copy all the files into `/home/oddmu` to your server: `oddmu`,
|
||||
`oddmu.service`, `view.html` and `edit.html`.
|
||||
|
||||
Edit the `oddmu.service` file. These are the three lines you most
|
||||
likely have to take care of:
|
||||
|
||||
```
|
||||
ExecStart=/home/oddmu/oddmu
|
||||
WorkingDirectory=/home/oddmu
|
||||
Environment="ODDMU_PORT=8080"
|
||||
```
|
||||
|
||||
Install the service file and enable it:
|
||||
|
||||
```sh
|
||||
ln -s /home/oddmu/oddmu.service /etc/systemd/system/
|
||||
systemctl enable --now oddmu
|
||||
```
|
||||
|
||||
Check the log:
|
||||
|
||||
```sh
|
||||
journalctl --unit oddmu
|
||||
```
|
||||
|
||||
Follow the log:
|
||||
|
||||
```sh
|
||||
journalctl --follow --unit oddmu
|
||||
```
|
||||
|
||||
Edit the first page using `lynx`:
|
||||
|
||||
```sh
|
||||
lynx http://localhost:8080/view/index
|
||||
```
|
||||
|
||||
## Web server setup
|
||||
|
||||
HTTPS is not part of the wiki. You probably want to configure this in
|
||||
your webserver. I guess you could use stunnel, too. If you're using
|
||||
Apache, you might have set up a site like I did, below. In my case,
|
||||
that'd be `/etc/apache2/sites-enabled/500-transjovian.conf`:
|
||||
|
||||
```apache
|
||||
MDomain transjovian.org
|
||||
MDCertificateAgreement accepted
|
||||
|
||||
<VirtualHost *:80>
|
||||
ServerName transjovian.org
|
||||
RewriteEngine on
|
||||
RewriteRule ^/(.*) https://%{HTTP_HOST}/$1 [redirect]
|
||||
</VirtualHost>
|
||||
<VirtualHost *:443>
|
||||
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]
|
||||
</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.
|
||||
|
||||
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.
|
||||
|
||||
Restart the server, gracefully:
|
||||
|
||||
```
|
||||
apachectl graceful
|
||||
```
|
||||
|
||||
## Access
|
||||
|
||||
Access control is not part of the wiki. By default, the wiki is
|
||||
editable by all. This is most likely not what you want unless you're
|
||||
running it stand-alone, unconnected to the Internet.
|
||||
|
||||
You probably want to configure this in your webserver. If you're using
|
||||
Apache, you might have set up a site like the following.
|
||||
|
||||
Create a new password file called `.htpasswd` and add the user "alex":
|
||||
|
||||
```sh
|
||||
cd /home/oddmu
|
||||
htpasswd -c .htpasswd alex
|
||||
```
|
||||
|
||||
To add more users, don't use the `-c` option or you will overwrite it!
|
||||
|
||||
To add another user:
|
||||
|
||||
```sh
|
||||
htpasswd .htpasswd berta
|
||||
```
|
||||
|
||||
To delete remove a user:
|
||||
|
||||
```sh
|
||||
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:
|
||||
|
||||
```apache
|
||||
<LocationMatch "^/(edit|save)/">
|
||||
AuthType Basic
|
||||
AuthName "Password Required"
|
||||
AuthUserFile /home/oddmu/.htpasswd
|
||||
Require valid-user
|
||||
</LocationMatch>
|
||||
```
|
||||
|
||||
## Serve static files
|
||||
|
||||
If you want to serve static files as well, add a document root to your
|
||||
webserver configuration. Using Apache, for example:
|
||||
|
||||
```apache
|
||||
DocumentRoot /home/oddmu/static
|
||||
<Directory /home/oddmu/static>
|
||||
Require all granted
|
||||
</Directory>
|
||||
```
|
||||
|
||||
Create this directory, making sure to give it a permission that your
|
||||
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.
|
||||
|
||||
```text
|
||||
User-agent: *
|
||||
Disallow: /
|
||||
```
|
||||
|
||||
You site now serves `/robots.txt` without interfering with the wiki,
|
||||
and without needing a wiki page.
|
||||
|
||||
[Wikipedia](https://en.wikipedia.org/wiki/Robot_exclusion_standard)
|
||||
has more information.
|
||||
|
||||
## Different logins for different access rights
|
||||
|
||||
What if you have a site with various subdirectories and each
|
||||
subdirectory is for a different group of friends? You can set this up
|
||||
using your webserver. One way to do this is to require specific
|
||||
usernames (which must have a password in the password file mentioned
|
||||
above.
|
||||
|
||||
This requires a valid login by the user "alex" or "berta":
|
||||
|
||||
```apache
|
||||
<LocationMatch "^/(edit|save)/intetebi/">
|
||||
Require user alex berta
|
||||
</LocationMatch>
|
||||
```
|
||||
|
||||
## Private wikis
|
||||
|
||||
Based on the above, you can prevent people from reading the wiki, too.
|
||||
The `LocationMatch` must cover the `/view/` URLs. In order to protect
|
||||
*everything*, use a [Location directive](https://httpd.apache.org/docs/current/mod/core.html#location)
|
||||
that matches everything:
|
||||
|
||||
```apache
|
||||
<Location />
|
||||
AuthType Basic
|
||||
AuthName "Password Required"
|
||||
AuthUserFile /home/oddmu/.htpasswd
|
||||
Require valid-user
|
||||
</Location>
|
||||
```
|
||||
|
||||
## Customization (with recompilation)
|
||||
|
||||
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);
|
||||
}
|
||||
```
|
||||
|
||||
## Understanding search
|
||||
|
||||
The index indexes trigrams. Each group of three characters is a
|
||||
trigram. A document with content "This is a test" is turned to lower
|
||||
case and indexed under the trigrams "thi", "his", "is ", "s i", " is",
|
||||
"is ", "s a", " a ", "a t", " te", "tes", "est".
|
||||
|
||||
Each query is split into words and then processed the same way. A
|
||||
query with the words "this test" is turned to lower case and produces
|
||||
the trigrams "thi", "his", "tes", "est". This means that the word
|
||||
order is not considered when searching for documents.
|
||||
|
||||
This also means that there is no stemming. Searching for "testing"
|
||||
won't find "This is a test" because there are no matches for the
|
||||
trigrams "sti", "tin", "ing".
|
||||
|
||||
These trigrams are looked up in the index, resulting in the list of
|
||||
documents. Each document found is then scored. Each of the following
|
||||
increases the score by one point:
|
||||
|
||||
- the entire phrase matches
|
||||
- a word matches
|
||||
- a word matches at the beginning of a word
|
||||
- a word matches at the end of a word
|
||||
- a word matches as a whole word
|
||||
|
||||
A document with content "This is a test" when searched with the phrase
|
||||
"this test" therefore gets a score of 8: the entire phrase does not
|
||||
match but each word gets four points.
|
||||
|
||||
Trigrams are sometimes strange: In a text containing the words "main"
|
||||
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.
|
||||
|
||||
## Limitations
|
||||
|
||||
Page titles are filenames with `.md` appended. If your filesystem
|
||||
cannot handle it, it can't be a page name.
|
||||
|
||||
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.
|
||||
your browser to http://localhost:8080/ to use it.
|
||||
|
||||
## Bugs
|
||||
|
||||
@@ -443,9 +52,3 @@ If you spot any, [contact](https://alexschroeder.ch/wiki/Contact) me.
|
||||
|
||||
[Writing Web Applications](https://golang.org/doc/articles/wiki/)
|
||||
provided the initial code for this wiki.
|
||||
|
||||
For the proxy stuff, see
|
||||
[Apache: mod_proxy](https://httpd.apache.org/docs/current/mod/mod_proxy.html).
|
||||
|
||||
For the usernames and password stuff, see
|
||||
[Apache: Authentication and Authorization](https://httpd.apache.org/docs/current/howto/auth.html).
|
||||
|
||||
13
TODO.md
13
TODO.md
@@ -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.
|
||||
|
||||
161
accounts.go
Normal file
161
accounts.go
Normal file
@@ -0,0 +1,161 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"github.com/gomarkdown/markdown/ast"
|
||||
"github.com/gomarkdown/markdown/parser"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// useWebfinger indicates whether Oddmu looks up the profile pages of
|
||||
// fediverse accounts. To enable this, set the environment variable
|
||||
// ODDMU_WEBFINGER to "1".
|
||||
var useWebfinger = false
|
||||
|
||||
// Accounts contains the map used to set the usernames. Make sure to
|
||||
// lock and unlock as appropriate.
|
||||
type Accounts struct {
|
||||
sync.RWMutex
|
||||
|
||||
// uris is a map, mapping account names likes
|
||||
// "@alex@alexschroeder.ch" to URIs like
|
||||
// "https://social.alexschroeder.ch/@alex".
|
||||
uris map[string]string
|
||||
}
|
||||
|
||||
// accounts holds the global mapping of accounts to profile URIs.
|
||||
var accounts Accounts
|
||||
|
||||
// initAccounts sets up the accounts map. This is called once at
|
||||
// startup and therefore does not need to be locked. On ever restart,
|
||||
// this map starts empty and is slowly repopulated as pages are
|
||||
// visited.
|
||||
func initAccounts() {
|
||||
if os.Getenv("ODDMU_WEBFINGER") == "1" {
|
||||
accounts.uris = make(map[string]string)
|
||||
useWebfinger = true
|
||||
}
|
||||
}
|
||||
|
||||
// account links a social media account like @account@domain to a
|
||||
// profile page like https://domain/user/account. Any account seen for
|
||||
// the first time uses a best guess profile URI. It is also looked up
|
||||
// using webfinger, in parallel. See lookUpAccountUri. If the lookup
|
||||
// succeeds, the best guess is replaced with the new URI so on
|
||||
// subsequent requests, the URI is correct.
|
||||
func account(p *parser.Parser, data []byte, offset int) (int, ast.Node) {
|
||||
data = data[offset:]
|
||||
i := 1 // skip @ of username
|
||||
n := len(data)
|
||||
d := 0
|
||||
for i < n && (data[i] >= 'a' && data[i] <= 'z' ||
|
||||
data[i] >= 'A' && data[i] <= 'Z' ||
|
||||
data[i] >= '0' && data[i] <= '9' ||
|
||||
data[i] == '@' ||
|
||||
data[i] == '.' ||
|
||||
data[i] == '-') {
|
||||
if data[i] == '@' {
|
||||
if d != 0 {
|
||||
// more than one @ is invalid
|
||||
return 0, nil
|
||||
} else {
|
||||
d = i + 1 // skip @ of domain
|
||||
}
|
||||
}
|
||||
i++
|
||||
}
|
||||
for i > 1 && (data[i-1] == '.' ||
|
||||
data[i-1] == '-') {
|
||||
i--
|
||||
}
|
||||
if i == 0 || d == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
user := data[0 : d-1] // includes @
|
||||
domain := data[d:i] // excludes @
|
||||
account := data[1:i] // excludes @
|
||||
accounts.RLock()
|
||||
uri, ok := accounts.uris[string(account)]
|
||||
defer accounts.RUnlock()
|
||||
if !ok {
|
||||
fmt.Printf("Looking up %s\n", account)
|
||||
uri = "https://" + string(domain) + "/users/" + string(user[1:])
|
||||
accounts.uris[string(account)] = uri // prevent more lookings
|
||||
go lookUpAccountUri(string(account), string(domain))
|
||||
}
|
||||
link := &ast.Link{
|
||||
Destination: []byte(uri),
|
||||
Title: data[0:i],
|
||||
}
|
||||
ast.AppendChild(link, &ast.Text{Leaf: ast.Leaf{Literal: data[0 : d-1]}})
|
||||
return i, link
|
||||
}
|
||||
|
||||
// lookUpAccountUri is called for accounts that haven't been seen
|
||||
// before. It calls webfinger and parses the JSON. If possible, it
|
||||
// extracts the link to the profile page and replaces the entry in
|
||||
// accounts.
|
||||
func lookUpAccountUri(account, domain string) {
|
||||
uri := "https://" + domain + "/.well-known/webfinger"
|
||||
resp, err := http.Get(uri + "?resource=acct:" + account)
|
||||
if err != nil {
|
||||
fmt.Printf("Failed to look up %s: %s\n", account, err)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
fmt.Printf("Failed to read from %s: %s\n", account, err)
|
||||
return
|
||||
}
|
||||
var wf WebFinger
|
||||
err = json.Unmarshal([]byte(body), &wf)
|
||||
if err != nil {
|
||||
fmt.Printf("Failed to parse the JSON from %s: %s\n", account, err)
|
||||
return
|
||||
}
|
||||
uri, err = parseWebFinger(body)
|
||||
if err != nil {
|
||||
fmt.Printf("Could not find profile URI for %s: %s\n", account, err)
|
||||
}
|
||||
fmt.Printf("Found profile for %s: %s\n", account, uri)
|
||||
accounts.Lock()
|
||||
defer accounts.Unlock()
|
||||
accounts.uris[account] = uri
|
||||
}
|
||||
|
||||
// Link a link in the WebFinger JSON.
|
||||
type Link struct {
|
||||
Rel string `json:"rel"`
|
||||
Type string `json:"type"`
|
||||
Href string `json:"href"`
|
||||
}
|
||||
|
||||
// WebFinger is a structure used to unmarshall JSON.
|
||||
type WebFinger struct {
|
||||
Subject string `json:"subject"`
|
||||
Aliases []string `json:"aliases"`
|
||||
Links []Link `json:"links"`
|
||||
}
|
||||
|
||||
// parseWebFinger parses the web finger JSON and returns the profile
|
||||
// page URI. For unmarshalling the JSON, it uses the Link and
|
||||
// WebFinger structs.
|
||||
func parseWebFinger(body []byte) (string, error) {
|
||||
var wf WebFinger
|
||||
err := json.Unmarshal(body, &wf)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
for _, link := range wf.Links {
|
||||
if link.Rel == "http://webfinger.net/rel/profile-page" &&
|
||||
link.Type == "text/html" {
|
||||
return link.Href, nil
|
||||
}
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
45
accounts_test.go
Normal file
45
accounts_test.go
Normal file
@@ -0,0 +1,45 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"github.com/stretchr/testify/assert"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// This causes network access!
|
||||
// func TestPageAccount(t *testing.T) {
|
||||
// initAccounts()
|
||||
// p := &Page{Body: []byte(`@alex, @alex@alexschroeder.ch said`)}
|
||||
// p.renderHtml()
|
||||
// r := `<p>@alex, <a href="https://alexschroeder.ch/users/alex" rel="nofollow">@alex</a> said</p>
|
||||
// `
|
||||
// assert.Equal(t, r, string(p.Html))
|
||||
// }
|
||||
|
||||
func TestWebfingerParsing(t *testing.T) {
|
||||
body := []byte(`{
|
||||
"subject": "acct:Gargron@mastodon.social",
|
||||
"aliases": [
|
||||
"https://mastodon.social/@Gargron",
|
||||
"https://mastodon.social/users/Gargron"
|
||||
],
|
||||
"links": [
|
||||
{
|
||||
"rel": "http://webfinger.net/rel/profile-page",
|
||||
"type": "text/html",
|
||||
"href": "https://mastodon.social/@Gargron"
|
||||
},
|
||||
{
|
||||
"rel": "self",
|
||||
"type": "application/activity+json",
|
||||
"href": "https://mastodon.social/users/Gargron"
|
||||
},
|
||||
{
|
||||
"rel": "http://ostatus.org/schema/1.0/subscribe",
|
||||
"template": "https://mastodon.social/authorize_interaction?uri={uri}"
|
||||
}
|
||||
]
|
||||
}`)
|
||||
uri, err := parseWebFinger(body)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "https://mastodon.social/@Gargron", uri)
|
||||
}
|
||||
21
add.html
Normal file
21
add.html
Normal 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
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")
|
||||
})
|
||||
}
|
||||
15
concurrency_test.go
Normal file
15
concurrency_test.go
Normal 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))
|
||||
}
|
||||
@@ -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
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")
|
||||
})
|
||||
}
|
||||
73
feed.go
Normal file
73
feed.go
Normal file
@@ -0,0 +1,73 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"github.com/gomarkdown/markdown"
|
||||
"github.com/gomarkdown/markdown/ast"
|
||||
"github.com/gomarkdown/markdown/parser"
|
||||
"html/template"
|
||||
"bytes"
|
||||
"os"
|
||||
"path"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Item struct {
|
||||
Page
|
||||
Date string
|
||||
}
|
||||
|
||||
type Feed struct {
|
||||
Item
|
||||
Items []Item
|
||||
}
|
||||
|
||||
func feed(p *Page, ti time.Time) *Feed {
|
||||
feed := new(Feed)
|
||||
feed.Name = p.Name
|
||||
feed.Title = p.Title
|
||||
feed.Date = ti.Format(time.RFC1123Z)
|
||||
parser := parser.New()
|
||||
doc := markdown.Parse(p.Body, parser)
|
||||
items := make([]Item, 0)
|
||||
inListItem := false
|
||||
ast.WalkFunc(doc, func(node ast.Node, entering bool) ast.WalkStatus {
|
||||
// set the flag if we're in a list item
|
||||
listItem, ok := node.(*ast.ListItem)
|
||||
if ok && listItem.BulletChar == '*' {
|
||||
inListItem = entering
|
||||
return ast.GoToNext
|
||||
}
|
||||
// if we're not in a list item, continue
|
||||
if !inListItem || !entering {
|
||||
return ast.GoToNext
|
||||
}
|
||||
// if we're in a link and it's local
|
||||
link, ok := node.(*ast.Link)
|
||||
if !ok || bytes.Contains(link.Destination, []byte("//")) {
|
||||
return ast.GoToNext
|
||||
}
|
||||
name := path.Join(path.Dir(p.Name), string(link.Destination))
|
||||
fi, err := os.Stat(name + ".md")
|
||||
if err != nil {
|
||||
return ast.GoToNext
|
||||
}
|
||||
p2, err := loadPage(name)
|
||||
if err != nil {
|
||||
return ast.GoToNext
|
||||
}
|
||||
p2.handleTitle(false)
|
||||
p2.renderHtml()
|
||||
it := Item{Date: fi.ModTime().Format(time.RFC1123Z)}
|
||||
it.Title = p2.Title
|
||||
it.Name = p2.Name
|
||||
it.Html = template.HTML(template.HTMLEscaper(p2.Html))
|
||||
it.Hashtags = p2.Hashtags
|
||||
items = append(items, it)
|
||||
if len(items) >= 10 {
|
||||
return ast.Terminate
|
||||
}
|
||||
return ast.GoToNext
|
||||
})
|
||||
feed.Items = items
|
||||
return feed
|
||||
}
|
||||
28
feed.html
Normal file
28
feed.html
Normal file
@@ -0,0 +1,28 @@
|
||||
<rss xmlns:atom="http://www.w3.org/2005/Atom" version="2.0">
|
||||
<channel>
|
||||
<docs>http://blogs.law.harvard.edu/tech/rss</docs>
|
||||
<title>{{.Title}}</title>
|
||||
<link>https://example.org/</link>
|
||||
<managingEditor>you@example.org (Your Name)</managingEditor>
|
||||
<webMaster>you@example.org (Your Name)</webMaster>
|
||||
<atom:link href="https://example.org/view/{{.Name}}.rss" rel="self" type="application/rss+xml"/>
|
||||
<description>This is the digital garden of Your Name.</description>
|
||||
<image>
|
||||
<url>https://example.org/view/logo.jpg</url>
|
||||
<title>{{.Title}}</title>
|
||||
<link>https://example.org/</link>
|
||||
</image>
|
||||
{{range .Items}}
|
||||
<item>
|
||||
<title>{{.Title}}</title>
|
||||
<link>https://example.org/view/{{.Name}}</link>
|
||||
<guid>https://example.org/view/{{.Name}}</guid>
|
||||
<description>{{.Html}}</description>
|
||||
<pubDate>{{.Date}}</pubDate>
|
||||
{{range .Hashtags}}
|
||||
<category>{{.}}</category>
|
||||
{{end}}
|
||||
</item>
|
||||
{{end}}
|
||||
</channel>
|
||||
</rss>
|
||||
55
feed_test.go
Normal file
55
feed_test.go
Normal file
@@ -0,0 +1,55 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"github.com/stretchr/testify/assert"
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestFeed(t *testing.T) {
|
||||
assert.Contains(t,
|
||||
assert.HTTPBody(makeHandler(viewHandler, true), "GET", "/view/index.rss", nil),
|
||||
"Welcome to Oddµ")
|
||||
}
|
||||
|
||||
// wipes testdata
|
||||
func TestFeedItems(t *testing.T) {
|
||||
_ = os.RemoveAll("testdata")
|
||||
index.load()
|
||||
|
||||
p1 := &Page{Name: "testdata/cactus", Body: []byte(`# Cactus
|
||||
Green head and white hair
|
||||
A bench in the evening sun
|
||||
Unmoved by the news
|
||||
|
||||
#Succulent`)}
|
||||
p1.save()
|
||||
|
||||
p2 := &Page{Name: "testdata/dragon", Body: []byte(`# Dragon
|
||||
My palm tree grows straight
|
||||
Up and up to touch the sky
|
||||
Ignoring the roof
|
||||
|
||||
#Palmtree`)}
|
||||
p2.save()
|
||||
|
||||
p3 := &Page{Name: "testdata/plants", Body: []byte(`# Plants
|
||||
Writing poems about plants.
|
||||
|
||||
* [My Cactus](cactus)
|
||||
* [My Dragon Tree](dragon)`)}
|
||||
p3.save()
|
||||
|
||||
body := assert.HTTPBody(makeHandler(viewHandler, true), "GET", "/view/testdata/plants.rss", nil)
|
||||
assert.Contains(t, body, "<title>Plants</title>")
|
||||
assert.Contains(t, body, "<title>Cactus</title>")
|
||||
assert.Contains(t, body, "<title>Dragon</title>")
|
||||
assert.Contains(t, body, "<h1>Cactus</h1>")
|
||||
assert.Contains(t, body, "<h1>Dragon</h1>")
|
||||
assert.Contains(t, body, "<category>Succulent</category>")
|
||||
assert.Contains(t, body, "<category>Palmtree</category>")
|
||||
|
||||
t.Cleanup(func() {
|
||||
_ = os.RemoveAll("testdata")
|
||||
})
|
||||
}
|
||||
14
go.mod
14
go.mod
@@ -3,17 +3,27 @@ module alexschroeder.ch/cgit/oddmu
|
||||
go 1.21.0
|
||||
|
||||
require (
|
||||
github.com/anthonynsimon/bild v0.13.0
|
||||
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/google/subcommands v1.2.0
|
||||
github.com/hexops/gotextdiff v1.0.3
|
||||
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/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/kr/pretty v0.1.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/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
57
go.sum
57
go.sum
@@ -1,24 +1,77 @@
|
||||
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/google/subcommands v1.2.0 h1:vWQspBTo2nEqTUFita5/KeEWlUL8kQObDFbub/EN9oE=
|
||||
github.com/google/subcommands v1.2.0/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk=
|
||||
github.com/gorilla/css v1.0.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/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
|
||||
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
|
||||
github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=
|
||||
github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/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/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
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=
|
||||
|
||||
56
html_cmd.go
Normal file
56
html_cmd.go
Normal file
@@ -0,0 +1,56 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"github.com/google/subcommands"
|
||||
"io"
|
||||
"os"
|
||||
)
|
||||
|
||||
type htmlCmd struct {
|
||||
useTemplate bool
|
||||
}
|
||||
|
||||
func (*htmlCmd) Name() string { return "html" }
|
||||
func (*htmlCmd) Synopsis() string { return "Render a page as HTML." }
|
||||
func (*htmlCmd) Usage() string {
|
||||
return `html [-view] <page name>:
|
||||
Render a page as HTML.
|
||||
`
|
||||
}
|
||||
|
||||
func (cmd *htmlCmd) SetFlags(f *flag.FlagSet) {
|
||||
f.BoolVar(&cmd.useTemplate, "view", false, "Use the 'view.html' template.")
|
||||
}
|
||||
|
||||
func (cmd *htmlCmd) Execute(_ context.Context, f *flag.FlagSet, _ ...interface{}) subcommands.ExitStatus {
|
||||
return htmlCli(os.Stdout, cmd.useTemplate, f.Args())
|
||||
}
|
||||
|
||||
func htmlCli(w io.Writer, useTemplate bool, args []string) subcommands.ExitStatus {
|
||||
for _, arg := range args {
|
||||
p, err := loadPage(arg)
|
||||
if err != nil {
|
||||
fmt.Fprintf(w, "Cannot load %s: %s\n", arg, err)
|
||||
return subcommands.ExitFailure
|
||||
}
|
||||
initAccounts()
|
||||
if useTemplate {
|
||||
p.handleTitle(true)
|
||||
p.renderHtml()
|
||||
t := "view.html"
|
||||
err := templates.ExecuteTemplate(w, t, p)
|
||||
if err != nil {
|
||||
fmt.Fprintf(w, "Cannot execute %s template for %s: %s\n", t, arg, err)
|
||||
return subcommands.ExitFailure
|
||||
}
|
||||
} else {
|
||||
// do not handle title
|
||||
p.renderHtml()
|
||||
fmt.Fprintln(w, p.Html)
|
||||
}
|
||||
}
|
||||
return subcommands.ExitSuccess
|
||||
}
|
||||
22
html_cmd_test.go
Normal file
22
html_cmd_test.go
Normal file
@@ -0,0 +1,22 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"testing"
|
||||
"github.com/google/subcommands"
|
||||
)
|
||||
|
||||
func TestHtmlCmd(t *testing.T) {
|
||||
b := new(bytes.Buffer)
|
||||
s := htmlCli(b, false, []string{"index"})
|
||||
assert.Equal(t, subcommands.ExitSuccess, s)
|
||||
r := `<h1>Welcome to Oddµ</h1>
|
||||
|
||||
<p>Hello! 🙃</p>
|
||||
|
||||
<p>Check out the <a href="README" rel="nofollow">README</a>.</p>
|
||||
|
||||
`
|
||||
assert.Equal(t, r, b.String())
|
||||
}
|
||||
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 := "#like_this"
|
||||
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
58
languages.go
Normal 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
50
languages_test.go
Normal 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)
|
||||
}
|
||||
5
man/Makefile
Normal file
5
man/Makefile
Normal file
@@ -0,0 +1,5 @@
|
||||
docs: oddmu.1 oddmu.5 oddmu-templates.5 oddmu-apache.5 oddmu.service.5 oddmu-replace.1 \
|
||||
oddmu-search.1 oddmu-search.7 oddmu-html.1
|
||||
|
||||
oddmu%: oddmu%.txt
|
||||
scdoc < $< > $@
|
||||
220
man/oddmu-apache.5
Normal file
220
man/oddmu-apache.5
Normal file
@@ -0,0 +1,220 @@
|
||||
.\" Generated by scdoc 1.11.2
|
||||
.\" Complete documentation for this program is not available as a GNU info page
|
||||
.ie \n(.g .ds Aq \(aq
|
||||
.el .ds Aq '
|
||||
.nh
|
||||
.ad l
|
||||
.\" Begin generated content:
|
||||
.TH "ODDMU-APACHE" "5" "2023-09-18"
|
||||
.PP
|
||||
.SH NAME
|
||||
.PP
|
||||
oddmu-apache - how to setup Apache as a reverse proxy for Oddmu
|
||||
.PP
|
||||
.SS DESCRIPTION
|
||||
.PP
|
||||
The oddmu program serves the current working directory as a wiki on
|
||||
port 8080.\& This is an unpriviledged port so an ordinary use account
|
||||
can do this.\&
|
||||
.PP
|
||||
The best way to protect the wiki against vandalism and spam is to use
|
||||
a regular web server as reverse proxy.\& This page explains how to setup
|
||||
Apache on Debian to do this.\&
|
||||
.PP
|
||||
.SS CONFIGURATION
|
||||
.PP
|
||||
HTTPS is not part of the wiki.\& You probably want to configure this in
|
||||
your webserver.\& I guess you could use stunnel, too.\& If you'\&re using
|
||||
Apache, you can use "mod_md" to manage your domain.\&
|
||||
.PP
|
||||
In the example below, the site is configured in a file called
|
||||
"/etc/apache2/sites-available/500-transjovian.\&conf" and a link poins
|
||||
there from "/etc/apache2/sites-enabled".\& Create this link using
|
||||
\fIa2ensite\fR(1).\&
|
||||
.PP
|
||||
.nf
|
||||
.RS 4
|
||||
MDomain transjovian\&.org
|
||||
MDCertificateAgreement accepted
|
||||
|
||||
<VirtualHost *:80>
|
||||
ServerName transjovian\&.org
|
||||
RewriteEngine on
|
||||
RewriteRule ^/(\&.*) https://%{HTTP_HOST}/$1 [redirect]
|
||||
</VirtualHost>
|
||||
<VirtualHost *:443>
|
||||
ServerAdmin alex@alexschroeder\&.ch
|
||||
ServerName transjovian\&.org
|
||||
SSLEngine on
|
||||
ProxyPassMatch ^/(search|(view|edit|save|add|append|upload|drop)/(\&.*))?$ http://localhost:8080/$1
|
||||
</VirtualHost>
|
||||
.fi
|
||||
.RE
|
||||
.PP
|
||||
First, it manages the domain, getting the necessary certificates.\& It
|
||||
redirects regular HTTP traffic from port 80 to port 443.\& It turns on
|
||||
the SSL engine for port 443.\& It proxies the requests for the wiki to
|
||||
port 8080.\&
|
||||
.PP
|
||||
Thus, this is what happens:
|
||||
.PP
|
||||
\fB The user tells the browser to visit `transjovian.\&org`
|
||||
\fR The browser sends a request for `http://transjovian.\&org` (on port 80)
|
||||
\fB Apache redirects this to `https://transjovian.\&org/` by default (now on port 443)
|
||||
\fR This is proxied to `http://transjovian.\&org:8080/` (no encryption, on port 8080)
|
||||
.PP
|
||||
Restart the server, gracefully:
|
||||
.PP
|
||||
.nf
|
||||
.RS 4
|
||||
apachectl graceful
|
||||
.fi
|
||||
.RE
|
||||
.PP
|
||||
To serve both HTTP and HTTPS, don'\&t redirect from the first virtual
|
||||
host to the second – instead just proxy to the wiki like you did for
|
||||
the second virtual host: use a copy of the "ProxyPassMatch" directive
|
||||
instead of "RewriteEngine on" and "RewriteRule".\&
|
||||
.PP
|
||||
.SS Access
|
||||
.PP
|
||||
Access control is not part of the wiki.\& By default, the wiki is
|
||||
editable by all.\& This is most likely not what you want unless you'\&re
|
||||
running it stand-alone, unconnected to the Internet.\&
|
||||
.PP
|
||||
Create a new password file called ".\&htpasswd" and add the user "alex":
|
||||
.PP
|
||||
.nf
|
||||
.RS 4
|
||||
cd /home/oddmu
|
||||
htpasswd -c \&.htpasswd alex
|
||||
.fi
|
||||
.RE
|
||||
.PP
|
||||
To add more users, don'\&t use the "-c" option or you will overwrite it!\&
|
||||
.PP
|
||||
To add another user:
|
||||
.PP
|
||||
.nf
|
||||
.RS 4
|
||||
htpasswd \&.htpasswd berta
|
||||
.fi
|
||||
.RE
|
||||
.PP
|
||||
To remove a user:
|
||||
.PP
|
||||
.nf
|
||||
.RS 4
|
||||
htpasswd -D \&.htpasswd berta
|
||||
.fi
|
||||
.RE
|
||||
.PP
|
||||
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:
|
||||
.PP
|
||||
.nf
|
||||
.RS 4
|
||||
<LocationMatch "^/(edit|save|add|append|upload|drop)/">
|
||||
AuthType Basic
|
||||
AuthName "Password Required"
|
||||
AuthUserFile /home/oddmu/\&.htpasswd
|
||||
Require valid-user
|
||||
</LocationMatch>
|
||||
.fi
|
||||
.RE
|
||||
.PP
|
||||
.SS Serve static files
|
||||
.PP
|
||||
If you want to serve static files as well, add a document root to your
|
||||
webserver configuration.\& In this case, the document root is the
|
||||
directory where all the data files are.\& Apache will not serve files
|
||||
such as ".\&htpasswd".\&
|
||||
.PP
|
||||
.nf
|
||||
.RS 4
|
||||
DocumentRoot /home/oddmu
|
||||
<Directory /home/oddmu>
|
||||
Require all granted
|
||||
</Directory>
|
||||
.fi
|
||||
.RE
|
||||
.PP
|
||||
Make sure that none of the subdirectories look like the wiki paths
|
||||
"/view/", "/edit/", "/save/", "/add/", "/append/", "/upload/",
|
||||
"/drop/" or "/search".\& For example, create a file called "robots.\&txt"
|
||||
containing the following, telling all robots that they'\&re not welcome.\&
|
||||
.PP
|
||||
.nf
|
||||
.RS 4
|
||||
User-agent: *
|
||||
Disallow: /
|
||||
.fi
|
||||
.RE
|
||||
.PP
|
||||
You site now serves "/robots.\&txt" without interfering with the wiki,
|
||||
and without needing a wiki page.\&
|
||||
.PP
|
||||
.SS Different logins for different access rights
|
||||
.PP
|
||||
What if you have a site with various subdirectories and each
|
||||
subdirectory is for a different group of friends?\& You can set this up
|
||||
using your webserver.\& One way to do this is to require specific
|
||||
usernames (which must have a password in the password file mentioned
|
||||
above.\&
|
||||
.PP
|
||||
This requires a valid login by the user "alex" or "berta":
|
||||
.PP
|
||||
.nf
|
||||
.RS 4
|
||||
<LocationMatch "^/(edit|save|add|append|upload|drop)/intetebi/">
|
||||
Require user alex berta
|
||||
</LocationMatch>
|
||||
.fi
|
||||
.RE
|
||||
.PP
|
||||
.SS Private wikis
|
||||
.PP
|
||||
Based on the above, you can prevent people from \fIreading\fR the wiki.\&
|
||||
The "LocationMatch" must cover the "/view/" URLs in order to protect
|
||||
\fBeverything\fR.\&
|
||||
.PP
|
||||
.nf
|
||||
.RS 4
|
||||
<Location />
|
||||
AuthType Basic
|
||||
AuthName "Password Required"
|
||||
AuthUserFile /home/oddmu/\&.htpasswd
|
||||
Require valid-user
|
||||
</Location>
|
||||
.fi
|
||||
.RE
|
||||
.PP
|
||||
.SS Virtual hosting
|
||||
.PP
|
||||
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.\&
|
||||
.PP
|
||||
.SH SEE ALSO
|
||||
.PP
|
||||
\fIoddmu\fR(1)
|
||||
.PP
|
||||
"Apache Core Features".\&
|
||||
https://httpd.\&apache.\&org/docs/current/mod/core.\&html
|
||||
.PP
|
||||
"Apache: Authentication and Authorization".\&
|
||||
https://httpd.\&apache.\&org/docs/current/howto/auth.\&html
|
||||
.PP
|
||||
"Apache Module mod_proxy".\&
|
||||
https://httpd.\&apache.\&org/docs/current/mod/mod_proxy.\&html
|
||||
.PP
|
||||
"Robot exclusion standard" on Wikipedia.\&
|
||||
https://en.\&wikipedia.\&org/wiki/Robot_exclusion_standard
|
||||
.PP
|
||||
.SH AUTHORS
|
||||
.PP
|
||||
Maintained by Alex Schroeder <alex@gnu.\&org>.\&
|
||||
193
man/oddmu-apache.5.txt
Normal file
193
man/oddmu-apache.5.txt
Normal file
@@ -0,0 +1,193 @@
|
||||
ODDMU-APACHE(5)
|
||||
|
||||
# NAME
|
||||
|
||||
oddmu-apache - how to setup Apache as a reverse proxy for Oddmu
|
||||
|
||||
## DESCRIPTION
|
||||
|
||||
The oddmu program serves the current working directory as a wiki on
|
||||
port 8080. This is an unpriviledged port so an ordinary use account
|
||||
can do this.
|
||||
|
||||
The best way to protect the wiki against vandalism and spam is to use
|
||||
a regular web server as reverse proxy. This page explains how to setup
|
||||
Apache on Debian to do this.
|
||||
|
||||
## CONFIGURATION
|
||||
|
||||
HTTPS is not part of the wiki. You probably want to configure this in
|
||||
your webserver. I guess you could use stunnel, too. If you're using
|
||||
Apache, you can use "mod_md" to manage your domain.
|
||||
|
||||
In the example below, the site is configured in a file called
|
||||
"/etc/apache2/sites-available/500-transjovian.conf" and a link poins
|
||||
there from "/etc/apache2/sites-enabled". Create this link using
|
||||
_a2ensite_(1).
|
||||
|
||||
```
|
||||
MDomain transjovian.org
|
||||
MDCertificateAgreement accepted
|
||||
|
||||
<VirtualHost *:80>
|
||||
ServerName transjovian.org
|
||||
RewriteEngine on
|
||||
RewriteRule ^/(.*) https://%{HTTP_HOST}/$1 [redirect]
|
||||
</VirtualHost>
|
||||
<VirtualHost *:443>
|
||||
ServerAdmin alex@alexschroeder.ch
|
||||
ServerName transjovian.org
|
||||
SSLEngine on
|
||||
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 proxies the requests for the wiki to
|
||||
port 8080.
|
||||
|
||||
Thus, this is what happens:
|
||||
|
||||
* 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:
|
||||
|
||||
```
|
||||
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
|
||||
editable by all. This is most likely not what you want unless you're
|
||||
running it stand-alone, unconnected to the Internet.
|
||||
|
||||
Create a new password file called ".htpasswd" and add the user "alex":
|
||||
|
||||
```
|
||||
cd /home/oddmu
|
||||
htpasswd -c .htpasswd alex
|
||||
```
|
||||
|
||||
To add more users, don't use the "-c" option or you will overwrite it!
|
||||
|
||||
To add another user:
|
||||
|
||||
```
|
||||
htpasswd .htpasswd berta
|
||||
```
|
||||
|
||||
To remove a user:
|
||||
|
||||
```
|
||||
htpasswd -D .htpasswd berta
|
||||
```
|
||||
|
||||
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:
|
||||
|
||||
```
|
||||
<LocationMatch "^/(edit|save|add|append|upload|drop)/">
|
||||
AuthType Basic
|
||||
AuthName "Password Required"
|
||||
AuthUserFile /home/oddmu/.htpasswd
|
||||
Require valid-user
|
||||
</LocationMatch>
|
||||
```
|
||||
|
||||
## Serve static files
|
||||
|
||||
If you want to serve static files as well, add a document root to your
|
||||
webserver configuration. In this case, the document root is the
|
||||
directory where all the data files are. Apache will not serve files
|
||||
such as ".htpasswd".
|
||||
|
||||
```
|
||||
DocumentRoot /home/oddmu
|
||||
<Directory /home/oddmu>
|
||||
Require all granted
|
||||
</Directory>
|
||||
```
|
||||
|
||||
Make sure that none of the subdirectories look like the wiki paths
|
||||
"/view/", "/edit/", "/save/", "/add/", "/append/", "/upload/",
|
||||
"/drop/" or "/search". For example, create a file called "robots.txt"
|
||||
containing the following, telling all robots that they're not welcome.
|
||||
|
||||
```
|
||||
User-agent: *
|
||||
Disallow: /
|
||||
```
|
||||
|
||||
You site now serves "/robots.txt" without interfering with the wiki,
|
||||
and without needing a wiki page.
|
||||
|
||||
## Different logins for different access rights
|
||||
|
||||
What if you have a site with various subdirectories and each
|
||||
subdirectory is for a different group of friends? You can set this up
|
||||
using your webserver. One way to do this is to require specific
|
||||
usernames (which must have a password in the password file mentioned
|
||||
above.
|
||||
|
||||
This requires a valid login by the user "alex" or "berta":
|
||||
|
||||
```
|
||||
<LocationMatch "^/(edit|save|add|append|upload|drop)/intetebi/">
|
||||
Require user alex berta
|
||||
</LocationMatch>
|
||||
```
|
||||
|
||||
## Private wikis
|
||||
|
||||
Based on the above, you can prevent people from _reading_ the wiki.
|
||||
The "LocationMatch" must cover the "/view/" URLs in order to protect
|
||||
*everything*.
|
||||
|
||||
```
|
||||
<Location />
|
||||
AuthType Basic
|
||||
AuthName "Password Required"
|
||||
AuthUserFile /home/oddmu/.htpasswd
|
||||
Require valid-user
|
||||
</Location>
|
||||
```
|
||||
|
||||
## Virtual hosting
|
||||
|
||||
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.
|
||||
|
||||
# SEE ALSO
|
||||
|
||||
_oddmu_(1)
|
||||
|
||||
"Apache Core Features".
|
||||
https://httpd.apache.org/docs/current/mod/core.html
|
||||
|
||||
"Apache: Authentication and Authorization".
|
||||
https://httpd.apache.org/docs/current/howto/auth.html
|
||||
|
||||
"Apache Module mod_proxy".
|
||||
https://httpd.apache.org/docs/current/mod/mod_proxy.html
|
||||
|
||||
"Robot exclusion standard" on Wikipedia.
|
||||
https://en.wikipedia.org/wiki/Robot_exclusion_standard
|
||||
|
||||
# AUTHORS
|
||||
|
||||
Maintained by Alex Schroeder <alex@gnu.org>.
|
||||
53
man/oddmu-html.1
Normal file
53
man/oddmu-html.1
Normal file
@@ -0,0 +1,53 @@
|
||||
.\" Generated by scdoc 1.11.2
|
||||
.\" Complete documentation for this program is not available as a GNU info page
|
||||
.ie \n(.g .ds Aq \(aq
|
||||
.el .ds Aq '
|
||||
.nh
|
||||
.ad l
|
||||
.\" Begin generated content:
|
||||
.TH "ODDMU-HTML" "1" "2023-09-22"
|
||||
.PP
|
||||
.SH NAME
|
||||
.PP
|
||||
oddmu-html - render Oddmu page HTML from the command-line
|
||||
.PP
|
||||
.SH SYNOPSIS
|
||||
.PP
|
||||
\fBoddmu html\fR [-view] \fIpage-name\fR
|
||||
.PP
|
||||
.SH DESCRIPTION
|
||||
.PP
|
||||
The "html" subcommand opens the Markdown file for the given page name
|
||||
(appending the ".\&md" extension) and prints the HTML to STDOUT without
|
||||
invoking the "view.\&html" template.\&
|
||||
.PP
|
||||
.SH OPTIONS
|
||||
.PP
|
||||
\fB-view\fR
|
||||
.RS 4
|
||||
Use the "view.\&html" template to render the page.\& Without this,
|
||||
the HTML will lack html and body tags.\&
|
||||
.PP
|
||||
.RE
|
||||
.SH EXAMPLE
|
||||
.PP
|
||||
Generate the HTML for "README.\&md":
|
||||
.PP
|
||||
.nf
|
||||
.RS 4
|
||||
oddmu html README
|
||||
.fi
|
||||
.RE
|
||||
.PP
|
||||
.SH ENVIRONMENT
|
||||
.PP
|
||||
The ODDMU_WEBFINGER environment variable has no effect in this
|
||||
situation.\& Fediverse accounts are not linked to their profile pages.\&
|
||||
.PP
|
||||
.SH SEE ALSO
|
||||
.PP
|
||||
\fIoddmu\fR(1)
|
||||
.PP
|
||||
.SH AUTHORS
|
||||
.PP
|
||||
Maintained by Alex Schroeder <alex@gnu.\&org>.\&
|
||||
42
man/oddmu-html.1.txt
Normal file
42
man/oddmu-html.1.txt
Normal file
@@ -0,0 +1,42 @@
|
||||
ODDMU-HTML(1)
|
||||
|
||||
# NAME
|
||||
|
||||
oddmu-html - render Oddmu page HTML from the command-line
|
||||
|
||||
# SYNOPSIS
|
||||
|
||||
*oddmu html* [-view] _page-name_
|
||||
|
||||
# DESCRIPTION
|
||||
|
||||
The "html" subcommand opens the Markdown file for the given page name
|
||||
(appending the ".md" extension) and prints the HTML to STDOUT without
|
||||
invoking the "view.html" template.
|
||||
|
||||
# OPTIONS
|
||||
|
||||
*-view*
|
||||
Use the "view.html" template to render the page. Without this,
|
||||
the HTML will lack html and body tags.
|
||||
|
||||
# EXAMPLE
|
||||
|
||||
Generate the HTML for "README.md":
|
||||
|
||||
```
|
||||
oddmu html README
|
||||
```
|
||||
|
||||
# ENVIRONMENT
|
||||
|
||||
The ODDMU_WEBFINGER environment variable has no effect in this
|
||||
situation. Fediverse accounts are not linked to their profile pages.
|
||||
|
||||
# SEE ALSO
|
||||
|
||||
_oddmu_(1)
|
||||
|
||||
# AUTHORS
|
||||
|
||||
Maintained by Alex Schroeder <alex@gnu.org>.
|
||||
66
man/oddmu-replace.1
Normal file
66
man/oddmu-replace.1
Normal file
@@ -0,0 +1,66 @@
|
||||
.\" Generated by scdoc 1.11.2
|
||||
.\" Complete documentation for this program is not available as a GNU info page
|
||||
.ie \n(.g .ds Aq \(aq
|
||||
.el .ds Aq '
|
||||
.nh
|
||||
.ad l
|
||||
.\" Begin generated content:
|
||||
.TH "ODDMU-REPLACE" "1" "2023-09-22"
|
||||
.PP
|
||||
.SH NAME
|
||||
.PP
|
||||
oddmu-replace - replace text in Oddmu pages from the command-line
|
||||
.PP
|
||||
.SH SYNOPSIS
|
||||
.PP
|
||||
\fBoddmu replace\fR [-confirm] \fIregexp\fR \fIreplacement\fR
|
||||
.PP
|
||||
.SH DESCRIPTION
|
||||
.PP
|
||||
The "replace" subcommand replaces the Markdown files in the current
|
||||
directory (!\&), returning the replace result as a Markdown-formatted
|
||||
list.\&
|
||||
.PP
|
||||
.SH OPTIONS
|
||||
.PP
|
||||
\fB-confirm\fR
|
||||
.RS 4
|
||||
By default, the replacement doesn'\&t save the changes made.\&
|
||||
Instead, a unified diff is produced and printed.\& Given this
|
||||
option, the changed Markdown files are saved to disk.\&
|
||||
.PP
|
||||
.RE
|
||||
.SH EXAMPLE
|
||||
.PP
|
||||
Replace for "oddmu" in the Markdown files of the current directory:
|
||||
.PP
|
||||
.nf
|
||||
.RS 4
|
||||
oddmu replace oddmu
|
||||
.fi
|
||||
.RE
|
||||
.PP
|
||||
Result:
|
||||
.PP
|
||||
.nf
|
||||
.RS 4
|
||||
Replace oddmu: 1 result
|
||||
* [Oddµ: A minimal wiki](README) (5)
|
||||
.fi
|
||||
.RE
|
||||
.PP
|
||||
.SH NOTES
|
||||
.PP
|
||||
This is the equivalent of using \fIsed\fR(1) with the --quiet,
|
||||
--regexp-extended, --in-place=~ and --expression command with the s
|
||||
command "s/regexp/replacement/g" except that it prints a unified diff
|
||||
per default instead of making any changes and the regexp rules differ
|
||||
slightly.\&
|
||||
.PP
|
||||
.SH SEE ALSO
|
||||
.PP
|
||||
\fIoddmu\fR(1), \fIoddmu-search\fR(7)
|
||||
.PP
|
||||
.SH AUTHORS
|
||||
.PP
|
||||
Maintained by Alex Schroeder <alex@gnu.\&org>.\&
|
||||
53
man/oddmu-replace.1.txt
Normal file
53
man/oddmu-replace.1.txt
Normal file
@@ -0,0 +1,53 @@
|
||||
ODDMU-REPLACE(1)
|
||||
|
||||
# NAME
|
||||
|
||||
oddmu-replace - replace text in Oddmu pages from the command-line
|
||||
|
||||
# SYNOPSIS
|
||||
|
||||
*oddmu replace* [-confirm] _regexp_ _replacement_
|
||||
|
||||
# DESCRIPTION
|
||||
|
||||
The "replace" subcommand replaces the Markdown files in the current
|
||||
directory (!), returning the replace result as a Markdown-formatted
|
||||
list.
|
||||
|
||||
# OPTIONS
|
||||
|
||||
*-confirm*
|
||||
By default, the replacement doesn't save the changes made.
|
||||
Instead, a unified diff is produced and printed. Given this
|
||||
option, the changed Markdown files are saved to disk.
|
||||
|
||||
# EXAMPLE
|
||||
|
||||
Replace for "oddmu" in the Markdown files of the current directory:
|
||||
|
||||
```
|
||||
oddmu replace oddmu
|
||||
```
|
||||
|
||||
Result:
|
||||
|
||||
```
|
||||
Replace oddmu: 1 result
|
||||
* [Oddµ: A minimal wiki](README) (5)
|
||||
```
|
||||
|
||||
# NOTES
|
||||
|
||||
This is the equivalent of using _sed_(1) with the --quiet,
|
||||
\--regexp-extended, --in-place=~ and --expression command with the s
|
||||
command "s/regexp/replacement/g" except that it prints a unified diff
|
||||
per default instead of making any changes and the regexp rules differ
|
||||
slightly.
|
||||
|
||||
# SEE ALSO
|
||||
|
||||
_oddmu_(1), _oddmu-search_(7)
|
||||
|
||||
# AUTHORS
|
||||
|
||||
Maintained by Alex Schroeder <alex@gnu.org>.
|
||||
61
man/oddmu-search.1
Normal file
61
man/oddmu-search.1
Normal file
@@ -0,0 +1,61 @@
|
||||
.\" Generated by scdoc 1.11.2
|
||||
.\" Complete documentation for this program is not available as a GNU info page
|
||||
.ie \n(.g .ds Aq \(aq
|
||||
.el .ds Aq '
|
||||
.nh
|
||||
.ad l
|
||||
.\" Begin generated content:
|
||||
.TH "ODDMU-SEARCH" "1" "2023-09-19"
|
||||
.PP
|
||||
.SH NAME
|
||||
.PP
|
||||
oddmu-search - search the Oddmu pages from the command-line
|
||||
.PP
|
||||
.SH SYNOPSIS
|
||||
.PP
|
||||
\fBoddmu search\fR [-page \fIn\fR] \fIterms.\&.\&.\&\fR
|
||||
.PP
|
||||
.SH DESCRIPTION
|
||||
.PP
|
||||
The "search" subcommand searches the Markdown files in the current
|
||||
directory (!\&), returning the search result as a Markdown-formatted
|
||||
list.\&
|
||||
.PP
|
||||
The use of a trigram index makes it possible to find substrings and
|
||||
for the word order not to matter, but it also makes the search results
|
||||
a bit harder to understand.\& See \fIoddmu-search\fR(7) for more.\&
|
||||
.PP
|
||||
.SH OPTIONS
|
||||
.PP
|
||||
\fB-page\fR \fIn\fR
|
||||
.RS 4
|
||||
Search results are paginated and by default only the first
|
||||
page is shown.\& This option allows you to view other pages.\&
|
||||
.PP
|
||||
.RE
|
||||
.SH EXAMPLE
|
||||
.PP
|
||||
Search for "oddmu" in the Markdown files of the current directory:
|
||||
.PP
|
||||
.nf
|
||||
.RS 4
|
||||
oddmu search oddmu
|
||||
.fi
|
||||
.RE
|
||||
.PP
|
||||
Result:
|
||||
.PP
|
||||
.nf
|
||||
.RS 4
|
||||
Search oddmu: 1 result
|
||||
* [Oddµ: A minimal wiki](README) (5)
|
||||
.fi
|
||||
.RE
|
||||
.PP
|
||||
.SH SEE ALSO
|
||||
.PP
|
||||
\fIoddmu\fR(1), \fIoddmu-replace\fR(1), \fIoddmu-search\fR(7)
|
||||
.PP
|
||||
.SH AUTHORS
|
||||
.PP
|
||||
Maintained by Alex Schroeder <alex@gnu.\&org>.\&
|
||||
48
man/oddmu-search.1.txt
Normal file
48
man/oddmu-search.1.txt
Normal file
@@ -0,0 +1,48 @@
|
||||
ODDMU-SEARCH(1)
|
||||
|
||||
# NAME
|
||||
|
||||
oddmu-search - search the Oddmu pages from the command-line
|
||||
|
||||
# SYNOPSIS
|
||||
|
||||
*oddmu search* [-page _n_] _terms..._
|
||||
|
||||
# DESCRIPTION
|
||||
|
||||
The "search" subcommand searches the Markdown files in the current
|
||||
directory (!), returning the search result as a Markdown-formatted
|
||||
list.
|
||||
|
||||
The use of a trigram index makes it possible to find substrings and
|
||||
for the word order not to matter, but it also makes the search results
|
||||
a bit harder to understand. See _oddmu-search_(7) for more.
|
||||
|
||||
# OPTIONS
|
||||
|
||||
*-page* _n_
|
||||
Search results are paginated and by default only the first
|
||||
page is shown. This option allows you to view other pages.
|
||||
|
||||
# EXAMPLE
|
||||
|
||||
Search for "oddmu" in the Markdown files of the current directory:
|
||||
|
||||
```
|
||||
oddmu search oddmu
|
||||
```
|
||||
|
||||
Result:
|
||||
|
||||
```
|
||||
Search oddmu: 1 result
|
||||
* [Oddµ: A minimal wiki](README) (5)
|
||||
```
|
||||
|
||||
# SEE ALSO
|
||||
|
||||
_oddmu_(1), _oddmu-replace_(1), _oddmu-search_(7)
|
||||
|
||||
# AUTHORS
|
||||
|
||||
Maintained by Alex Schroeder <alex@gnu.org>.
|
||||
89
man/oddmu-search.7
Normal file
89
man/oddmu-search.7
Normal file
@@ -0,0 +1,89 @@
|
||||
.\" Generated by scdoc 1.11.2
|
||||
.\" Complete documentation for this program is not available as a GNU info page
|
||||
.ie \n(.g .ds Aq \(aq
|
||||
.el .ds Aq '
|
||||
.nh
|
||||
.ad l
|
||||
.\" Begin generated content:
|
||||
.TH "ODDMU-SEARCH" "7" "2023-09-18"
|
||||
.PP
|
||||
.SH NAME
|
||||
.PP
|
||||
oddmu-search - understanding the Oddmu search engine
|
||||
.PP
|
||||
.SH SYNOPSIS
|
||||
.PP
|
||||
\fBoddmu search\fR \fIterms\fR.\&.\&.\&
|
||||
.PP
|
||||
.SH DESCRIPTION
|
||||
.PP
|
||||
The index indexes trigrams.\& Each group of three characters is a
|
||||
trigram.\& A document with content "This is a test" is turned to lower
|
||||
case and indexed under the trigrams "thi", "his", "is ", "s i", " is",
|
||||
"is ", "s a", " a ", "a t", " te", "tes", "est".\&
|
||||
.PP
|
||||
Each query is split into words and then processed the same way.\& A
|
||||
query with the words "this test" is turned to lower case and produces
|
||||
the trigrams "thi", "his", "tes", "est".\& This means that the word
|
||||
order is not considered when searching for documents.\&
|
||||
.PP
|
||||
This also means that there is no stemming.\& Searching for "testing"
|
||||
won'\&t find "This is a test" because there are no matches for the
|
||||
trigrams "sti", "tin", "ing".\&
|
||||
.PP
|
||||
These trigrams are looked up in the index, resulting in the list of
|
||||
documents.\& Each document found is then scored.\& Each of the following
|
||||
increases the score by one point:
|
||||
.PP
|
||||
.PD 0
|
||||
.IP \(bu 4
|
||||
the entire phrase matches
|
||||
.IP \(bu 4
|
||||
a word matches
|
||||
.IP \(bu 4
|
||||
a word matches at the beginning of a word
|
||||
.IP \(bu 4
|
||||
a word matches at the end of a word
|
||||
.IP \(bu 4
|
||||
a word matches as a whole word
|
||||
.PD
|
||||
.PP
|
||||
A document with content "This is a test" when searched with the phrase
|
||||
"this test" therefore gets a score of 8: the entire phrase does not
|
||||
match but each word gets four points.\&
|
||||
.PP
|
||||
Trigrams are sometimes strange: In a text containing the words "main"
|
||||
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.\&
|
||||
.PP
|
||||
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:
|
||||
.PP
|
||||
.PD 0
|
||||
.IP \(bu 4
|
||||
If the page title contains the query string, it gets sorted first.\&
|
||||
.IP \(bu 4
|
||||
If the page name (the filename!\&) begins with a number, it is sorted
|
||||
descending.\&
|
||||
.IP \(bu 4
|
||||
All other pages follow, sorted ascending.\&
|
||||
.PD
|
||||
.PP
|
||||
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.\& This assumes that blog pages start
|
||||
with an ISO date like "2023-09-16".\&
|
||||
.PP
|
||||
The score and highlighting of snippets is used to help visitors decide
|
||||
which links to click.\& A score of 0 indicates that all the trigrams
|
||||
were found but \fIno exact matches\fR for any of the terms.\&
|
||||
.PP
|
||||
.SH SEE ALSO
|
||||
.PP
|
||||
\fIoddmu\fR(1), \fIoddmu-search\fR(1)
|
||||
.PP
|
||||
.SH AUTHORS
|
||||
.PP
|
||||
Maintained by Alex Schroeder <alex@gnu.\&org>.\&
|
||||
70
man/oddmu-search.7.txt
Normal file
70
man/oddmu-search.7.txt
Normal file
@@ -0,0 +1,70 @@
|
||||
ODDMU-SEARCH(7)
|
||||
|
||||
# NAME
|
||||
|
||||
oddmu-search - understanding the Oddmu search engine
|
||||
|
||||
# SYNOPSIS
|
||||
|
||||
*oddmu search* _terms_...
|
||||
|
||||
# DESCRIPTION
|
||||
|
||||
The index indexes trigrams. Each group of three characters is a
|
||||
trigram. A document with content "This is a test" is turned to lower
|
||||
case and indexed under the trigrams "thi", "his", "is ", "s i", " is",
|
||||
"is ", "s a", " a ", "a t", " te", "tes", "est".
|
||||
|
||||
Each query is split into words and then processed the same way. A
|
||||
query with the words "this test" is turned to lower case and produces
|
||||
the trigrams "thi", "his", "tes", "est". This means that the word
|
||||
order is not considered when searching for documents.
|
||||
|
||||
This also means that there is no stemming. Searching for "testing"
|
||||
won't find "This is a test" because there are no matches for the
|
||||
trigrams "sti", "tin", "ing".
|
||||
|
||||
These trigrams are looked up in the index, resulting in the list of
|
||||
documents. Each document found is then scored. Each of the following
|
||||
increases the score by one point:
|
||||
|
||||
- the entire phrase matches
|
||||
- a word matches
|
||||
- a word matches at the beginning of a word
|
||||
- a word matches at the end of a word
|
||||
- a word matches as a whole word
|
||||
|
||||
A document with content "This is a test" when searched with the phrase
|
||||
"this test" therefore gets a score of 8: the entire phrase does not
|
||||
match but each word gets four points.
|
||||
|
||||
Trigrams are sometimes strange: In a text containing the words "main"
|
||||
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 name (the filename!) 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. This assumes that blog pages start
|
||||
with an ISO date like "2023-09-16".
|
||||
|
||||
The score and highlighting of snippets is used to help visitors decide
|
||||
which links to click. A score of 0 indicates that all the trigrams
|
||||
were found but _no exact matches_ for any of the terms.
|
||||
|
||||
# SEE ALSO
|
||||
|
||||
_oddmu_(1), _oddmu-search_(1)
|
||||
|
||||
# AUTHORS
|
||||
|
||||
Maintained by Alex Schroeder <alex@gnu.org>.
|
||||
152
man/oddmu-templates.5
Normal file
152
man/oddmu-templates.5
Normal file
@@ -0,0 +1,152 @@
|
||||
.\" Generated by scdoc 1.11.2
|
||||
.\" Complete documentation for this program is not available as a GNU info page
|
||||
.ie \n(.g .ds Aq \(aq
|
||||
.el .ds Aq '
|
||||
.nh
|
||||
.ad l
|
||||
.\" Begin generated content:
|
||||
.TH "ODDMU-TEMPLATES" "5" "2023-09-22" "File Formats Manual"
|
||||
.PP
|
||||
.SH NAME
|
||||
.PP
|
||||
oddmu-templates - how to write the templates
|
||||
.PP
|
||||
.SH SYNTAX
|
||||
.PP
|
||||
The templates can refer to the following properties of a page:
|
||||
.PP
|
||||
\fI{{.\&Title}}\fR is the page title.\& If the page doesn'\&t provide its own
|
||||
title, the page name is used.\&
|
||||
.PP
|
||||
\fI{{.\&Name}}\fR 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 \fI.\&md\fR extension.\&
|
||||
.PP
|
||||
For the \fIview.\&html\fR template:
|
||||
.PP
|
||||
\fI{{.\&Html}}\fR is the rendered Markdown, as HTML.\&
|
||||
.PP
|
||||
\fI{{.\&Hashtags}}\fR is an array of strings.\&
|
||||
.PP
|
||||
For the \fIedit.\&html\fR template:
|
||||
.PP
|
||||
\fI{{printf "%s" .\&Body}}\fR is the Markdown, as a string (the data itself
|
||||
is a byte array and that'\&s why we need to call \fIprintf\fR).\&
|
||||
.PP
|
||||
For the \fIsearch.\&html\fR template only:
|
||||
.PP
|
||||
\fI{{.\&Previous}}\fR, \fI{{.\&Page}}\fR, \fI{{.\&Next}}\fR and \fI{{.\&Last}}\fR 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.\&
|
||||
.PP
|
||||
\fI{{.\&More}}\fR indicates if there are any more search results.\&
|
||||
.PP
|
||||
\fI{{.\&Results}}\fR indicates if there were any search results at all.\&
|
||||
.PP
|
||||
\fI{{.\&Items}}\fR 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 \fI{{range .\&Items}}\fR … \fI{{end}}\fR
|
||||
construct.\&
|
||||
.PP
|
||||
For items in the search result:
|
||||
.PP
|
||||
\fI{{.\&Html}}\fR is the rendered Markdown of a page summary, as HTML.\&
|
||||
.PP
|
||||
\fI{{.\&Score}}\fR is a numerical score for search results.\&
|
||||
.PP
|
||||
For the \fIfeed.\&html\fR template:
|
||||
.PP
|
||||
\fI{{.\&Name}}\fR is the page name, escaped for use in URLs.\&
|
||||
.PP
|
||||
\fI{{.\&Title}}\fR is the title of the underlying main page.\&
|
||||
.PP
|
||||
\fI{{.\&Date}}\fR is the date of the last update to the underlying main page,
|
||||
in RFC 822 format.\&
|
||||
.PP
|
||||
\fI{{.\&Items}}\fR is an array of feed items.\&
|
||||
.PP
|
||||
For items in the feed:
|
||||
.PP
|
||||
\fI{{.\&Name}}\fR is the page name, escaped for use in URLs.\&
|
||||
.PP
|
||||
\fI{{.\&Title}}\fR is the title of the page.\&
|
||||
.PP
|
||||
\fI{{.\&Html}}\fR is the rendered Markdown, as HTML.\&
|
||||
.PP
|
||||
\fI{{.\&Hashtags}}\fR is an array of strings.\&
|
||||
.PP
|
||||
\fI{{.\&Date}}\fR, the date of the last update to this page, in RFC 822
|
||||
format.\&
|
||||
.PP
|
||||
The \fIupload.\&html\fR template cannot refer to anything.\&
|
||||
.PP
|
||||
When calling the \fIsave\fR and \fIappend\fR action, the page name is taken
|
||||
from the URL path and the page content is taken from the \fIbody\fR form
|
||||
parameter.\& To illustrate, here'\&s how to edit the "welcome" page using
|
||||
\fIcurl\fR:
|
||||
.PP
|
||||
.nf
|
||||
.RS 4
|
||||
curl --form body="Did you bring a towel?"
|
||||
http://localhost:8080/save/welcome
|
||||
.fi
|
||||
.RE
|
||||
.PP
|
||||
When calling the \fIsearch\fR action, the query is taken from the URL
|
||||
parameter \fIq\fR.\&
|
||||
.PP
|
||||
.nf
|
||||
.RS 4
|
||||
curl http://localhost:8080/search?q=towel
|
||||
.fi
|
||||
.RE
|
||||
.PP
|
||||
.SS Non-English hyphenation
|
||||
.PP
|
||||
Automatic hyphenation by the browser requires two things: The style
|
||||
sheet must indicate "hyphen: auto" for an HTML element such as "body",
|
||||
and that element must have a "lang" set (usually a two letter language
|
||||
code such as "de" for German).\&
|
||||
.PP
|
||||
Oddmu attempts to detect the correct language for each page.\& It
|
||||
assumes that languages are not mixed on the same page.\& 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".\&
|
||||
.PP
|
||||
"view.\&html" is used the template to render a single page and so the
|
||||
language detected is added to the "html" element.\&
|
||||
.PP
|
||||
"search.\&html" is the template used to render search results and so
|
||||
"en" is used for the "html" element and the language detected for
|
||||
every page in the search result is added to the "article" element for
|
||||
each snippet.\&
|
||||
.PP
|
||||
"edit.\&html" and "add.\&html" are the templates used to edit a page and
|
||||
at that point, the language isn'\&t known, so "en" is used for the
|
||||
"html" element and no language is used for the "textarea" element.\&
|
||||
.PP
|
||||
SEE ALSO
|
||||
.PP
|
||||
\fIoddmu\fR(1)
|
||||
.PP
|
||||
"Structuring the web with HTML"
|
||||
https://developer.\&mozilla.\&org/en-US/docs/Learn/HTML
|
||||
.PP
|
||||
"Learn to style HTML using CSS"
|
||||
https://developer.\&mozilla.\&org/en-US/docs/Learn/CSS
|
||||
.PP
|
||||
The "text/template" library explains how to write templates from a
|
||||
programmer perspective.\& https://pkg.\&go.\&dev/text/template
|
||||
.PP
|
||||
The "html/template" library explains how the templates are made more
|
||||
secure in a HTML context.\& https://pkg.\&go.\&dev/html/template
|
||||
.PP
|
||||
"Lingua" is the library used to detect languages.\&
|
||||
https://github.\&com/pemistahl/lingua-go
|
||||
.PP
|
||||
.SH AUTHORS
|
||||
.PP
|
||||
Maintained by Alex Schroeder <alex@gnu.\&org>.\& Up-to-date sources can be
|
||||
found at https://alexschroeder.\&ch/cgit/oddmu/.\&
|
||||
141
man/oddmu-templates.5.txt
Normal file
141
man/oddmu-templates.5.txt
Normal file
@@ -0,0 +1,141 @@
|
||||
ODDMU-TEMPLATES(5) "File Formats Manual"
|
||||
|
||||
# NAME
|
||||
|
||||
oddmu-templates - how to write the templates
|
||||
|
||||
# SYNTAX
|
||||
|
||||
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, escaped for use in URLs. More
|
||||
specifically, it is URI escaped except for the slashes. The page name
|
||||
doesn't include the _.md_ extension.
|
||||
|
||||
For the _view.html_ template:
|
||||
|
||||
_{{.Html}}_ is the rendered Markdown, as HTML.
|
||||
|
||||
_{{.Hashtags}}_ is an array of strings.
|
||||
|
||||
For the _edit.html_ template:
|
||||
|
||||
_{{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 items in the search result:
|
||||
|
||||
_{{.Html}}_ is the rendered Markdown of a page summary, as HTML.
|
||||
|
||||
_{{.Score}}_ is a numerical score for search results.
|
||||
|
||||
For the _feed.html_ template:
|
||||
|
||||
_{{.Name}}_ is the page name, escaped for use in URLs.
|
||||
|
||||
_{{.Title}}_ is the title of the underlying main page.
|
||||
|
||||
_{{.Date}}_ is the date of the last update to the underlying main page,
|
||||
in RFC 822 format.
|
||||
|
||||
_{{.Items}}_ is an array of feed items.
|
||||
|
||||
For items in the feed:
|
||||
|
||||
_{{.Name}}_ is the page name, escaped for use in URLs.
|
||||
|
||||
_{{.Title}}_ is the title of the page.
|
||||
|
||||
_{{.Html}}_ is the rendered Markdown, as escaped (!) HTML.
|
||||
|
||||
_{{.Hashtags}}_ is an array of strings.
|
||||
|
||||
_{{.Date}}_, the date of the last update to this page, in RFC 822
|
||||
format.
|
||||
|
||||
The _upload.html_ template cannot refer to anything.
|
||||
|
||||
When calling the _save_ and _append_ action, the page name is taken
|
||||
from the URL path and the page content is taken from the _body_ form
|
||||
parameter. To illustrate, here's how to edit the "welcome" page using
|
||||
_curl_:
|
||||
|
||||
```
|
||||
curl --form body="Did you bring a towel?" \
|
||||
http://localhost:8080/save/welcome
|
||||
```
|
||||
|
||||
When calling the _search_ action, the query is taken from the URL
|
||||
parameter _q_.
|
||||
|
||||
```
|
||||
curl http://localhost:8080/search?q=towel
|
||||
```
|
||||
|
||||
## Non-English hyphenation
|
||||
|
||||
Automatic hyphenation by the browser requires two things: The style
|
||||
sheet must indicate "hyphen: auto" for an HTML element such as "body",
|
||||
and that element must have a "lang" set (usually a two letter language
|
||||
code such as "de" for German).
|
||||
|
||||
Oddmu attempts to detect the correct language for each page. It
|
||||
assumes that languages are not mixed on the same page. 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".
|
||||
|
||||
"view.html" is used the template to render a single page and so the
|
||||
language detected is added to the "html" element.
|
||||
|
||||
"search.html" is the template used to render search results and so
|
||||
"en" is used for the "html" element and the language detected for
|
||||
every page in the search result is added to the "article" element for
|
||||
each snippet.
|
||||
|
||||
"edit.html" and "add.html" are the templates used to edit a page and
|
||||
at that point, the language isn't known, so "en" is used for the
|
||||
"html" element and no language is used for the "textarea" element.
|
||||
|
||||
SEE ALSO
|
||||
|
||||
_oddmu_(1)
|
||||
|
||||
"Structuring the web with HTML"
|
||||
https://developer.mozilla.org/en-US/docs/Learn/HTML
|
||||
|
||||
"Learn to style HTML using CSS"
|
||||
https://developer.mozilla.org/en-US/docs/Learn/CSS
|
||||
|
||||
The "text/template" library explains how to write templates from a
|
||||
programmer perspective. https://pkg.go.dev/text/template
|
||||
|
||||
The "html/template" library explains how the templates are made more
|
||||
secure in a HTML context. https://pkg.go.dev/html/template
|
||||
|
||||
"Lingua" is the library used to detect languages.
|
||||
https://github.com/pemistahl/lingua-go
|
||||
|
||||
# AUTHORS
|
||||
|
||||
Maintained by Alex Schroeder <alex@gnu.org>. Up-to-date sources can be
|
||||
found at https://alexschroeder.ch/cgit/oddmu/.
|
||||
147
man/oddmu.1
Normal file
147
man/oddmu.1
Normal file
@@ -0,0 +1,147 @@
|
||||
.\" Generated by scdoc 1.11.2
|
||||
.\" Complete documentation for this program is not available as a GNU info page
|
||||
.ie \n(.g .ds Aq \(aq
|
||||
.el .ds Aq '
|
||||
.nh
|
||||
.ad l
|
||||
.\" Begin generated content:
|
||||
.TH "ODDMU" "1" "2023-09-22"
|
||||
.PP
|
||||
.SH NAME
|
||||
.PP
|
||||
oddmu - a wiki server
|
||||
.PP
|
||||
Oddmu is sometimes written Oddµ because µ is the letter mu.\&
|
||||
.PP
|
||||
.SH SYNOPSIS
|
||||
.PP
|
||||
\fBoddmu\fR
|
||||
.PP
|
||||
.SH DESCRIPTION
|
||||
.PP
|
||||
The oddmu program serves the current working directory as a wiki on port 8080.\&
|
||||
Point your browser to http://localhost:8080/ to get started.\& This is equivalent
|
||||
to http://localhost:8080/view/index – the first page you'\&ll create, most likely.\&
|
||||
.PP
|
||||
If you request a page that doesn'\&t exist, oddmu tries to find a matching
|
||||
Markdown file by appending the extension ".\&md" to the page name.\& In the example
|
||||
above, the page name requested is "index" and the file name oddmu tries to read
|
||||
is "index.\&md".\& If no such file exists, oddmu offers you to create the page.\&
|
||||
.PP
|
||||
If your files don'\&t provide their own title ("# title"), the file name (without
|
||||
".\&md") is used for the page title.\&
|
||||
.PP
|
||||
Subdirectories are created as necessary.\&
|
||||
.PP
|
||||
See \fIoddmu\fR(5) for details about the page formatting.\&
|
||||
.PP
|
||||
.SH CONFIGURATION
|
||||
.PP
|
||||
The template files are the HTML files in the working directory: "add.\&html",
|
||||
"edit.\&html", "search.\&html", "upload.\&html" and "view.\&html".\& Feel free to change
|
||||
the templates and restart the server.\&
|
||||
.PP
|
||||
The first change you should make is to replace the name and email
|
||||
address in the footer of "view.\&html".\& Look for "Your Name" and
|
||||
"example.\&org".\&
|
||||
.PP
|
||||
The second change you should make is to replace the name, email
|
||||
address and domain name in "feed.\&html".\& Look for "Your Name" and
|
||||
"example.\&org".\& This second template is used to generate the RSS feeds
|
||||
(despite it'\&s ".\&html" extension).\&
|
||||
.PP
|
||||
See \fIoddmu-templates\fR(5) for more.\&
|
||||
.PP
|
||||
.SH ENVIRONMENT
|
||||
.PP
|
||||
You can change the port served by setting the ODDMU_PORT environment variable.\&
|
||||
.PP
|
||||
In order to limit language-detection to the languages you actually use, set the
|
||||
environment variable ODDMU_LANGUAGES to a comma-separated list of ISO 639-1
|
||||
codes, e.\&g.\& "en" or "en,de,fr,pt".\&
|
||||
.PP
|
||||
You can enable webfinger to link fediverse accounts to their correct profile
|
||||
pages by setting ODDMU_WEBFINGER to "1".\& See \fIoddmu\fR(5).\&
|
||||
.PP
|
||||
.SH SECURITY
|
||||
.PP
|
||||
If the machine you are running Oddmu on is accessible from the Internet, you
|
||||
must secure your installation.\& The best way to do this is use a regular web
|
||||
server as a reverse proxy.\&
|
||||
.PP
|
||||
See \fIoddmu-apache\fR(5) for an example.\&
|
||||
.PP
|
||||
.SH OPTIONS
|
||||
.PP
|
||||
The oddmu program can be run on the command-line using various subcommands.\&
|
||||
.PP
|
||||
.PD 0
|
||||
.IP \(bu 4
|
||||
to generate the HTML for a page, see \fIoddmu-html\fR(1)
|
||||
.IP \(bu 4
|
||||
to search a regular expression and replace it across all files, see
|
||||
\fIoddmu-replace\fR(1)
|
||||
.IP \(bu 4
|
||||
to emulate a search of the files, see \fIoddmu-search\fR(1); to understand how the
|
||||
search engine indexes pages and how it sorts and scores results, see
|
||||
\fIoddmu-search\fR(7)
|
||||
.PD
|
||||
.PP
|
||||
.SH EXAMPLE
|
||||
.PP
|
||||
When saving a page, 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 \fIcurl\fR(1):
|
||||
.PP
|
||||
.nf
|
||||
.RS 4
|
||||
curl --form body="Did you bring a towel?"
|
||||
http://localhost:8080/save/welcome
|
||||
.fi
|
||||
.RE
|
||||
.PP
|
||||
.SH DESIGN
|
||||
.PP
|
||||
This is a minimal wiki.\& There is no version history.\& It'\&s well suited as a
|
||||
\fIsecondary\fR medium: collaboration and conversation happens elsewhere, in chat,
|
||||
on social media.\& The wiki serves as the text repository that results from these
|
||||
discussions.\&
|
||||
.PP
|
||||
The wiki lists no recent changes.\& The expectation is that the people that care
|
||||
were involved in the discussions beforehand.\&
|
||||
.PP
|
||||
The wiki also produces no feed.\& The assumption is that announcements are made on
|
||||
social media: blogs, news aggregators, discussion forums, the fediverse, but
|
||||
humans.\&
|
||||
.PP
|
||||
The idea is that the webserver handles as many tasks as possible.\& It logs
|
||||
requests, does rate limiting, handles encryption, gets the certificates, and so
|
||||
on.\& The web server acts as a reverse proxy and the wiki ends up being a content
|
||||
management system with almost no structure – or endless malleability, depending
|
||||
on your point of view.\& See \fIoddmu-apache\fR(5).\&
|
||||
.PP
|
||||
.SH NOTES
|
||||
.PP
|
||||
Page names are filenames with ".\&md" appended.\& If your filesystem cannot handle
|
||||
it, it can'\&t be a page name.\&
|
||||
.PP
|
||||
The pages are indexed as the server starts and the index is kept in memory.\& If
|
||||
you have a ton of pages, this takes a lot of memory.\&
|
||||
.PP
|
||||
Files may not end with a tilde ('\&~'\&) – these are backup files.\&
|
||||
.PP
|
||||
You cannot edit uploaded files.\& If you upload a file called "hello.\&txt" and
|
||||
attempt to edit it by using "/edit/hello.\&txt" you will create a page with the
|
||||
name "hello.\&txt.\&md" instead.\&
|
||||
.PP
|
||||
You cannot delete uploaded files via the web – but you can delete regular wiki
|
||||
pages by saving an empty file.\&
|
||||
.PP
|
||||
.SH SEE ALSO
|
||||
.PP
|
||||
\fIoddmu\fR(5), \fIoddmu.\&service\fR(5), oddmu-apache_(5), \fIoddmu-html\fR(1),
|
||||
\fIoddmu-replace\fR(1), \fIoddmu-search\fR(1), \fIoddmu-search\fR(7), \fIoddmu-feed\fR(1)
|
||||
.PP
|
||||
.SH AUTHORS
|
||||
.PP
|
||||
Maintained by Alex Schroeder <alex@gnu.\&org>.\&
|
||||
132
man/oddmu.1.txt
Normal file
132
man/oddmu.1.txt
Normal file
@@ -0,0 +1,132 @@
|
||||
ODDMU(1)
|
||||
|
||||
# NAME
|
||||
|
||||
oddmu - a wiki server
|
||||
|
||||
Oddmu is sometimes written Oddµ because µ is the letter mu.
|
||||
|
||||
# SYNOPSIS
|
||||
|
||||
*oddmu*
|
||||
|
||||
# DESCRIPTION
|
||||
|
||||
The oddmu program serves the current working directory as a wiki on port 8080.
|
||||
Point your browser to http://localhost:8080/ to get started. This is equivalent
|
||||
to http://localhost:8080/view/index – the first page you'll create, most likely.
|
||||
|
||||
If you request a page that doesn't exist, oddmu tries to find a matching
|
||||
Markdown file by appending the extension ".md" to the page name. In the example
|
||||
above, the page name requested is "index" and the file name oddmu tries to read
|
||||
is "index.md". If no such file exists, oddmu offers you to create the page.
|
||||
|
||||
If your files don't provide their own title ("# title"), the file name (without
|
||||
".md") is used for the page title.
|
||||
|
||||
Every file can be viewed as feed by using the extension ".rss". The
|
||||
feed items are based on links in bullet lists using the asterix ("*").
|
||||
|
||||
Subdirectories are created as necessary.
|
||||
|
||||
See _oddmu_(5) for details about the page formatting.
|
||||
|
||||
# CONFIGURATION
|
||||
|
||||
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 name and email
|
||||
address in the footer of "view.html". Look for "Your Name" and
|
||||
"example.org".
|
||||
|
||||
The second change you should make is to replace the name, email
|
||||
address and domain name in "feed.html". Look for "Your Name" and
|
||||
"example.org". This second template is used to generate the RSS feeds
|
||||
(despite it's ".html" extension).
|
||||
|
||||
See _oddmu-templates_(5) for more.
|
||||
|
||||
# ENVIRONMENT
|
||||
|
||||
You can change the port served by setting the ODDMU_PORT environment variable.
|
||||
|
||||
In order to limit language-detection to the languages you actually use, set the
|
||||
environment variable ODDMU_LANGUAGES to a comma-separated list of ISO 639-1
|
||||
codes, e.g. "en" or "en,de,fr,pt".
|
||||
|
||||
You can enable webfinger to link fediverse accounts to their correct profile
|
||||
pages by setting ODDMU_WEBFINGER to "1". See _oddmu_(5).
|
||||
|
||||
# SECURITY
|
||||
|
||||
If the machine you are running Oddmu on is accessible from the Internet, you
|
||||
must secure your installation. The best way to do this is use a regular web
|
||||
server as a reverse proxy.
|
||||
|
||||
See _oddmu-apache_(5) for an example.
|
||||
|
||||
# OPTIONS
|
||||
|
||||
The oddmu program can be run on the command-line using various subcommands.
|
||||
|
||||
- to generate the HTML for a page, see _oddmu-html_(1)
|
||||
- to search a regular expression and replace it across all files, see
|
||||
_oddmu-replace_(1)
|
||||
- to emulate a search of the files, see _oddmu-search_(1); to understand how the
|
||||
search engine indexes pages and how it sorts and scores results, see
|
||||
_oddmu-search_(7)
|
||||
|
||||
# EXAMPLE
|
||||
|
||||
When saving a page, 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_(1):
|
||||
|
||||
```
|
||||
curl --form body="Did you bring a towel?" \
|
||||
http://localhost:8080/save/welcome
|
||||
```
|
||||
|
||||
# DESIGN
|
||||
|
||||
This is a minimal wiki. There is no version history. It's well suited as a
|
||||
_secondary_ medium: collaboration and conversation happens elsewhere, in chat,
|
||||
on social media. The wiki serves as the text repository that results from these
|
||||
discussions.
|
||||
|
||||
The wiki lists no recent changes. The expectation is that the people that care
|
||||
were involved in the discussions beforehand.
|
||||
|
||||
The idea is that the webserver handles as many tasks as possible. It logs
|
||||
requests, does rate limiting, handles encryption, gets the certificates, and so
|
||||
on. The web server acts as a reverse proxy and the wiki ends up being a content
|
||||
management system with almost no structure – or endless malleability, depending
|
||||
on your point of view. See _oddmu-apache_(5).
|
||||
|
||||
# NOTES
|
||||
|
||||
Page names are filenames with ".md" appended. If your filesystem cannot handle
|
||||
it, it can't be a page name.
|
||||
|
||||
The pages are indexed as the server starts and the index is kept in memory. If
|
||||
you have a ton of pages, this takes 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 – but you can delete regular wiki
|
||||
pages by saving an empty file.
|
||||
|
||||
# SEE ALSO
|
||||
|
||||
_oddmu_(5), _oddmu.service_(5), oddmu-apache_(5), _oddmu-html_(1),
|
||||
_oddmu-replace_(1), _oddmu-search_(1), _oddmu-search_(7)
|
||||
|
||||
# AUTHORS
|
||||
|
||||
Maintained by Alex Schroeder <alex@gnu.org>.
|
||||
133
man/oddmu.5
Normal file
133
man/oddmu.5
Normal file
@@ -0,0 +1,133 @@
|
||||
.\" Generated by scdoc 1.11.2
|
||||
.\" Complete documentation for this program is not available as a GNU info page
|
||||
.ie \n(.g .ds Aq \(aq
|
||||
.el .ds Aq '
|
||||
.nh
|
||||
.ad l
|
||||
.\" Begin generated content:
|
||||
.TH "ODDMU" "5" "2023-09-21" "File Formats Manual"
|
||||
.PP
|
||||
.SH NAME
|
||||
.PP
|
||||
oddmu - text formatting of wiki pages
|
||||
.PP
|
||||
.SH SYNTAX
|
||||
.PP
|
||||
The wiki pages are UTF-8 encoded Markdown files.\&
|
||||
.PP
|
||||
There are three Oddµ-specific extensions: local links, hashtags and
|
||||
fediverse account links.\& The Markdown library used features some
|
||||
additional extensions, most importantly tables and definition lists.\&
|
||||
.PP
|
||||
.SS Local links
|
||||
.PP
|
||||
Local links use double square brackets [[like this]].\&
|
||||
.PP
|
||||
.SS Hashtags
|
||||
.PP
|
||||
Hashtags are single word links to searches for themselves.\& Use the
|
||||
underscore to use hashtags consisting of multiple words.\& Hashtags are
|
||||
distinguished from page titles because there is no space after the
|
||||
hash.\&
|
||||
.PP
|
||||
.nf
|
||||
.RS 4
|
||||
# Title
|
||||
|
||||
Text
|
||||
|
||||
#Tag #Another_Tag
|
||||
.fi
|
||||
.RE
|
||||
.PP
|
||||
.SS Tables
|
||||
.PP
|
||||
A table with footers and a columnspan:
|
||||
.PP
|
||||
.nf
|
||||
.RS 4
|
||||
Name | Age
|
||||
--------|------
|
||||
Bob ||
|
||||
Alice | 23
|
||||
========|======
|
||||
Total | 23
|
||||
.fi
|
||||
.RE
|
||||
.PP
|
||||
.SS Definition lists:
|
||||
.PP
|
||||
.nf
|
||||
.RS 4
|
||||
Cat
|
||||
: Fluffy animal everyone likes
|
||||
|
||||
Internet
|
||||
: Vector of transmission for pictures of cats
|
||||
.fi
|
||||
.RE
|
||||
.PP
|
||||
.SS Fediverse account links
|
||||
.PP
|
||||
Fediverse accounts look a bit like an at sign followed by an email
|
||||
address, e.\&g.\& @alex@alexschroeder.\&ch.\& When rendering a page, these
|
||||
turn into a username linked to a profile page.\& In this case, "@alex"
|
||||
would be linked to "https://alexschroeder.\&ch/users/alex".\&
|
||||
.PP
|
||||
In many cases, this will work as is.\& In reality, however, the link to
|
||||
the profile page needs to be retrieved via webfinger.\& Oddµ does that
|
||||
in the background, and as soon as the information is available, the
|
||||
actual profile link is used when pages are rendered.\& In the example
|
||||
above, the result would be "https://social.\&alexschroeder.\&ch/@alex".\&
|
||||
.PP
|
||||
As this sort of packground network activity is surprising, it is not
|
||||
enabled by default.\& Set the environment variable ODDMU_WEBFINGER to
|
||||
"1" in order to enable this.\&
|
||||
.PP
|
||||
.SS Other extensions
|
||||
.PP
|
||||
The Markdown processor comes with a few extensions:
|
||||
.PP
|
||||
\fB emphasis markers inside words are ignored
|
||||
\fR fenced code blocks are supported
|
||||
\fB autolinking of "naked" URLs are supported
|
||||
\fR strikethrough using two tildes is supported (~~like this~~)
|
||||
\fB it is strict about prefix heading rules
|
||||
\fR you can specify an id for headings ({#id})
|
||||
\fB trailing backslashes turn into line breaks
|
||||
\fR MathJax is supported (but needs a separte setup)
|
||||
.PP
|
||||
.SH PERCENT ENCODING
|
||||
.PP
|
||||
If you use Markdown links to local pages, you must percent-encode the
|
||||
link target.\& Any character that is not an "unreserved character"
|
||||
according to RFC 3986 might need to be encoded.\& The unreserved
|
||||
characters are a-z, A-Z, 0-9, as well as the four characters '\&-'\&,
|
||||
\&'\&_'\&, '\&.\&'\& and '\&~'\&.\&
|
||||
.PP
|
||||
Percent-encoding means that each character is converted into one or
|
||||
more bytes, and each byte is represented as a percent character
|
||||
followed by a hexadecimal representation.\&
|
||||
.PP
|
||||
Realistically, what probably works best is to use a browser.\& If you
|
||||
type "http://example.\&org/Alex Schröder" into the address bar, you'\&ll
|
||||
get sent to the example domain.\& If you now copy the address and paste
|
||||
it back into a text editor, you'\&ll get
|
||||
"http://example.\&org/Alex%20Schr%C3%B6der" and that'\&s how you'\&ll learn
|
||||
that the Space is encoded by %20 and that the character '\&ö'\& is encoded
|
||||
by %C3%B6.\& To link to the page "Alex Schröder" you would write
|
||||
something like this: "[Alex](Alex%20Schr%C3%B6der)".\&
|
||||
.PP
|
||||
.SH SEE ALSO
|
||||
.PP
|
||||
\fIoddmu\fR(1)
|
||||
.PP
|
||||
This wiki uses the Go Markdown library.\&
|
||||
https://github.\&com/gomarkdown/markdown
|
||||
.PP
|
||||
For more about percent-encoding, see Wikipedia.\&
|
||||
https://en.\&wikipedia.\&org/wiki/Percent-encoding
|
||||
.PP
|
||||
.SH AUTHORS
|
||||
.PP
|
||||
Maintained by Alex Schroeder <alex@gnu.\&org>.\&
|
||||
149
man/oddmu.5.txt
Normal file
149
man/oddmu.5.txt
Normal file
@@ -0,0 +1,149 @@
|
||||
ODDMU(5) "File Formats Manual"
|
||||
|
||||
# NAME
|
||||
|
||||
oddmu - text formatting of wiki pages
|
||||
|
||||
# SYNTAX
|
||||
|
||||
The wiki pages are UTF-8 encoded Markdown files.
|
||||
|
||||
There are three Oddµ-specific extensions: local links, hashtags and
|
||||
fediverse account links. The Markdown library used features some
|
||||
additional extensions, most importantly tables and definition lists.
|
||||
|
||||
## Local links
|
||||
|
||||
Local links use double square brackets [[like this]].
|
||||
|
||||
## Hashtags
|
||||
|
||||
Hashtags are single word links to searches for themselves. Use the
|
||||
underscore to use hashtags consisting of multiple words. Hashtags are
|
||||
distinguished from page titles because there is no space after the
|
||||
hash.
|
||||
|
||||
```
|
||||
# Title
|
||||
|
||||
Text
|
||||
|
||||
#Tag #Another_Tag
|
||||
```
|
||||
|
||||
## Tables
|
||||
|
||||
A table with footers and a columnspan:
|
||||
|
||||
```
|
||||
Name | Age
|
||||
--------|------
|
||||
Bob ||
|
||||
Alice | 23
|
||||
========|======
|
||||
Total | 23
|
||||
```
|
||||
|
||||
## Definition lists:
|
||||
|
||||
```
|
||||
Cat
|
||||
: Fluffy animal everyone likes
|
||||
|
||||
Internet
|
||||
: Vector of transmission for pictures of cats
|
||||
```
|
||||
|
||||
## Fediverse account links
|
||||
|
||||
Fediverse accounts look a bit like an at sign followed by an email
|
||||
address, e.g. @alex@alexschroeder.ch. When rendering a page, these
|
||||
turn into a username linked to a profile page. In this case, "@alex"
|
||||
would be linked to "https://alexschroeder.ch/users/alex".
|
||||
|
||||
In many cases, this will work as is. In reality, however, the link to
|
||||
the profile page needs to be retrieved via webfinger. Oddµ does that
|
||||
in the background, and as soon as the information is available, the
|
||||
actual profile link is used when pages are rendered. In the example
|
||||
above, the result would be "https://social.alexschroeder.ch/@alex".
|
||||
|
||||
As this sort of packground network activity is surprising, it is not
|
||||
enabled by default. Set the environment variable ODDMU_WEBFINGER to
|
||||
"1" in order to enable this.
|
||||
|
||||
## Other extensions
|
||||
|
||||
The Markdown processor comes with a few extensions:
|
||||
|
||||
* emphasis markers inside words are ignored
|
||||
* fenced code blocks are supported
|
||||
* autolinking of "naked" URLs are supported
|
||||
* strikethrough using two tildes is supported (~~like this~~)
|
||||
* it is strict about prefix heading rules
|
||||
* you can specify an id for headings ({#id})
|
||||
* trailing backslashes turn into line breaks
|
||||
* MathJax is supported (but needs a separte setup)
|
||||
|
||||
# FEEDS
|
||||
|
||||
Every file can be viewed as feed by using the extension ".rss". The
|
||||
feed items are based on links in bullet lists using the asterix ("*").
|
||||
The items must point to local pages. This is why the link may not
|
||||
contain two forward slashes ("//").
|
||||
|
||||
Assume this is the index page. The feed would be "/view/index.rss". It
|
||||
would contain the pages "Arianism", "Donatism" and "Monophysitism" but
|
||||
it would not contain the pages "Feed" and "About" since the list items
|
||||
don't start with an asterix.
|
||||
|
||||
```
|
||||
# Main Page
|
||||
|
||||
Hello and welcome! Here are some important links:
|
||||
|
||||
- [Feed](index.rss)
|
||||
- [About](about)
|
||||
|
||||
Recent posts:
|
||||
|
||||
* [Arianism](arianism)
|
||||
* [Donatism](donatism)
|
||||
* [Monophysitism](monophysitism)
|
||||
```
|
||||
|
||||
The feed contains at most 10 items, starting at the top.
|
||||
|
||||
# PERCENT ENCODING
|
||||
|
||||
If you use Markdown links to local pages, you must percent-encode the
|
||||
link target. Any character that is not an "unreserved character"
|
||||
according to RFC 3986 might need to be encoded. The unreserved
|
||||
characters are a-z, A-Z, 0-9, as well as the four characters '-',
|
||||
'\_', '.' and '~'.
|
||||
|
||||
Percent-encoding means that each character is converted into one or
|
||||
more bytes, and each byte is represented as a percent character
|
||||
followed by a hexadecimal representation.
|
||||
|
||||
Realistically, what probably works best is to use a browser. If you
|
||||
type "http://example.org/Alex Schröder" into the address bar, you'll
|
||||
get sent to the example domain. If you now copy the address and paste
|
||||
it back into a text editor, you'll get
|
||||
"http://example.org/Alex%20Schr%C3%B6der" and that's how you'll learn
|
||||
that the Space is encoded by %20 and that the character 'ö' is encoded
|
||||
by %C3%B6. To link to the page "Alex Schröder" you would write
|
||||
something like this: "[Alex](Alex%20Schr%C3%B6der)".
|
||||
|
||||
# SEE ALSO
|
||||
|
||||
_oddmu_(1)
|
||||
|
||||
This wiki uses the Go Markdown library.
|
||||
https://github.com/gomarkdown/markdown
|
||||
|
||||
For more about percent-encoding, see Wikipedia.
|
||||
https://en.wikipedia.org/wiki/Percent-encoding
|
||||
|
||||
# AUTHORS
|
||||
|
||||
Maintained by Alex Schroeder <alex@gnu.org>.
|
||||
82
man/oddmu.service.5
Normal file
82
man/oddmu.service.5
Normal file
@@ -0,0 +1,82 @@
|
||||
.\" Generated by scdoc 1.11.2
|
||||
.\" Complete documentation for this program is not available as a GNU info page
|
||||
.ie \n(.g .ds Aq \(aq
|
||||
.el .ds Aq '
|
||||
.nh
|
||||
.ad l
|
||||
.\" Begin generated content:
|
||||
.TH "ODDMU.SERVICE" "5" "2023-09-21"
|
||||
.PP
|
||||
.SH NAME
|
||||
.PP
|
||||
oddmu.\&service - how to setup Oddmu using systemd
|
||||
.PP
|
||||
.SS DESCRIPTION
|
||||
.PP
|
||||
Here'\&s how to setup a wiki using systemd such that it starts
|
||||
automatically when the system boots and gets restarted automatically
|
||||
when it crashes.\&
|
||||
.PP
|
||||
First, create a new user called "oddmu" with it'\&s own home directory
|
||||
but without a login.\&
|
||||
.PP
|
||||
.nf
|
||||
.RS 4
|
||||
adduser --system --home /home/oddmu oddmu
|
||||
.fi
|
||||
.RE
|
||||
.PP
|
||||
The directory "/home/oddmu" contains the templates and all the data
|
||||
files.\& Copy all the tempaltes files ending in ".\&html" from the source
|
||||
distribution to "/home/oddmu".\&
|
||||
.PP
|
||||
If you want to keep everything in one place, copy the binary "oddmu"
|
||||
and the service file "oddmu.\&service" to "/home/oddmu", too.\&
|
||||
.PP
|
||||
Edit the `oddmu.\&service` file.\& These are the lines you most likely
|
||||
have to take care of:
|
||||
.PP
|
||||
.nf
|
||||
.RS 4
|
||||
ExecStart=/home/oddmu/oddmu
|
||||
WorkingDirectory=/home/oddmu
|
||||
Environment="ODDMU_PORT=8080"
|
||||
Environment="ODDMU_WEBFINGER=1"
|
||||
.fi
|
||||
.RE
|
||||
.PP
|
||||
Install the service file and enable it:
|
||||
.PP
|
||||
.nf
|
||||
.RS 4
|
||||
ln -s /home/oddmu/oddmu\&.service /etc/systemd/system/
|
||||
systemctl enable --now oddmu
|
||||
.fi
|
||||
.RE
|
||||
.PP
|
||||
You should be able to visit the wiki at
|
||||
http://localhost:8080/.\&
|
||||
.PP
|
||||
Check the log:
|
||||
.PP
|
||||
.nf
|
||||
.RS 4
|
||||
journalctl --unit oddmu
|
||||
.fi
|
||||
.RE
|
||||
.PP
|
||||
Follow the log:
|
||||
.PP
|
||||
.nf
|
||||
.RS 4
|
||||
journalctl --follow --unit oddmu
|
||||
.fi
|
||||
.RE
|
||||
.PP
|
||||
.SH SEE ALSO
|
||||
.PP
|
||||
\fIoddmu\fR(1), \fIsystemd.\&exec\fR(5), \fIcapabilities\fR(7)
|
||||
.PP
|
||||
.SH AUTHORS
|
||||
.PP
|
||||
Maintained by Alex Schroeder <alex@gnu.\&org>.\&
|
||||
65
man/oddmu.service.5.txt
Normal file
65
man/oddmu.service.5.txt
Normal file
@@ -0,0 +1,65 @@
|
||||
ODDMU.SERVICE(5)
|
||||
|
||||
# NAME
|
||||
|
||||
oddmu.service - how to setup Oddmu using systemd
|
||||
|
||||
## DESCRIPTION
|
||||
|
||||
Here's how to setup a wiki using systemd such that it starts
|
||||
automatically when the system boots and gets restarted automatically
|
||||
when it crashes.
|
||||
|
||||
First, create a new user called "oddmu" with it's own home directory
|
||||
but without a login.
|
||||
|
||||
```
|
||||
adduser --system --home /home/oddmu oddmu
|
||||
```
|
||||
|
||||
The directory "/home/oddmu" contains the templates and all the data
|
||||
files. Copy all the tempaltes files ending in ".html" from the source
|
||||
distribution to "/home/oddmu".
|
||||
|
||||
If you want to keep everything in one place, copy the binary "oddmu"
|
||||
and the service file "oddmu.service" to "/home/oddmu", too.
|
||||
|
||||
Edit the `oddmu.service` file. These are the lines you most likely
|
||||
have to take care of:
|
||||
|
||||
```
|
||||
ExecStart=/home/oddmu/oddmu
|
||||
WorkingDirectory=/home/oddmu
|
||||
Environment="ODDMU_PORT=8080"
|
||||
Environment="ODDMU_WEBFINGER=1"
|
||||
```
|
||||
|
||||
Install the service file and enable it:
|
||||
|
||||
```
|
||||
ln -s /home/oddmu/oddmu.service /etc/systemd/system/
|
||||
systemctl enable --now oddmu
|
||||
```
|
||||
|
||||
You should be able to visit the wiki at
|
||||
http://localhost:8080/.
|
||||
|
||||
Check the log:
|
||||
|
||||
```
|
||||
journalctl --unit oddmu
|
||||
```
|
||||
|
||||
Follow the log:
|
||||
|
||||
```
|
||||
journalctl --follow --unit oddmu
|
||||
```
|
||||
|
||||
# SEE ALSO
|
||||
|
||||
_oddmu_(1), _systemd.exec_(5), _capabilities_(7)
|
||||
|
||||
# AUTHORS
|
||||
|
||||
Maintained by Alex Schroeder <alex@gnu.org>.
|
||||
@@ -12,17 +12,16 @@ MemoryHigh=120M
|
||||
ExecStart=/home/oddmu/oddmu
|
||||
WorkingDirectory=/home/oddmu
|
||||
Environment="ODDMU_PORT=8080"
|
||||
Environment="ODDMU_WEBFINGER=1"
|
||||
|
||||
# (man "systemd.exec")
|
||||
ReadWritePaths=/home/oddmu
|
||||
ProtectHostname=yes
|
||||
RestrictSUIDSGID=yes
|
||||
UMask=0077
|
||||
RemoveIPC=yes
|
||||
MemoryDenyWriteExecute=yes
|
||||
|
||||
# Sandboxing options to harden security
|
||||
# Details for these options: https://www.freedesktop.org/software/systemd/man/systemd.exec.html
|
||||
NoNewPrivileges=yes
|
||||
PrivateTmp=yes
|
||||
PrivateDevices=yes
|
||||
@@ -38,7 +37,7 @@ LockPersonality=yes
|
||||
SystemCallFilter=~@clock @debug @module @mount @obsolete @reboot @setuid @swap
|
||||
|
||||
# Denying access to capabilities that should not be relevant
|
||||
# Doc: https://man7.org/linux/man-pages/man7/capabilities.7.html
|
||||
# (man "capabilities")
|
||||
CapabilityBoundingSet=~CAP_RAWIO CAP_MKNOD
|
||||
CapabilityBoundingSet=~CAP_AUDIT_CONTROL CAP_AUDIT_READ CAP_AUDIT_WRITE
|
||||
CapabilityBoundingSet=~CAP_SYS_BOOT CAP_SYS_TIME CAP_SYS_MODULE CAP_SYS_PACCT
|
||||
|
||||
112
page.go
112
page.go
@@ -3,32 +3,14 @@ package main
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"github.com/gomarkdown/markdown"
|
||||
"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"
|
||||
@@ -42,35 +24,57 @@ type Page struct {
|
||||
Body []byte
|
||||
Html template.HTML
|
||||
Score int
|
||||
Hashtags []string
|
||||
}
|
||||
|
||||
func sanitize(s string) template.HTML {
|
||||
return template.HTML(bluemonday.UGCPolicy().Sanitize(s))
|
||||
// santize uses bluemonday to sanitize the HTML.
|
||||
// No exceptions are made because this is used for snippets.
|
||||
func sanitizeStrict(s string) template.HTML {
|
||||
return template.HTML(bluemonday.StrictPolicy().Sanitize(s))
|
||||
}
|
||||
|
||||
// santizeBytes uses bluemonday to sanitize the HTML.
|
||||
func sanitizeBytes(bytes []byte) template.HTML {
|
||||
return template.HTML(bluemonday.UGCPolicy().SanitizeBytes(bytes))
|
||||
policy := bluemonday.UGCPolicy()
|
||||
policy.AllowAttrs("class").OnElements("a") // for hashtags
|
||||
return template.HTML(policy.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,52 +105,24 @@ func (p *Page) handleTitle(replace bool) {
|
||||
}
|
||||
}
|
||||
|
||||
// renderHtml renders the Page.Body to HTML and sets Page.Html.
|
||||
func (p *Page) renderHtml() {
|
||||
maybeUnsafeHTML := markdown.ToHTML(p.Body, nil, nil)
|
||||
p.Html = sanitizeBytes(maybeUnsafeHTML)
|
||||
p.Language = language(p.plainText())
|
||||
}
|
||||
|
||||
// plainText renders the Page.Body to plain text and returns it,
|
||||
// ignoring all the Markdown and all the newlines. The result is one
|
||||
// long single line of text.
|
||||
func (p *Page) plainText() string {
|
||||
parser := parser.New()
|
||||
doc := markdown.Parse(p.Body, parser)
|
||||
text := []byte("")
|
||||
ast.WalkFunc(doc, func(node ast.Node, entering bool) ast.WalkStatus {
|
||||
if entering && node.AsLeaf() != nil {
|
||||
text = append(text, node.AsLeaf().Literal...)
|
||||
text = append(text, []byte(" ")...)
|
||||
}
|
||||
return ast.GoToNext
|
||||
})
|
||||
// Some Markdown still contains newlines
|
||||
for i, c := range text {
|
||||
if c == '\n' {
|
||||
text[i] = ' '
|
||||
}
|
||||
}
|
||||
// Remove trailing space
|
||||
for len(text) > 0 && text[len(text)-1] == ' ' {
|
||||
text = text[0 : len(text)-1]
|
||||
}
|
||||
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.Name = nameEscape(p.Name)
|
||||
p.Html = sanitizeStrict(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
|
||||
}
|
||||
|
||||
81
page_test.go
81
page_test.go
@@ -1,8 +1,9 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"github.com/stretchr/testify/assert"
|
||||
"os"
|
||||
"strings"
|
||||
"regexp"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -12,78 +13,36 @@ 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()
|
||||
}
|
||||
}
|
||||
|
||||
func TestPagePlainText(t *testing.T) {
|
||||
p := &Page{Body: []byte(`# Water
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
func TestPageHtml(t *testing.T) {
|
||||
p := &Page{Body: []byte(`# Sun
|
||||
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.Regexp(t, regexp.MustCompile("^My back"), string(p.Body))
|
||||
}
|
||||
|
||||
// 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()
|
||||
}
|
||||
}
|
||||
|
||||
104
parser.go
Normal file
104
parser.go
Normal file
@@ -0,0 +1,104 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"github.com/gomarkdown/markdown"
|
||||
"github.com/gomarkdown/markdown/ast"
|
||||
"github.com/gomarkdown/markdown/parser"
|
||||
"net/url"
|
||||
)
|
||||
|
||||
// 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
|
||||
}
|
||||
}
|
||||
|
||||
// hashtag returns an inline parser function. This indirection is
|
||||
// required because we want to receive an array of hashtags found.
|
||||
func hashtag() (func(p *parser.Parser, data []byte, offset int) (int, ast.Node), *[]string) {
|
||||
hashtags := make([]string,0)
|
||||
return func(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
|
||||
}
|
||||
hashtags = append(hashtags, string(data[1:i]))
|
||||
link := &ast.Link{
|
||||
AdditionalAttributes: []string{`class="tag"`},
|
||||
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
|
||||
}, &hashtags
|
||||
}
|
||||
|
||||
// renderHtml renders the Page.Body to HTML and sets Page.Html,
|
||||
// Page.Language, Page.Hashtags, and escapes Page.Name.
|
||||
func (p *Page) renderHtml() {
|
||||
parser := parser.New()
|
||||
prev := parser.RegisterInline('[', nil)
|
||||
parser.RegisterInline('[', wikiLink(parser, prev))
|
||||
fn, hashtags := hashtag()
|
||||
parser.RegisterInline('#', fn)
|
||||
if useWebfinger {
|
||||
parser.RegisterInline('@', account)
|
||||
}
|
||||
maybeUnsafeHTML := markdown.ToHTML(p.Body, parser, nil)
|
||||
p.Name = nameEscape(p.Name)
|
||||
p.Html = sanitizeBytes(maybeUnsafeHTML)
|
||||
p.Language = language(p.plainText())
|
||||
p.Hashtags = *hashtags
|
||||
}
|
||||
|
||||
// plainText renders the Page.Body to plain text and returns it,
|
||||
// ignoring all the Markdown and all the newlines. The result is one
|
||||
// long single line of text.
|
||||
func (p *Page) plainText() string {
|
||||
parser := parser.New()
|
||||
doc := markdown.Parse(p.Body, parser)
|
||||
text := []byte("")
|
||||
ast.WalkFunc(doc, func(node ast.Node, entering bool) ast.WalkStatus {
|
||||
if entering && node.AsLeaf() != nil {
|
||||
text = append(text, node.AsLeaf().Literal...)
|
||||
text = append(text, []byte(" ")...)
|
||||
}
|
||||
return ast.GoToNext
|
||||
})
|
||||
// Some Markdown still contains newlines
|
||||
for i, c := range text {
|
||||
if c == '\n' {
|
||||
text[i] = ' '
|
||||
}
|
||||
}
|
||||
// Remove trailing space
|
||||
for len(text) > 0 && text[len(text)-1] == ' ' {
|
||||
text = text[0 : len(text)-1]
|
||||
}
|
||||
return string(text)
|
||||
}
|
||||
64
parser_test.go
Normal file
64
parser_test.go
Normal file
@@ -0,0 +1,64 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"github.com/stretchr/testify/assert"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestPagePlainText(t *testing.T) {
|
||||
p := &Page{Body: []byte(`# Water
|
||||
The air will not come
|
||||
To inhale is an effort
|
||||
The summer heat kills`)}
|
||||
r := "Water The air will not come To inhale is an effort The summer heat kills"
|
||||
assert.Equal(t, r, p.plainText())
|
||||
}
|
||||
|
||||
func TestPageHtml(t *testing.T) {
|
||||
p := &Page{Body: []byte(`# Sun
|
||||
Silver leaves shine bright
|
||||
They droop, boneless, weak and sad
|
||||
A cruel sun stares down`)}
|
||||
p.renderHtml()
|
||||
r := `<h1>Sun</h1>
|
||||
|
||||
<p>Silver leaves shine bright
|
||||
They droop, boneless, weak and sad
|
||||
A cruel sun stares down</p>
|
||||
`
|
||||
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 class="tag" href="/search?q=%23Haiku" rel="nofollow">#Haiku</a> <a class="tag" 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))
|
||||
}
|
||||
92
replace_cmd.go
Normal file
92
replace_cmd.go
Normal file
@@ -0,0 +1,92 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"github.com/google/subcommands"
|
||||
"github.com/hexops/gotextdiff"
|
||||
"github.com/hexops/gotextdiff/myers"
|
||||
"github.com/hexops/gotextdiff/span"
|
||||
"io"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type replaceCmd struct {
|
||||
confirm bool
|
||||
}
|
||||
|
||||
func (cmd *replaceCmd) SetFlags(f *flag.FlagSet) {
|
||||
f.BoolVar(&cmd.confirm, "confirm", false, "do the replacement instead of just doing a dry run")
|
||||
}
|
||||
|
||||
func (*replaceCmd) Name() string { return "replace" }
|
||||
func (*replaceCmd) Synopsis() string { return "Search and replace a regular expression." }
|
||||
func (*replaceCmd) Usage() string {
|
||||
return `replace [-confirm] <regexp> <replacement>:
|
||||
Search a regular expression and replace it. By default, this is a
|
||||
dry run and nothing is saved. The replacement can use $1, $2, etc.
|
||||
to refer to capture groups in the regular expression.
|
||||
`
|
||||
}
|
||||
|
||||
func (cmd *replaceCmd) Execute(_ context.Context, f *flag.FlagSet, _ ...interface{}) subcommands.ExitStatus {
|
||||
return replaceCli(os.Stdout, cmd.confirm, f.Args())
|
||||
}
|
||||
|
||||
func replaceCli(w io.Writer, confirm bool, args []string) subcommands.ExitStatus {
|
||||
if len(args) != 2 {
|
||||
fmt.Fprintln(w, "Replace takes exactly two arguments.")
|
||||
return subcommands.ExitFailure
|
||||
}
|
||||
re := regexp.MustCompile(args[0])
|
||||
repl := []byte(args[1])
|
||||
changes := 0
|
||||
err := filepath.Walk(".", func(path string, info fs.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if info.IsDir() || strings.HasPrefix(path, ".") || !strings.HasSuffix(path, ".md") {
|
||||
return nil
|
||||
}
|
||||
body, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
result := re.ReplaceAll(body, repl)
|
||||
if !slices.Equal(result, body) {
|
||||
changes++
|
||||
if confirm {
|
||||
fmt.Fprintln(w, path)
|
||||
_ = os.Rename(path, path+"~")
|
||||
err = os.WriteFile(path, result, 0644)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
edits := myers.ComputeEdits(span.URIFromPath(path+"~"), string(body), string(result))
|
||||
diff := fmt.Sprint(gotextdiff.ToUnified(path+"~", path, string(body), edits))
|
||||
fmt.Fprintln(w, diff)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
fmt.Fprintln(w, err)
|
||||
return subcommands.ExitFailure
|
||||
}
|
||||
if changes == 1 {
|
||||
fmt.Fprintln(w, "1 change was made.")
|
||||
} else {
|
||||
fmt.Fprintf(w, "%d changes were made.\n", changes)
|
||||
}
|
||||
if !confirm && changes > 0 {
|
||||
fmt.Fprintln(w, "This is a dry run. Use -confirm to make it happen.")
|
||||
}
|
||||
return subcommands.ExitSuccess
|
||||
}
|
||||
44
replace_cmd_test.go
Normal file
44
replace_cmd_test.go
Normal file
@@ -0,0 +1,44 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/google/subcommands"
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// wipes testdata
|
||||
func TestReplaceCmd(t *testing.T) {
|
||||
_ = os.RemoveAll("testdata")
|
||||
index.load()
|
||||
p := &Page{Name: "testdata/pluto", Body: []byte(`# Pluto
|
||||
Out there is a rock
|
||||
And more rocks uncountable
|
||||
You are no planet`)}
|
||||
p.save()
|
||||
|
||||
r := `--- testdata/pluto.md~
|
||||
+++ testdata/pluto.md
|
||||
@@ -1,4 +1,4 @@
|
||||
# Pluto
|
||||
Out there is a rock
|
||||
And more rocks uncountable
|
||||
-You are no planet
|
||||
\ No newline at end of file
|
||||
+You are planetoid
|
||||
\ No newline at end of file
|
||||
|
||||
1 change was made.
|
||||
This is a dry run. Use -confirm to make it happen.
|
||||
`
|
||||
|
||||
b := new(bytes.Buffer)
|
||||
s := replaceCli(b, false, []string{`\bno planet`, `planetoid`})
|
||||
assert.Equal(t, subcommands.ExitSuccess, s)
|
||||
assert.Equal(t, r, b.String())
|
||||
|
||||
t.Cleanup(func() {
|
||||
_ = os.RemoveAll("testdata")
|
||||
})
|
||||
}
|
||||
5
score.go
5
score.go
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
194
search.go
194
search.go
@@ -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"
|
||||
@@ -16,122 +15,111 @@ import (
|
||||
// Currently there is no pagination of results! When a page is part of
|
||||
// a search result, Body and Html are simple extracts.
|
||||
type Search struct {
|
||||
Query string
|
||||
Items []Page
|
||||
Results bool
|
||||
Query string
|
||||
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 {
|
||||
// Otherwise sort by title, ascending.
|
||||
if index.titles[a] < index.titles[b] {
|
||||
return -1
|
||||
} else if a.Title > b.Title {
|
||||
} else if index.titles[a] > index.titles[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)
|
||||
}
|
||||
|
||||
23
search.html
23
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" accesskey="f" 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>
|
||||
@@ -34,6 +43,12 @@ img { max-width: 20%; }
|
||||
<blockquote>{{.Html}}</blockquote>
|
||||
</article>
|
||||
{{end}}
|
||||
<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}}
|
||||
{{else}}
|
||||
<p>No results.</p>
|
||||
{{end}}
|
||||
|
||||
54
search_cmd.go
Normal file
54
search_cmd.go
Normal file
@@ -0,0 +1,54 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"github.com/google/subcommands"
|
||||
"io"
|
||||
"os"
|
||||
)
|
||||
|
||||
type searchCmd struct {
|
||||
page int
|
||||
}
|
||||
|
||||
func (cmd *searchCmd) SetFlags(f *flag.FlagSet) {
|
||||
f.IntVar(&cmd.page, "page", 1, "the page in the search result set")
|
||||
}
|
||||
|
||||
func (*searchCmd) Name() string { return "search" }
|
||||
func (*searchCmd) Synopsis() string { return "Search pages and print a list of links." }
|
||||
func (*searchCmd) Usage() string {
|
||||
return `search [-page <n>] <terms>:
|
||||
Search for pages matching terms and print the result set as a
|
||||
Markdown list. Before searching, all the pages are indexed. Thus,
|
||||
startup is slow. The benefit is that the page order and scores are
|
||||
exactly as when the wiki runs.
|
||||
`
|
||||
}
|
||||
|
||||
func (cmd *searchCmd) Execute(_ context.Context, f *flag.FlagSet, _ ...interface{}) subcommands.ExitStatus {
|
||||
return searchCli(os.Stdout, cmd.page, f.Args())
|
||||
}
|
||||
|
||||
// searchCli runs the search command on the command line. It is used
|
||||
// here with an io.Writer for easy testing.
|
||||
func searchCli(w io.Writer, n int, args []string) subcommands.ExitStatus {
|
||||
index.load()
|
||||
for _, q := range args {
|
||||
items, more, _ := search(q, n)
|
||||
if len(items) == 1 {
|
||||
fmt.Fprintf(w, "Search for %s, page %d: 1 result\n", q, n)
|
||||
} else {
|
||||
fmt.Fprintf(w, "Search for %s, page %d: %d results\n", q, n, len(items))
|
||||
}
|
||||
for _, p := range items {
|
||||
fmt.Fprintf(w, "* [%s](%s) (%d)\n", p.Title, p.Name, p.Score)
|
||||
}
|
||||
if more {
|
||||
fmt.Fprintf(w, "There are more results\n")
|
||||
}
|
||||
}
|
||||
return subcommands.ExitSuccess
|
||||
}
|
||||
19
search_cmd_test.go
Normal file
19
search_cmd_test.go
Normal file
@@ -0,0 +1,19 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"testing"
|
||||
"github.com/google/subcommands"
|
||||
)
|
||||
|
||||
func TestSearchCmd(t *testing.T) {
|
||||
b := new(bytes.Buffer)
|
||||
s := searchCli(b, 1, []string{"oddµ"})
|
||||
assert.Equal(t, subcommands.ExitSuccess, s)
|
||||
r := `Search for oddµ, page 1: 2 results
|
||||
* [Oddµ: A minimal wiki](README) (5)
|
||||
* [Welcome to Oddµ](index) (5)
|
||||
`
|
||||
assert.Equal(t, r, b.String())
|
||||
}
|
||||
118
search_test.go
118
search_test.go
@@ -1,102 +1,30 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"net/url"
|
||||
"testing"
|
||||
"os"
|
||||
)
|
||||
|
||||
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")
|
||||
}
|
||||
|
||||
// wipes testdata
|
||||
func TestSearchQuestionmark(t *testing.T) {
|
||||
_ = os.RemoveAll("testdata")
|
||||
p := &Page{Name: "testdata/Odd?", Body: []byte(`# Even?
|
||||
|
||||
yes or no?`)}
|
||||
p.save()
|
||||
data := url.Values{}
|
||||
data.Set("q", "yes")
|
||||
body := assert.HTTPBody(searchHandler, "GET", "/search", data)
|
||||
assert.Contains(t, body, "yes or no?")
|
||||
assert.NotContains(t, body, "Odd?")
|
||||
assert.Contains(t, body, "Even?")
|
||||
}
|
||||
|
||||
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")
|
||||
})
|
||||
}
|
||||
82
view.go
Normal file
82
view.go
Normal file
@@ -0,0 +1,82 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// 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. As far as
|
||||
// caching goes: we respond with a 304 NOT MODIFIED if the request has
|
||||
// an If-Modified-Since header that matches the file's modification
|
||||
// time, truncated to one second, because the file's modtime has
|
||||
// sub-second precision and the HTTP timestamp for the Last-Modified
|
||||
// header has not.
|
||||
func viewHandler(w http.ResponseWriter, r *http.Request, name string) {
|
||||
file := true
|
||||
rss := false
|
||||
fn := name
|
||||
fi, err := os.Stat(fn)
|
||||
if err != nil {
|
||||
file = false
|
||||
if strings.HasSuffix(fn, ".rss") {
|
||||
rss = true
|
||||
name = fn[0:len(fn)-4]
|
||||
fn = name
|
||||
}
|
||||
fn += ".md"
|
||||
fi, err = os.Stat(fn)
|
||||
}
|
||||
if err == nil {
|
||||
h, ok := r.Header["If-Modified-Since"]
|
||||
if ok {
|
||||
ti, err := http.ParseTime(h[0])
|
||||
if err == nil && !fi.ModTime().Truncate(time.Second).After(ti) {
|
||||
w.WriteHeader(http.StatusNotModified)
|
||||
return
|
||||
}
|
||||
}
|
||||
w.Header().Set("Last-Modified", fi.ModTime().UTC().Format(http.TimeFormat))
|
||||
}
|
||||
if r.Method == http.MethodHead {
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/edit/"+name, http.StatusFound)
|
||||
}
|
||||
if file {
|
||||
body, err := os.ReadFile(fn)
|
||||
if err != nil {
|
||||
// This is an internal error because os.Stat
|
||||
// says there is a file. Non-existent files
|
||||
// are treated like pages.
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
w.Write(body)
|
||||
return
|
||||
}
|
||||
p, err := loadPage(name)
|
||||
if err != nil {
|
||||
http.Redirect(w, r, "/edit/"+name, http.StatusFound)
|
||||
return
|
||||
}
|
||||
p.handleTitle(true)
|
||||
if rss {
|
||||
it := feed(p, fi.ModTime())
|
||||
w.Write([]byte(`<?xml version="1.0" encoding="UTF-8"?>`))
|
||||
renderTemplate(w, "feed", it)
|
||||
return
|
||||
}
|
||||
p.renderHtml()
|
||||
renderTemplate(w, "view", p)
|
||||
}
|
||||
17
view.html
17
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,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}}" accesskey="e">Edit</a>
|
||||
<a href="/add/{{.Name}}" accesskey="a">Add</a>
|
||||
<a href="/upload/{{.Dir}}" accesskey="u">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" accesskey="f" required>
|
||||
<button>Go</button>
|
||||
</form>
|
||||
</header>
|
||||
<main id="main">
|
||||
@@ -30,7 +35,7 @@ img { max-width: 100%; }
|
||||
</main>
|
||||
<footer>
|
||||
<address>
|
||||
Comments? Send mail to Alex Schroeder <<a href="mailto:alex@alexschroeder.ch">alex@alexschroeder.ch</a>>
|
||||
Comments? Send mail to Your Name <<a href="mailto:you@example.org">you@example.org</a>>
|
||||
</address>
|
||||
</footer>
|
||||
</body>
|
||||
|
||||
122
view_test.go
Normal file
122
view_test.go
Normal file
@@ -0,0 +1,122 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"github.com/stretchr/testify/assert"
|
||||
"net/http"
|
||||
"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")
|
||||
})
|
||||
}
|
||||
|
||||
// wipes 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\" accesskey=\"e\">Edit</a>")
|
||||
|
||||
t.Cleanup(func() {
|
||||
_ = os.RemoveAll("testdata")
|
||||
})
|
||||
}
|
||||
|
||||
// wipes testdata
|
||||
func TestFileLastModified(t *testing.T) {
|
||||
_ = os.RemoveAll("testdata")
|
||||
assert.NoError(t, os.Mkdir("testdata", 0755))
|
||||
assert.NoError(t, os.WriteFile("testdata/now.txt", []byte(`
|
||||
A spider sitting
|
||||
Unmoving and still
|
||||
In the autumn chill
|
||||
`), 0644))
|
||||
fi, err := os.Stat("testdata/now.txt")
|
||||
assert.NoError(t, err)
|
||||
h := makeHandler(viewHandler, true)
|
||||
assert.Equal(t, []string{fi.ModTime().UTC().Format(http.TimeFormat)},
|
||||
HTTPHeaders(h, "GET", "/view/testdata/now.txt", nil, "Last-Modified"))
|
||||
HTTPStatusCodeIfModifiedSince(t, h, "/view/testdata/now.txt", fi.ModTime())
|
||||
t.Cleanup(func() {
|
||||
_ = os.RemoveAll("testdata")
|
||||
})
|
||||
}
|
||||
|
||||
// wipes testdata
|
||||
func TestPageLastModified(t *testing.T) {
|
||||
_ = os.RemoveAll("testdata")
|
||||
p := &Page{Name: "testdata/now", Body: []byte(`
|
||||
The sky glows softly
|
||||
Sadly, the birds are quiet
|
||||
I like spring better
|
||||
`)}
|
||||
p.save()
|
||||
fi, err := os.Stat("testdata/now.md")
|
||||
assert.NoError(t, err)
|
||||
h := makeHandler(viewHandler, true)
|
||||
assert.Equal(t, []string{fi.ModTime().UTC().Format(http.TimeFormat)},
|
||||
HTTPHeaders(h, "GET", "/view/testdata/now", nil, "Last-Modified"))
|
||||
HTTPStatusCodeIfModifiedSince(t, h, "/view/testdata/now", fi.ModTime())
|
||||
t.Cleanup(func() {
|
||||
_ = os.RemoveAll("testdata")
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
// wipes testdata
|
||||
func TestPageHead(t *testing.T) {
|
||||
_ = os.RemoveAll("testdata")
|
||||
p := &Page{Name: "testdata/peace", Body: []byte(`
|
||||
No urgent typing
|
||||
No todos, no list, no queue.
|
||||
Just me and the birds.
|
||||
`)}
|
||||
p.save()
|
||||
fi, err := os.Stat("testdata/peace.md")
|
||||
assert.NoError(t, err)
|
||||
h := makeHandler(viewHandler, true)
|
||||
assert.Equal(t, []string(nil),
|
||||
HTTPHeaders(h, "HEAD", "/view/testdata/war", nil, "Last-Modified"))
|
||||
assert.Equal(t, []string(nil),
|
||||
HTTPHeaders(h, "GET", "/view/testdata/war", nil, "Last-Modified"))
|
||||
assert.Equal(t, []string{fi.ModTime().UTC().Format(http.TimeFormat)},
|
||||
HTTPHeaders(h, "HEAD", "/view/testdata/peace", nil, "Last-Modified"))
|
||||
assert.Equal(t, "",
|
||||
assert.HTTPBody(h, "HEAD", "/view/testdata/peace", nil))
|
||||
t.Cleanup(func() {
|
||||
_ = os.RemoveAll("testdata")
|
||||
})
|
||||
}
|
||||
154
wiki.go
154
wiki.go
@@ -1,23 +1,27 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"github.com/google/subcommands"
|
||||
"html/template"
|
||||
"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", "feed.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 +38,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 +55,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 +65,67 @@ 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()
|
||||
initAccounts()
|
||||
port := getPort()
|
||||
fmt.Printf("Serving a wiki on port %s\n", port)
|
||||
http.ListenAndServe(":"+port, nil)
|
||||
}
|
||||
|
||||
// commands does the command line parsing in case Oddmu is called with
|
||||
// some arguments. Without any arguments, the wiki server is started.
|
||||
// At this point we already know that there is at least one
|
||||
// subcommand.
|
||||
func commands() {
|
||||
subcommands.Register(subcommands.HelpCommand(), "")
|
||||
subcommands.Register(subcommands.FlagsCommand(), "")
|
||||
subcommands.Register(subcommands.CommandsCommand(), "")
|
||||
subcommands.Register(&htmlCmd{}, "")
|
||||
subcommands.Register(&searchCmd{}, "")
|
||||
subcommands.Register(&replaceCmd{}, "")
|
||||
|
||||
flag.Parse()
|
||||
ctx := context.Background()
|
||||
os.Exit(int(subcommands.Execute(ctx)))
|
||||
}
|
||||
|
||||
func main() {
|
||||
if len(os.Args) == 1 {
|
||||
serve()
|
||||
} else {
|
||||
commands()
|
||||
}
|
||||
}
|
||||
|
||||
78
wiki_test.go
Normal file
78
wiki_test.go
Normal file
@@ -0,0 +1,78 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 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
|
||||
}
|
||||
// HTTPStatusCodeIfModifiedSince checks that the request results in a
|
||||
// 304 response for the given time.
|
||||
func HTTPStatusCodeIfModifiedSince(t *testing.T, handler http.HandlerFunc, url string, ti time.Time) {
|
||||
w := httptest.NewRecorder()
|
||||
req, err := http.NewRequest("GET", url, nil)
|
||||
assert.NoError(t, err)
|
||||
req.Header.Set("If-Modified-Since", ti.UTC().Format(http.TimeFormat))
|
||||
handler(w, req)
|
||||
assert.Equal(t, http.StatusNotModified, w.Code)
|
||||
}
|
||||
Reference in New Issue
Block a user