240 Commits
v0.8 ... v1.5

Author SHA1 Message Date
Alex Schroeder
a08df076fa Add ODDMU_FILTER environment variable
This is used to hide secret subdirectories from search since search
includes subdirectories.
2024-02-13 23:17:22 +01:00
Alex Schroeder
a5a7549bec Add version command
As suggested by Xavier B.
2024-02-13 15:32:07 +01:00
Alex Schroeder
883f3fee47 Add one more system to the Makefile 2024-02-13 15:06:10 +01:00
Alex Schroeder
6a4d1e5ca9 Better distinction between path and filepath
Make sure to serve the page foo if both foo.md and the directory foo/
exist.
2024-02-13 15:06:01 +01:00
Alex Schroeder
8698c64dda Don't log missing pages in the index
Only existing pages are in the index. Therefore there is no point in
logging pages missing in the index since this happens for every new
page.
2024-02-11 12:57:53 +01:00
Alex Schroeder
dade693451 Log usernames when saving files
Return an error when page notification breaks in the appendHandler,
just like it does in the saveHandler and the dropHandler.
2024-02-11 09:31:48 +01:00
Alex Schroeder
b5272803f5 More logging improvements
Errors in p.save aren't logged, so the saveHandler must log them.

Errors in p.notify are logged, so the saveHandler must not log them.
That's because in this situation, the errors are not directly related
to page saving (which is what the saveHandler does). However, the user
must still be made aware of the error, so a HTTP error page is shown.

When backup fails (since it now returns an error), save does not
proceed.

The helper functions in notify.go using c as the variable for the
changes page, the index page, or a hashtag page now all use p.
2024-02-11 00:01:56 +01:00
Alex Schroeder
3f586a51f1 Log file saves and fix potential backup problem
Log file saves and deletes.

When logging errors, just log err.

Better HTTP status codes. If something is a bad request, say so. If
something really is an internal error, log it.

Saving a file foo/bar would rename bar to bar~ (in the root
directory!) instead of renaming foo/bar to foo/bar~! Perhaps this was
the source of the sometimes disappearing view.html file during
testing? It doesn't explain why this happend only sometimes, though.

Also make sure that backup rename errors only get reported if the
original file exists.

Rename updateIndex to update.
2024-02-10 13:49:29 +01:00
Alex Schroeder
72aad37979 Ensure watcher never sees ./ prefix 2024-02-10 00:47:07 +01:00
Alex Schroeder
93de7b2517 Move hidden segment check into makeHandler
Apply it to the id, too.
2024-02-10 00:46:41 +01:00
Alex Schroeder
afda27be76 Fix more errors regarding tree walking
Also prevent hidden files from being shown.
2024-02-10 00:35:02 +01:00
Alex Schroeder
9457d38cc1 Fix errors regarding skipping hidden files
When looking for hidden files, one needs to check whether the base
name of the path starts with a period. Sadly, a few places checked the
path itself, which only makes sense in the root directory.
2024-02-09 21:36:28 +01:00
Alex Schroeder
cd92bea269 Fixing stuff in index and watch, again 2024-02-09 20:33:36 +01:00
Alex Schroeder
fdaf1303c8 Update to the static man page 2024-02-09 19:11:04 +01:00
Alex Schroeder
d5286c9633 Switching to shuffled tests and fixing stuff 2024-02-09 18:51:35 +01:00
Alex Schroeder
a1469a7c19 Small test data fix 2024-02-09 17:42:44 +01:00
Alex Schroeder
31065ea95e Fix comment 2024-02-09 17:42:44 +01:00
Alex Schroeder
785b861ba0 Remove useless prefix to error log print 2024-02-09 17:42:44 +01:00
Alex Schroeder
2ecc0861f0 Don't watch hidden files or directories 2024-02-09 17:42:44 +01:00
Alex Schroeder
e7be104718 Add more assertions in the templates test
There remains the mysterious case of tests running and occasionally
deleting view.html. Why?
2024-02-09 17:42:44 +01:00
Alex Schroeder
6bec3500c3 Remove commented test
This test is never going to be brought back since it results in
network traffic.
2024-02-09 17:42:44 +01:00
Alex Schroeder
9b3dfd31d0 Add ignores map to the watches
- renamed watchDo to watchDoUpdate and takes ignore map into account
- add watchDoRemove which takes ignore map into account
- watchTimer now takes a path argument instead of iterating over all
  files
2024-02-09 17:42:17 +01:00
Alex Schroeder
3f876c6326 Delete some comments 2024-02-09 17:02:52 +01:00
Alex Schroeder
74b192a609 deleteDocument no longer requires the original text
If only hashtags are indexed, then there are comparatively few tokens
and we can search them all when deindexing a file. This has the
benefit of no longer requiring the old text to be provided (which is a
requirement to be able to handle filesystem deletes).
2024-02-09 17:00:40 +01:00
Alex Schroeder
c39ad26901 Wrap sort in an index read lock
sortNames access the index and that requires a read lock.
2024-02-09 16:58:24 +01:00
Alex Schroeder
293e5bc1c5 Whitespace changes
Reformatting some comments to the new width.
2024-02-09 16:57:29 +01:00
Alex Schroeder
1b3dc89f28 Fix order of arguments in test 2024-02-09 16:56:38 +01:00
Alex Schroeder
7374114bbd Whitespace changes
Reformatting some comments to the new width.
2024-02-09 16:56:10 +01:00
Alex Schroeder
0802091da1 Rename filename to path in page.go
I want to move away from "filename" and use "path", "base" and "dir"
instead.
2024-02-09 16:54:51 +01:00
Alex Schroeder
148aff3d74 Turn templates into a struct with mutex
Since templates can be updated in parallel and they're in a map, a
mutex is required.
2024-02-09 16:48:22 +01:00
Alex Schroeder
bcaa51506c Moved some code into init functions
- initAccounts
- watches.files initialisation
2024-02-09 16:44:20 +01:00
Alex Schroeder
2d12168612 Fix pattern in man/Makefile 2024-02-09 01:22:13 +01:00
Alex Schroeder
c8197cd811 Makefile to turn man pages into markdown files 2024-02-09 01:10:52 +01:00
Alex Schroeder
791b95e80d Rewrite watches: handle file creation events
When a file is moved or renamed into the directory, this is a Create
event, not a Write event. So handle it appropriately and immediately.
2024-02-09 01:06:53 +01:00
Alex Schroeder
f0517a1d30 Mention oddmu-notify in the main man page 2024-02-09 00:41:15 +01:00
Alex Schroeder
6cf1a2dc9e Return when unable to stat file
Otherwise fi.IsDir() turns into a panic.
2024-02-09 00:15:31 +01:00
Alex Schroeder
c236adf0d6 Add links to the README 2024-02-08 01:09:41 +01:00
Alex Schroeder
6f48140958 Restructure watches and write test 2024-02-07 21:48:56 +01:00
Alex Schroeder
85b3b63af8 Reverse the order of return values
watches.install should return error as the last value.
2024-02-07 20:57:29 +01:00
Alex Schroeder
739787f582 Bring fsnotify back
The goal is to allow users to make changes to the files directly and
the wiki still takes these into account.
2024-02-07 17:45:49 +01:00
Alex Schroeder
ffe39953a4 Note the template files in the main man page 2024-02-06 23:51:18 +01:00
Alex Schroeder
e0fa8756ea Run go fmt and reword comment 2024-02-06 23:44:07 +01:00
Alex Schroeder
0cb4291394 Fix TestUploadDir so no files are left behind 2024-02-06 23:43:33 +01:00
Alex Schroeder
fd6bce6418 Delete files when uploading 0 byte files 2024-02-06 23:42:59 +01:00
Alex Schroeder
7e5d8b768c Pages in subdirectories prefer local templates
When rendering templates, pass along the directory of the page or data
being rendered and if the required template is found in that
directory, prefer using that.
2024-02-06 23:42:36 +01:00
Alex Schroeder
ff04c08537 Get rid of fsnotify
When uploading a file via the drop handler, check whether the saved
file is a valid template and if so, reload the template. There is no
point in using fsnotify unless we do the same thing for the index.
That would be interesting, in fact. But it's also complicated and
gives me a headache.
2024-02-06 22:06:20 +01:00
Alex Schroeder
57763c661b Recursively add watchers to all directories 2024-02-06 21:54:39 +01:00
Alex Schroeder
67d9a2a178 Move template code from wiki.go into templates.go 2024-02-05 23:25:57 +01:00
Alex Schroeder
5083787f7c Switch assert.Regexp to assert.Contains 2024-02-05 23:25:28 +01:00
Alex Schroeder
3ffe58d4b9 Document that templates can be changed uploading 2024-02-04 23:38:35 +01:00
Alex Schroeder
d8ee0ff963 Switch to a single file per template
This allows us to correctly reload them even when the template has
been executed.
2024-02-04 23:34:44 +01:00
Alex Schroeder
c829725f91 More documentation for the template reloading code 2024-02-04 23:07:51 +01:00
Alex Schroeder
0fb599bf8b Reload templates on demand
There is now a global variable to hold the parsed templates, and a
fsnotify watcher that watches the directory for any changes to the
templates. If a template is written to, a go routine is started which
waits for a second and then checks whether any of the files files that
were written to haven't been written to in the last second. If so, it
is assumed that writing is finished and the template is reloaded.

Thus, if file is written to twice, two go routines start, the first
one doesn't find a file that's old enough but the second one does, and
so the template is reloaded.

Previously, it would reload all the templates for every request that
needs a template. And before that, it would only load the templates as
the wiki started up.
2024-02-04 19:51:24 +01:00
Alex Schroeder
eec2a477ab Fix tests for the upload template 2024-02-04 19:50:34 +01:00
Alex Schroeder
0b0315802c Document {{.Base}} and id= 2024-02-03 22:46:05 +01:00
Alex Schroeder
efa9175c71 Suggest filename for uploads
Add a new function Base() for pages.
Use this in the view template to suggest an image upload.
2024-02-03 22:27:39 +01:00
Alex Schroeder
3b4211bc61 Run go fmt 2024-02-02 22:51:34 +01:00
Alex Schroeder
0283be53d6 Update dependencies 2024-02-02 22:50:27 +01:00
Alex Schroeder
5b1558cc57 Add a tiny test to rule out some bug 2024-02-02 22:49:42 +01:00
Alex Schroeder
1cd68929ff Test id parameter with subdirectory
This tests a construct like the following in view.html:

<form action="/edit/" method="GET">
  <label for="id">New page:</label>
  <input id="id" type="text" spellcheck="false" name="id"
         accesskey="g" value="{{.Dir}}/{{.Today}}" required>
  <button>Edit</button>
</form>

For a while I tried to handle mixed situations where both URL path and
query parameter have to be handled but I got strange 500 status codes.
Basically I wanted to support the following and was unable to write
code and a test for it to make that work:

<form action="/edit/{{.Dir}}" method="GET">
  <label for="id">New page:</label>
  <input id="id" type="text" spellcheck="false" name="id"
         accesskey="g" value="{{.Today}}" required>
  <button>Edit</button>
</form>

I kept getting a strange 500 code and duplicate headers according to
the tests. Very strange.
2024-02-02 22:48:41 +01:00
Alex Schroeder
d7711832c6 Home and changes link point to the local files 2024-02-02 22:48:00 +01:00
Alex Schroeder
304b803114 Explain static CSS file 2024-01-29 13:15:42 +01:00
Alex Schroeder
eef226f9d2 Typo 2024-01-29 13:11:18 +01:00
Alex Schroeder
e72a4418fd Updated Apache documentation
Dropping some unnecessary rewrite rules and prefer a redirect match.
2024-01-28 13:44:25 +01:00
Alex Schroeder
912957c990 Return a 404 from the root Handler
Otherwise any URL that isn't handled by Oddmu results in a redirect to
the index. This confuses me when trying to understand how redirects,
rewrites and proxying work on the web server.
2024-01-28 12:38:59 +01:00
Alex Schroeder
b64d56a648 Document a curious bug with the Apache configuration 2024-01-27 21:27:17 +01:00
Alex Schroeder
ce64d04dde Switch to 70ch max-width for all 2024-01-17 22:09:11 +01:00
Alex Schroeder
de5bd2d23e Remove restore and simplify cleanup
The test setups are now simplified, since no files in the source
directory are modified.
2024-01-17 14:15:07 +01:00
Alex Schroeder
29842fe685 Major change in how notifications work 2024-01-17 14:10:07 +01:00
Alex Schroeder
4042be68f3 Reformatting of documentation, man page generation
Fill column is 80, no double spaces after sentence endings.
2024-01-17 14:06:05 +01:00
Christopher Brannon
87846e15b9 Support socket activation.
This also removes the Unix socket support added yesterday.  To use
Unix sockets, use socket activation and let systemd manage the socket.
2024-01-17 13:56:05 +01:00
Alex Schroeder
1390d82e29 Update man page 2024-01-16 14:02:32 +01:00
Alex Schroeder
bb5bd1c629 Small comment fix
Reformatted comment for 120 characters. Note that the presence of a
forward-slash anywhere in ODDMU_ADDRESS makes this a Unix-domain
socket.
2024-01-16 13:46:09 +01:00
Christopher Brannon
777c498700 Allow specifying the listen address.
This patch also adds support for HTTP over a Unix-domain socket.
2024-01-16 13:43:01 +01:00
Alex Schroeder
803025f56a Fix test for new year 2024-01-16 13:43:01 +01:00
Alex Schroeder
dce66ec5a1 Silence output of static command for tests 2024-01-16 13:43:01 +01:00
Alex Schroeder
b6d596cb08 Improve man page for static subcommand
Discuss what it means to have some files hard linked.
Discuss the use of ODDMU_LANGUAGES to speed site generation up.
2024-01-10 07:37:33 +01:00
Alex Schroeder
3be26b9af1 Load languages before generating static site
Without it, all HTML files have lang="".

Also added a progress indication because generating the site takes a
lot longer, now.
2024-01-10 07:20:47 +01:00
Alex Schroeder
197f9b78f1 Add more links to man pages to the README 2024-01-07 11:47:49 +01:00
Alex Schroeder
0ebcd1a4ef Only load templates if they are required 2024-01-07 00:33:56 +01:00
Alex Schroeder
9591913acc Improve the README 2024-01-07 00:32:52 +01:00
Alex Schroeder
2a82435b92 Add oddmu-list to the README 2023-12-20 22:56:13 +01:00
Alex Schroeder
844f623f26 Update man pages for list and search subcommands 2023-12-20 08:22:25 +01:00
Alex Schroeder
e9b128d98c Return relative links from search
When search is limited to a directory, return relative links. This
makes it possible to paste the search result on to an "index.md" file
in that same directory.
2023-12-20 08:20:52 +01:00
Alex Schroeder
1ce8182571 Search CLI with -dir
Allow limiting search to a subdirectory from the command-line.
2023-12-20 08:11:17 +01:00
Alex Schroeder
fefc00e2a2 Lower case doc string for options 2023-12-20 08:09:25 +01:00
Alex Schroeder
b44821d6de Add man page for oddmu-list 2023-12-17 23:08:11 +01:00
Alex Schroeder
1174369e8a Deleted TODO file 2023-12-17 22:55:35 +01:00
Alex Schroeder
b31745a5e4 Rewrote HEIC support to use bashdrew's code 2023-12-17 22:55:35 +01:00
Alex Schroeder
ce70c97b6a Add new list subcommand argument and list test 2023-12-17 22:55:35 +01:00
Alex Schroeder
0f2dd71449 Update all dependencies 2023-12-09 21:26:17 +01:00
Alex Schroeder
7074995d9a Add HEIC decoding 2023-12-09 21:23:05 +01:00
Alex Schroeder
d2552b2f68 Better image upload instructions 2023-12-09 09:36:48 +01:00
Alex Schroeder
e231412bdb Add list command 2023-12-09 09:21:24 +01:00
Alex Schroeder
0481d9003c Do not overwrite fresh backups
If the backup file is less than an hour old, do not overwrite it.
2023-11-24 13:49:41 +01:00
Alex Schroeder
7060f8a027 Fix handling of directories
For subdirectories, the redirect my be to an absolute URL starting
with /view.
2023-11-19 12:36:25 +01:00
Alex Schroeder
1cc5fcb823 Show index page for directory URLs 2023-11-19 12:21:51 +01:00
Alex Schroeder
f392d18dc9 Ensure empty line when adding 2023-11-18 23:48:13 +01:00
Alex Schroeder
6ab51afa30 Document the fact that HTML is not sanitized 2023-11-14 15:34:29 +01:00
Alex Schroeder
a73328ca2e Remove all HTML sanitization *again*!!
The previous revision is the one with HTML and SVG sanitization.
I'm removing it because I think the authors are all trusted users
and so they produce trusted HTML.
2023-11-14 14:07:32 +01:00
Alex Schroeder
ee3afc3384 Follow dependabot recommendations
Upgraded markdown, net, and image libraries.
2023-11-12 12:51:25 +01:00
Alex Schroeder
160ebd71e2 go fmt 2023-11-12 12:39:48 +01:00
Alex Schroeder
8900725737 go mod tidy 2023-11-12 12:38:55 +01:00
Alex Schroeder
f58476bba5 Merge branch 'svg-sanitization' 2023-11-12 12:22:45 +01:00
Alex Schroeder
95a29264a5 Typos, omissions 2023-11-12 12:22:08 +01:00
Alex Schroeder
dcb0cc7f51 Revert "No longer sanitize the HTML output!"
This reverts commit eb44880e8e.
2023-11-12 12:12:19 +01:00
Alex Schroeder
51e47dacd9 SVG sanitization 2023-11-09 12:31:44 +01:00
Alex Schroeder
eb44880e8e No longer sanitize the HTML output! 2023-11-09 07:23:51 +01:00
Alex Schroeder
c3e0cdc50d Allow block level attributes 2023-11-07 17:34:47 +01:00
Alex Schroeder
3c08c316d5 Do not always add blog posts to the index page
Only do it for blog posts in this year.
2023-11-06 13:46:18 +01:00
Alex Schroeder
0da22a69da Added notify command 2023-11-05 10:48:32 +01:00
Alex Schroeder
be3ca7903d Fix changes for empty date headings 2023-10-30 13:17:59 +01:00
Alex Schroeder
07ecd1d27c Allow passing pagename with id parameter
Also add p.Today so that templates can have a link to today's page.
Document two examples.
2023-10-29 19:56:31 +01:00
Alex Schroeder
1a7a271669 Documentation for p.Dir() 2023-10-29 10:13:17 +01:00
Alex Schroeder
7247906942 An example for the "exact match" explanation 2023-10-29 00:06:34 +02:00
Alex Schroeder
73c33042ee More documentation about automatic links 2023-10-28 23:59:52 +02:00
Alex Schroeder
3ff1b4d218 grep gets an argument to keep the first page 2023-10-28 23:02:56 +02:00
Alex Schroeder
c4ebb7321b Add date page links to index 2023-10-28 13:34:36 +02:00
Alex Schroeder
098fc1ba19 Unescape the page name for diff
Diff needs to read the files after renderHtml has already escaped the
page name. Perhaps a different solution would be better: two
attributes for Page and changing all the templates to use the new
EscapedName attribute?
2023-10-28 00:56:32 +02:00
Alex Schroeder
541567425f Look over addLinkWithDate and addLink again
addLinkWithDate is only called once, so determine date within the
function, not within the caller.

addLink only saves if the page hasn't changed. A change in page title
also counts as a change.
2023-10-28 00:00:52 +02:00
Alex Schroeder
e12d145ce5 Prepend exact matches to the top of search results 2023-10-28 00:00:03 +02:00
Alex Schroeder
cdf2c01512 Automatically add link to existing hashtag pages 2023-10-27 00:55:43 +02:00
Alex Schroeder
4a961de768 Add "all" parameter to search 2023-10-27 00:10:38 +02:00
Alex Schroeder
8325e50546 Lazy loading of images 2023-10-16 23:42:23 +02:00
Alex Schroeder
8a58d0a9f9 How to survive a reboot 2023-10-16 19:46:01 +02:00
Alex Schroeder
e63317e103 Only save changes file if there is an actual change
This maintains the old diff and the timestamp.
2023-10-15 23:24:40 +02:00
Alex Schroeder
c97028cf9e Small documentation improvements 2023-10-12 19:54:55 +02:00
Alex Schroeder
a4ea3232f8 Calling it a memex 2023-10-12 16:30:54 +02:00
Alex Schroeder
ea36761cbb Don't panic when a page file disappeared 2023-10-12 15:56:18 +02:00
Alex Schroeder
8b57b2d9c0 Document the need for ./ prefixes in links 2023-10-12 15:34:37 +02:00
Alex Schroeder
8d49e01282 Errors from subcommands should go to STDERR 2023-10-12 15:33:59 +02:00
Alex Schroeder
8194f03d89 Make sure links to named anchor don't count as missing 2023-10-12 11:29:39 +02:00
Alex Schroeder
bbb98921b4 Warn about changing the files as the server runs 2023-10-12 11:29:09 +02:00
Alex Schroeder
0415fdc27f Fix grammar in output of "replace" subcommand 2023-10-12 09:00:59 +02:00
Alex Schroeder
d17b3f0512 Fix handling of .rss links
The .rss suffix was stripped from the link, but with the recent change
of deriving the destination from the URL path, this has no effect
except for the message printed. So now the .rss suffix is stripped
from the URL path.
2023-10-12 08:55:38 +02:00
Alex Schroeder
ad67738053 Use h2 for date headings 2023-10-11 12:48:26 +02:00
Alex Schroeder
be1e656c04 Add date handling to changes 2023-10-11 12:37:35 +02:00
Alex Schroeder
c7952b30de Move changes to separate file 2023-10-11 08:20:23 +02:00
Alex Schroeder
72365e788a Fix test: searchCli args changed 2023-10-11 07:56:33 +02:00
Alex Schroeder
cda595c4f4 Allow _ in account names 2023-10-10 23:36:49 +02:00
Alex Schroeder
3e27e920ad Explain the fix to a URL parsing error
When running the "missing" subcommand or when editing a page, trying
to link to a page containg a colon can be problematic. You can get the
error "first path segment in +URL cannot contain colon". The solution
is to prepend "./".
2023-10-10 23:25:23 +02:00
Alex Schroeder
832f14ba89 Fix error message report
The important part is naming the page.
2023-10-10 23:25:02 +02:00
Alex Schroeder
149e8dde25 Simplify error message report 2023-10-10 22:38:01 +02:00
Alex Schroeder
b0561e37cc Allow linking to gopher and gemini sites 2023-10-10 22:37:47 +02:00
Alex Schroeder
02e62a87b8 Two special files: index.md and changes.md 2023-10-10 19:11:03 +02:00
Alex Schroeder
403732ae3d Improved the SEE ALSO section of oddmu(1) 2023-10-10 11:15:13 +02:00
Alex Schroeder
2736e76d07 Add optional extracts to the search command 2023-10-10 11:13:44 +02:00
Alex Schroeder
14bc2d0da4 Mention diff.go in the README 2023-10-10 11:13:17 +02:00
Alex Schroeder
9d43a8af40 Changed CSS for templates
Be careful when updating your templates because you don't want to
overwrite your domain name, admin name and email address in the
templates.
2023-10-10 11:12:28 +02:00
Alex Schroeder
10554bd2ec Update man pages
Mostly, this is just for the 80 character width, but for the diff
template, it also documents {{.Diff}}.
2023-10-09 22:08:53 +02:00
Alex Schroeder
4af07d4002 Add notify checkbox to the add.html template 2023-10-09 21:52:04 +02:00
Alex Schroeder
2dcac930d2 Add links to the source files for the diff template 2023-10-09 21:51:51 +02:00
Alex Schroeder
5bc399283b Bugfix of one changes.md modification 2023-10-09 21:51:06 +02:00
Alex Schroeder
d4dbe4e8df Add notification for changes 2023-10-09 21:00:42 +02:00
Alex Schroeder
f2276a969b Add diff 2023-10-09 18:41:37 +02:00
Alex Schroeder
d0ceb4cec5 Add source overview to the README 2023-10-09 10:04:42 +02:00
Alex Schroeder
f8cf57898c More on linking in the man page 2023-10-09 09:52:25 +02:00
Alex Schroeder
f29b7ed016 Explain linking to Markdown files 2023-10-08 23:12:02 +02:00
Alex Schroeder
c371cc9030 Small documentation fixes 2023-10-07 12:30:35 +02:00
Alex Schroeder
c25d1898ac The static subcommand now uses a template 2023-10-07 12:30:35 +02:00
Alex Schroeder
f660ee0f10 Add static command 2023-10-07 00:36:10 +02:00
Alex Schroeder
270f190cce Use propper URL parsing 2023-10-07 00:03:38 +02:00
Alex Schroeder
1ee5b80d48 go fmt 2023-10-07 00:03:15 +02:00
Alex Schroeder
9f1c0ff90b Remove MathJax support
It gets in the way of talking about typical command line stuff like
environment variables.
2023-10-05 10:21:36 +02:00
Alex Schroeder
f10ac34424 Add test for the "missing" subcommand 2023-10-05 10:04:46 +02:00
Alex Schroeder
d3b6ea30a6 Fix link in README 2023-10-05 10:03:25 +02:00
Alex Schroeder
d42021521b Missing subcommand knows more schemas 2023-10-05 09:45:48 +02:00
Alex Schroeder
f99a092d8c Missing subcommand accepts other files
Files that don't have a .md extension are legitimate link targets.
2023-10-05 09:36:02 +02:00
Alex Schroeder
1ce5ec71ff New function wikiParser
This puts the parser setup in one place for easy modification since
the parser is being used in more than one place.
2023-10-05 09:36:02 +02:00
Alex Schroeder
d39d9c198d Replace does regular string search by default
This adds the -regexp flag.
2023-10-05 09:35:42 +02:00
Alex Schroeder
40998cf584 Fix error when deleting a page
Can't remove the file after renaming it. Add tests.
2023-10-04 19:01:01 +02:00
Alex Schroeder
68293398b0 Add "missing" subcommand 2023-10-04 19:00:14 +02:00
Alex Schroeder
6e74c69dfc Fix typo in man page 2023-10-03 15:30:14 +02:00
Alex Schroeder
7ba64186f3 Fix URLs 2023-10-03 13:44:54 +02:00
Alex Schroeder
62faaee25a Update man pages and link to them from the README 2023-10-03 13:40:43 +02:00
Alex Schroeder
4db9c4041d Fixes for the the upload/drop flow 2023-10-03 08:20:06 +02:00
Alex Schroeder
5eb238bf06 Auto increment filename numbers when uploading 2023-10-02 23:07:46 +02:00
Alex Schroeder
602d4f5b18 Add class and title for account links 2023-09-28 10:54:22 +02:00
Alex Schroeder
951c7fcd87 Remove the score from the search command
The search command has proven useful to add link lists to topic pages.
In this situation, you don't want to add the score.
2023-09-28 10:39:45 +02:00
Alex Schroeder
7c4d3380fb Hashtags link to the top level search 2023-09-28 10:34:57 +02:00
Alex Schroeder
ab53463601 Update search URL in man pages
The search URL path needs a trailing slash.
2023-09-27 18:24:32 +02:00
Alex Schroeder
70a4c8d476 Use makeHandler for searchHandler
This allows us to use the same code as for the uploadHandler. The
template works just the same. The Dir field is added to the Search
struct, unlike the Dir func for the Page struct since the Dir for a
Search cannot be computed based on the items found.
2023-09-27 18:01:10 +02:00
Alex Schroeder
fcd69274b1 Support limiting search by directory 2023-09-27 17:49:00 +02:00
Alex Schroeder
adffb463ba Deleted load function that loads named pages
It's no longer needed since grep now loads pages one by one.
2023-09-27 17:05:55 +02:00
Alex Schroeder
541725ecd7 Query string tokens include punctuation
Drop the tokenize function since there are now dedicated tokenizers
for query string and hashtag finding.
2023-09-27 08:24:33 +02:00
Alex Schroeder
43411f2f59 Add placeholder for search input in the view template 2023-09-26 23:14:46 +02:00
Alex Schroeder
77ee92a872 Fix scoring for title matches 2023-09-26 23:14:38 +02:00
Alex Schroeder
ab1d0fbd07 Add highlight tokenizer
Used to hightlight title:term.
2023-09-26 19:13:20 +02:00
Alex Schroeder
22c24e7c13 Intersection requires sorted lists 2023-09-26 19:00:08 +02:00
Alex Schroeder
ce2751ccd7 Title predicate is case insensitive 2023-09-26 18:45:03 +02:00
Alex Schroeder
a21934a904 Add search terms to the template 2023-09-26 18:44:43 +02:00
Alex Schroeder
562e34cb13 Updated the man pages for the new search 2023-09-26 18:34:20 +02:00
Alex Schroeder
c248f12c96 Add blog:true and blog:false predicates 2023-09-26 18:18:32 +02:00
Alex Schroeder
aa482e002b Remove link to last search page from search.html 2023-09-26 18:18:14 +02:00
Alex Schroeder
99a863771f Refactored tests
Each test now uses a separate directory in testdata. When cleaning up
the directory, all the index entries are properly removed, too.
2023-09-26 18:17:15 +02:00
Alex Schroeder
a47ae1b8d6 Dropped the "last" result for the search
This allows us to only grep as many pages as we actually need to grep.
2023-09-26 16:04:20 +02:00
Alex Schroeder
d25c5d1925 Remove exact search from the command-line again 2023-09-26 00:14:59 +02:00
Alex Schroeder
4b1b2e02bb go fmt 2023-09-26 00:09:42 +02:00
Alex Schroeder
4edb544f2d Allow b elements in snippets 2023-09-26 00:09:42 +02:00
Alex Schroeder
c17825306e Index hashtags and grep the rest 2023-09-26 00:09:42 +02:00
Alex Schroeder
8246f03be9 Use full text search 2023-09-26 00:09:42 +02:00
Alex Schroeder
7221d7484f Add exact search from the command line 2023-09-26 00:09:37 +02:00
Alex Schroeder
1cc6771d58 Add feed generation 2023-09-22 15:57:45 +02:00
Alex Schroeder
8183d39eb3 Test the replace command 2023-09-21 14:55:31 +02:00
Alex Schroeder
ef3a9d5e9b Test html command 2023-09-21 14:17:40 +02:00
Alex Schroeder
58ba30e1b4 Test search command 2023-09-21 14:17:22 +02:00
Alex Schroeder
806ee6d270 Renamed the cmd files 2023-09-21 13:48:03 +02:00
Alex Schroeder
2d2439c0c3 Split commands.go into different files 2023-09-21 13:45:19 +02:00
Alex Schroeder
f549dd9ea6 Oddmu replace is the equivalent of using sed 2023-09-21 13:40:52 +02:00
Alex Schroeder
e5dcd068d2 Switch off webfinger lookup by default
The ODDMU_WEBFINGER environment variable can be used to switch it on,
but it doesn't work on the command line.
2023-09-21 11:07:38 +02:00
Alex Schroeder
aa516bbcc0 Move curl example from README to man page 2023-09-21 10:35:03 +02:00
Alex Schroeder
be4c1ba4e5 Handle HEAD requests in the viewHandler 2023-09-20 23:31:36 +02:00
Alex Schroeder
d8138e92c4 Add handing of If-Modified-Since header 2023-09-20 14:46:47 +02:00
Alex Schroeder
09ea5da1e5 Add Last-Modified headers 2023-09-20 14:46:27 +02:00
Alex Schroeder
95d3573b10 Return a failure if the md file cannot be loaded
This is important for external processes using the oddmu html command.
2023-09-20 13:35:59 +02:00
Alex Schroeder
876a170899 Init accounts for the html subcommand 2023-09-19 16:43:34 +02:00
Alex Schroeder
22337d93c4 Make sure page names are escaped in search results 2023-09-19 16:43:17 +02:00
Alex Schroeder
2fa7a8855b Add links at the end of search results
The first/previous/next/last links are interesting enough to have them
again at the end of the page.
2023-09-19 16:42:22 +02:00
Alex Schroeder
528ae1c54b go fmt 2023-09-19 14:55:57 +02:00
Alex Schroeder
17b519071f Update man pages 2023-09-19 14:54:44 +02:00
Alex Schroeder
ca59a1ae5f Add a replace command 2023-09-19 14:37:13 +02:00
Alex Schroeder
fbe105bef8 Rewrote commands using subcommands package 2023-09-19 13:22:22 +02:00
Alex Schroeder
1f07ad867a Documented new fedi accounts parsing 2023-09-19 09:54:25 +02:00
Alex Schroeder
34b2afad94 Better documentation 2023-09-19 08:51:09 +02:00
Alex Schroeder
b274e6ba55 Split fedi account handling from the parser
When parsing fedi accounts, make a guess and look it up in a
goroutine. This keeps parsing fast.
2023-09-19 08:42:58 +02:00
Alex Schroeder
856f1ac235 Fix crash for handles without domains 2023-09-18 18:00:31 +02:00
Alex Schroeder
58a2f8b841 Add rudimentary handling of fediverse accounts 2023-09-18 17:28:58 +02:00
Alex Schroeder
b87302b683 Split parser from page 2023-09-18 17:22:46 +02:00
Alex Schroeder
243dd66317 Rewrote most of the README into man pages 2023-09-18 15:08:32 +02:00
Alex Schroeder
3c1dfce4ac Documentation 2023-09-17 22:46:37 +02:00
Alex Schroeder
8319a6438f Sorting non-blog pages by page title
Previously, they got sorted by page name (which you can't see in the
search results).
2023-09-17 22:35:46 +02:00
Alex Schroeder
9ee2af6093 Add access keys 2023-09-16 23:40:27 +02:00
Alex Schroeder
153a179d92 Search results are based on page titles only 2023-09-16 23:36:17 +02:00
Alex Schroeder
d9797aac75 Add pagination of the results
Sorting and scoring still requires the loading of all the found pages,
but the summary is only computed for the pages on the page.
2023-09-16 16:14:59 +02:00
Alex Schroeder
005500457e Add wiki markup 2023-09-16 13:26:06 +02:00
Alex Schroeder
2635d5f852 Improve HTML template accessibility
Used the web accessibility evaluation tool (WAVE) at wave.webaim.org
and fixed some errors.
2023-09-16 09:59:30 +02:00
Alex Schroeder
a79f4558b6 Fix file extension check 2023-09-15 21:37:33 +02:00
Alex Schroeder
d1c2b8e27c go fmt 2023-09-15 16:03:52 +02:00
Alex Schroeder
dd939e2c86 HTML changes to the upload template 2023-09-15 16:03:38 +02:00
Alex Schroeder
475c7071ba Add image resizing 2023-09-15 14:13:20 +02:00
101 changed files with 8244 additions and 1034 deletions

View File

@@ -8,21 +8,40 @@ help:
@echo " runs program, offline"
@echo
@echo make test
@echo " runs the tests"
@echo " runs the tests without log output"
@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 .
test:
go test
go test -shuffle on .
upload:
go build
rsync --itemize-changes --archive oddmu oddmu.service *.html README.md sibirocobombus.root:/home/oddmu/
ssh sibirocobombus.root "systemctl restart oddmu; systemctl restart alex"
rsync --itemize-changes --archive oddmu sibirocobombus.root:/home/oddmu/
ssh sibirocobombus.root "systemctl restart oddmu; systemctl restart alex; systemctl restart claudia; systemctl restart campaignwiki"
@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
missing:
for f in man/*.txt; do grep --quiet "$$f" README.md || echo $$f is not in the README; done

539
README.md
View File

@@ -1,163 +1,98 @@
# Oddµ: A minimal wiki
This program runs a wiki. It serves all the Markdown files (ending in
`.md`) into web pages and allows you to edit them. If your files don't
provide their own title (`# title`), the file name (without `.md`) is
used for the title. Subdirectories are created as necessary.
This program helps you run 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.
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.
If you're the only user and it just runs on your laptop, then you can
think of it as a [memex](https://en.wikipedia.org/wiki/Memex), a
memory extender.
The wiki lists no recent changes. The expectation is that the people
that care were involved in the discussions beforehand.
Oddµ can be used as a web server behind a reverse proxy such as Apache
or it can be used as a static site generator.
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.
When Oddµ runs as a web server, it serves all the Markdown files
(ending in `.md`) as web pages and allows you to edit them.
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.
If your files don't provide their own title (`# title`), the file name
(without `.md`) is used for the title. Subdirectories are created as
necessary.
And last but not least: µ is the letter mu, so Oddµ is usually written
Oddmu. 🙃
Oddµ uses a [Markdown library](https://github.com/gomarkdown/markdown)
to generate the web pages from Markdown. Oddmu adds the following
extensions: local links `[[like this]]`, hashtags `#Like_This` and
fediverse account links like `@alex@alexschroeder.ch`.
## Markdown
The [lingua](https://github.com/pemistahl/lingua-go) library detects
languages in order to get hyphenation right.
This wiki uses a [Markdown
library](https://github.com/gomarkdown/markdown) to generate the web
pages from Markdown. There is no additional wiki markup. Most
importantly, double square brackets are not a link. If you're used to
that, it'll be strange as you need to repeat the name: `[like
this](like this)`.
The standard [html/template](https://pkg.go.dev/html/template) library
is used to generate HTML.
The Markdown processor comes with a few extensions, some of which are
enable by default:
## Documentation
* 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)
This project uses man(1) pages. They are generated from text files
using [scdoc](https://git.sr.ht/~sircmpwn/scdoc). These are the files
available:
A table with footers and a columnspan:
[oddmu(1)](/oddmu.git/blob/main/man/oddmu.1.txt): This man page has a
short introduction to Oddmu, its configuration via templates and
environment variables, plus points to the other man pages.
```text
Name | Age
--------|------
Bob ||
Alice | 23
========|======
Total | 23
```
[oddmu(5)](/oddmu.git/blob/main/man/oddmu.5.txt): This man page talks
about the Markdown and includes some examples for the non-standard
features such as table markup. It also talks about the Oddmu
extensions to Markdown: wiki links, hashtags and fediverse account
links. Local links must use percent encoding for page names so there
is a section about percent encoding. The man page also explains how
feeds are generated.
A definition list:
[oddmu-list(1)](/oddmu.git/blob/main/man/oddmu-list.1.txt): This man
page documents the "list" subcommand which you can use to get page
names and page titles.
```text
Cat
: Fluffy animal everyone likes
[oddmu-search(1)](/oddmu.git/blob/main/man/oddmu-search.1.txt): This
man page documents the "search" subcommand which you can use to build
indexes lists of page links. These are important for feeds.
Internet
: Vector of transmission for pictures of cats
```
[oddmu-search(7)](/oddmu.git/blob/main/man/oddmu-search.7.txt): This
man page documents how search and scoring work.
There is another extension made: hashtags link to searches for the
hashtag. Hashtags are separate from titles because there is no space
after the hash. Use the underscore to use hashtags consisting of
multiple words.
[oddmu-replace(1)](/oddmu.git/blob/main/man/oddmu-replace.1.txt): This
man page documents the "replace" subcommand to make mass changes to
the files much like find(1), grep(1) and sed(1) or perl(1).
```
# Title
[oddmu-missing(1)](/oddmu.git/blob/main/man/oddmu-missing.1.txt): This
man page documents the "missing" subcommand to list local links that
don't point to any existing pages or files.
Text
[oddmu-html(1)](/oddmu.git/blob/main/man/oddmu-html.1.txt): This man
page documents the "html" subcommand to generate HTML from Markdown
pages from the command line.
#Tag #Another_Tag
```
[oddmu-static(1)](/oddmu.git/blob/main/man/oddmu-static.1.txt): This
man page documents the "static" subcommand to generate an entire
static website from the command line, avoiding the need to run Oddmu
as a server. Also great for archiving.
## Templates
[oddmu-notify(1)](/oddmu.git/blob/main/man/oddmu-notify.1.txt): This
man page documents the "notify" subcommand to add links to hashtag
pages, index and changes for a given page. This is useful when you
edit the Markdown files locally.
The template files are the HTML files in the working directory:
`add.html`, `edit.html`, `search.html`, `upload.html` and `view.html`.
Feel free to change the templates and restart the server. The first
change you should make is to replace the email address in `view.html`.
😄
[oddmu-templates(5)](/oddmu.git/blob/main/man/oddmu-templates.5.txt):
This man page documents how the templates can be changed (how they
*must* be changed) and lists the attributes available for the various
templates.
See [Structuring the web
with HTML](https://developer.mozilla.org/en-US/docs/Learn/HTML) to
learn more about HTML.
[oddmu-apache(5)](/oddmu.git/blob/main/man/oddmu-apache.5.txt): This
man page documents how to set up the web server for various common
tasks such as using logins to limit what visitors can edit.
Modifying the styles in the templates would be another good start to
get a feel for it. See [Learn to style HTML using
CSS](https://developer.mozilla.org/en-US/docs/Learn/CSS) to learn more
about style sheets.
The templates can refer to the following properties of a page:
`{{.Title}}` is the page title. If the page doesn't provide its own
title, the page name is used.
`{{.Name}}` is the page name, escaped for use in URLs. More
specifically, it is URI escaped except for the slashes. The page name
doesn't include the `.md` extension.
`{{.Html}}` is the rendered Markdown, as HTML.
`{{printf "%s" .Body}}` is the Markdown, as a string (the data itself
is a byte array and that's why we need to call `printf`).
For the `search.html` template only:
`{{.Results}}` indicates if there were any search results.
`{{.Items}}` is an array of pages, each containing a search result. A
search result is a page (with the properties seen above). Thus, to
refer to them, you need to use a `{{range .Items}}``{{end}}`
construct.
For search results, `{{.Html}}` is the rendered Markdown of a page
summary, as HTML.
`{{.Score}}` is a numerical score for search results.
The `upload.html` template cannot refer to anything.
When calling the `save` action, the page name is take from the URL and
the page content is taken from the `body` form parameter. To
illustrate, here's how to edit a page using `curl`:
```sh
curl --form body="Did you bring a towel?" \
http://localhost:8080/save/welcome
```
The wiki uses the standard
[html/template](https://pkg.go.dev/html/template) library to do this.
There's more information on writing templates in the documentation for
the [text/template](https://pkg.go.dev/text/template) library.
## Non-English hyphenation
Automatic hyphenation by the browser requires two things: The style
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`.
Oddmu uses the [lingua](github.com/pemistahl/lingua-go) library to
detect languages. If you know that you're only going to use a small
number of languages or just a single language! you can set the
environment variable ODDMU_LANGUAGES to a comma-separated list of ISO
639-1 codes, e.g. "en" or "en,de,fr,pt".
[oddmu.service(5)](/oddmu.git/blob/main/man/oddmu.service.5.txt): This
man page documents how to setup a systemd unit and have it manage
Oddmu. “Great configurability brings great burdens.”
## Building
@@ -165,7 +100,7 @@ environment variable ODDMU_LANGUAGES to a comma-separated list of ISO
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.
@@ -176,289 +111,65 @@ 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.
You can change the port by setting the ODDMU_PORT environment
variable.
## 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`, and all the template files ending in `.html`.
Edit the `oddmu.service` file. These are the three lines you most
likely have to take care of:
```
ExecStart=/home/oddmu/oddmu
WorkingDirectory=/home/oddmu
Environment="ODDMU_PORT=8080"
Environment="ODDMU_LANGUAGES=en,de"
```
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
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.
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/`, `/save/`,
`/add/`, `/append/`, `/upload/` and `/drop/` URLs with a password by
adding the following to your `<VirtualHost *:443>` section:
```apache
<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. 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/`, `/add/`, `/append/`, `/upload/`, `/drop/`
or `/search`. For example, create a file called `robots.txt`
containing the following, tellin all robots that they're not welcome.
```text
User-agent: *
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|add|append|upload|drop)/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>
```
## 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.
## 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.
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.
your browser to http://localhost:8080/ to use it.
## Bugs
If you spot any, [contact](https://alexschroeder.ch/wiki/Contact) me.
## Source
If you're interested in making changes to the code, here's a
high-level introduction to the various source files.
- *_test.go are the test files; a few library functions are defined in
wiki_test.go.
- *_cmd.go are the files implementing the various subcommands with
matching names
- accounts.go implements the webfinger code to fetch fediverse account
link destinations with the URI provided by webfinger
- add_append.go implements the /add and /append handlers
- diff.go implements the /diff handler
- edit_save.go implements the /edit and /save handlers
- feed.go implements the feed for a page based on the links it lists
- highlight.go implements the bold tags for matches when showing
search results
- index.go implements the index of all the hashtags
- languages.go implements the language detection
- page.go implements the page loading and saving
- parser.go implements the Markdown parsing
- score.go implements the page scoring when showing search results
- search.go implements the /search handler
- snippets.go implements the page summaries for search results
- templates.go implements template loading and reloading
- tokenizer.go implements the various tokenizers used
- upload_drop.go implements the /upload and /drop handlers
- view.go implements the /view handler
- watch.go implements the filesystem notification watch
- wiki.go implements the main function
If you want to change the exact markup rules, your starting point
should be `parser.go`. Make sure you read the documentation of [Go
Markdown](https://github.com/gomarkdown/markdown) and note that it
offers MathJax support (needs a change to the `view.html` template so
that the MathJax Javascript gets loaded) and
[MMark](https://mmark.miek.nl/post/syntax/) support, and it shows how
extensions can be added.
One of the sad parts of the code is the distinction between path and
filepath. On a Linux system, this doesn't matter. I suspect that it
also doesn't matter on MacOS and Windows because the file systems
handle forward slashes just fine. The code still tries to do the right
thing. A path that is derived from a URL is a path, with slashes.
Before accessing a file, it has to turned into a filepath using
filepath.FromSlashes and in the rare case where the inverse happens,
use filepath.ToSlashes.
In the rare cases where you need to access the page name in code that
is used from a template, you have to decode the path. See the code in
diff.go for an example.
## References
[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).

View File

@@ -1,6 +0,0 @@
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.

153
accounts.go Normal file
View File

@@ -0,0 +1,153 @@
package main
import (
"encoding/json"
"github.com/gomarkdown/markdown/ast"
"github.com/gomarkdown/markdown/parser"
"io"
"log"
"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
// This is called once at startup and therefore does not need to be locked. On every restart, this map starts empty and
// is slowly repopulated as pages are visited.
func init() {
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] == '_' ||
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 {
log.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{
AdditionalAttributes: []string{`class="account"`},
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 {
log.Printf("Failed to look up %s: %s", account, err)
return
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
log.Printf("Failed to read from %s: %s", account, err)
return
}
var wf WebFinger
err = json.Unmarshal([]byte(body), &wf)
if err != nil {
log.Printf("Failed to parse the JSON from %s: %s", account, err)
return
}
uri, err = parseWebFinger(body)
if err != nil {
log.Printf("Could not find profile URI for %s: %s", account, err)
}
log.Printf("Found profile for %s: %s", 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
}

35
accounts_test.go Normal file
View File

@@ -0,0 +1,35 @@
package main
import (
"github.com/stretchr/testify/assert"
"testing"
)
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)
}

View File

@@ -1,19 +1,20 @@
<!DOCTYPE html>
<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; }
html { max-width: 70ch; padding: 2ch; margin: auto; color: #111; background-color: #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" autofocus required></textarea>
<textarea name="body" rows="20" cols="80" placeholder="Text" lang="" autofocus required></textarea>
<p><label><input type="checkbox" name="notify" checked> Add link to <a href="/view/changes">the list of changes</a>.</label></p>
<p><input type="submit" value="Add">
<a href="/view/{{.Name}}"><button type="button">Cancel</button></a></p>
</form>

View File

@@ -1,6 +1,8 @@
package main
import (
"bytes"
"log"
"net/http"
)
@@ -14,24 +16,48 @@ func addHandler(w http.ResponseWriter, r *http.Request, name string) {
} else {
p.handleTitle(false)
}
renderTemplate(w, "add", p)
renderTemplate(w, p.Dir(), "add", p)
}
// appendHandler takes the "body" form parameter and appends it. The
// browser is redirected to the page view.
// appendHandler takes the "body" form parameter and appends it. The browser is redirected to the page view. This is
// similar to the saveHandler.
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)}
p = &Page{Name: name, Body: []byte(body)}
} else {
p.handleTitle(false)
p.Body = append(p.Body, []byte(body)...)
p.append([]byte(body))
}
p.handleTitle(false)
err = p.save()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
username, _, ok := r.BasicAuth()
if ok {
log.Println("Save", name, "by", username)
} else {
log.Println("Save", name)
}
if r.FormValue("notify") == "on" {
err = p.notify()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
}
http.Redirect(w, r, "/view/"+name, http.StatusFound)
}
func (p *Page) append(body []byte) {
// ensure an empty line at the end
if bytes.HasSuffix(p.Body, []byte("\n\n")) {
} else if bytes.HasSuffix(p.Body, []byte("\n")) {
p.Body = append(p.Body, '\n')
} else {
p.Body = append(p.Body, '\n', '\n')
}
p.Body = append(p.Body, body...)
}

View File

@@ -6,14 +6,25 @@ import (
"os"
"regexp"
"testing"
"time"
)
// wipes testdata
func TestEmptyLineAdd(t *testing.T) {
p := &Page{Name: "testdata/add/fire", Body: []byte(`# Coal
Black rocks light as foam
Shaking, puring, shoveling`)}
p.append([]byte("Into the oven"))
assert.Equal(t, string(p.Body), `# Coal
Black rocks light as foam
Shaking, puring, shoveling
Into the oven`)
}
func TestAddAppend(t *testing.T) {
_ = os.RemoveAll("testdata")
cleanup(t, "testdata/add")
index.load()
p := &Page{Name: "testdata/fire", Body: []byte(`# Fire
p := &Page{Name: "testdata/add/fire", Body: []byte(`# Fire
Orange sky above
Reflects a distant fire
It's not `)}
@@ -23,14 +34,39 @@ It's not `)}
data.Set("body", "barbecue")
assert.Regexp(t, regexp.MustCompile("a distant fire"),
assert.HTTPBody(makeHandler(viewHandler, true), "GET", "/view/testdata/fire", nil))
assert.HTTPBody(makeHandler(viewHandler, true),
"GET", "/view/testdata/add/fire", nil))
assert.NotRegexp(t, regexp.MustCompile("a distant fire"),
assert.HTTPBody(makeHandler(addHandler, true), "GET", "/add/testdata/fire", nil))
HTTPRedirectTo(t, makeHandler(appendHandler, true), "POST", "/append/testdata/fire", data, "/view/testdata/fire")
assert.Regexp(t, regexp.MustCompile("Its not barbecue"),
assert.HTTPBody(makeHandler(viewHandler, true), "GET", "/view/testdata/fire", nil))
t.Cleanup(func() {
_ = os.RemoveAll("testdata")
})
assert.HTTPBody(makeHandler(addHandler, true),
"GET", "/add/testdata/add/fire", nil))
HTTPRedirectTo(t, makeHandler(appendHandler, true),
"POST", "/append/testdata/add/fire", data, "/view/testdata/add/fire")
assert.Regexp(t, regexp.MustCompile(`not</p>\s*<p>barbecue`),
assert.HTTPBody(makeHandler(viewHandler, true),
"GET", "/view/testdata/add/fire", nil))
}
func TestAddAppendChanges(t *testing.T) {
cleanup(t, "testdata/append")
today := time.Now().Format(time.DateOnly)
p := &Page{Name: "testdata/append/" + today + "-water", Body: []byte(`# Water
Sunlight dancing fast
Blue and green and pebbles gray
`)}
p.save()
data := url.Values{}
data.Set("body", "Stand in cold water")
data.Add("notify", "on")
HTTPRedirectTo(t, makeHandler(appendHandler, true),
"POST", "/append/testdata/append/"+today+"-water",
data, "/view/testdata/append/"+today+"-water")
// The changes.md file was created
s, err := os.ReadFile("testdata/append/changes.md")
assert.NoError(t, err)
assert.Equal(t, "# Changes\n\n## "+today+"\n* [Water]("+today+"-water)\n", string(s))
// Link added to index.md file
s, err = os.ReadFile("testdata/append/index.md")
assert.NoError(t, err)
// New index contains just the link
assert.Equal(t, string(s), "* [Water]("+today+"-water)\n")
}

187
changes.go Normal file
View File

@@ -0,0 +1,187 @@
package main
import (
"log"
"path"
"regexp"
"strings"
"time"
)
// notify adds a link to the "changes" page, the "index" page, as well as to all the existing hashtag pages. The link to
// the "index" page is only added if the page being edited is a blog page for the current year. The link to existing
// hashtag pages is only added for blog pages. If the "changes" page does not exist, it is created. If the hashtag page
// does not exist, it is not. Hashtag pages are considered optional. If the page that's being edited is in a
// subdirectory, then the "changes", "index" and hashtag pages of that particular subdirectory are affected. Every
// subdirectory is treated like a potentially independent wiki. Errors are logged before being returned because the
// error messages are confusing from the point of view of the saveHandler.
func (p *Page) notify() error {
p.handleTitle(false)
if p.Title == "" {
p.Title = p.Name
}
esc := nameEscape(path.Base(p.Name))
link := "* [" + p.Title + "](" + esc + ")\n"
re := regexp.MustCompile(`(?m)^\* \[[^\]]+\]\(` + esc + `\)\n`)
dir := path.Dir(p.Name)
err := addLinkWithDate(path.Join(dir, "changes"), link, re)
if err != nil {
log.Printf("Updating changes in %s failed: %s", dir, err)
return err
}
if p.isBlog() {
// Add to the index only if the blog post is for the current year
if strings.HasPrefix(path.Base(p.Name), time.Now().Format("2006")) {
err := addLink(path.Join(dir, "index"), true, link, re)
if err != nil {
log.Printf("Updating index in %s failed: %s", dir, err)
return err
}
}
p.renderHtml() // to set hashtags
for _, hashtag := range p.Hashtags {
err := addLink(path.Join(dir, hashtag), false, link, re)
if err != nil {
log.Printf("Updating hashtag %s in %s failed: %s", hashtag, dir, err)
return err
}
}
}
return nil
}
// addLinkWithDate adds the link to a page, with date header for today. If a match already exists, it is removed. If
// this leaves a date header without any links, it is removed as well. If a list is found, the link is added at the top
// of the list. Lists must use the asterisk, not the minus character.
func addLinkWithDate(name, link string, re *regexp.Regexp) error {
date := time.Now().Format(time.DateOnly)
org := ""
p, err := loadPage(name)
if err != nil {
// create a new page
p = &Page{Name: name, Body: []byte("# Changes\n\n## " + date + "\n" + link)}
} else {
org = string(p.Body)
// remove the old match, if one exists
loc := re.FindIndex(p.Body)
if loc != nil {
r := p.Body[:loc[0]]
if loc[1] < len(p.Body) {
r = append(r, p.Body[loc[1]:]...)
}
p.Body = r
if loc[0] >= 14 && len(p.Body) >= loc[0]+15 {
// remove the preceding date if there are now two dates following each other
re := regexp.MustCompile(`(?m)^## (\d\d\d\d-\d\d-\d\d)\n\n## (\d\d\d\d-\d\d-\d\d)\n`)
if re.Match(p.Body[loc[0]-14 : loc[0]+15]) {
p.Body = append(p.Body[0:loc[0]-14], p.Body[loc[0]+1:]...)
}
} else if len(p.Body) == loc[0] {
// remove a trailing date
re := regexp.MustCompile(`## (\d\d\d\d-\d\d-\d\d)\n`)
if re.Match(p.Body[loc[0]-14 : loc[0]]) {
p.Body = p.Body[0 : loc[0]-14]
}
}
}
// locate the beginning of the list to insert the line
re := regexp.MustCompile(`(?m)^\* \[[^\]]+\]\([^\)]+\)\n`)
loc = re.FindIndex(p.Body)
if loc == nil {
// if no list was found, use the end of the page
loc = []int{len(p.Body)}
}
// start with new page content
r := []byte("")
// check if there is a date right before the insertion point
addDate := true
if loc[0] >= 14 {
re := regexp.MustCompile(`(?m)^## (\d\d\d\d-\d\d-\d\d)\n`)
m := re.Find(p.Body[loc[0]-14 : loc[0]])
if m == nil {
// not a date: insert date, don't move insertion point
} else if string(p.Body[loc[0]-11:loc[0]-1]) == date {
// if the date is our date, don't add it, don't move insertion point
addDate = false
} else {
// if the date is not out date, move the insertion point
loc[0] -= 14
}
}
// append up to the insertion point
r = append(r, p.Body[:loc[0]]...)
// append date, if necessary
if addDate {
// ensure paragraph break
if len(r) > 0 && r[len(r)-1] != '\n' {
r = append(r, '\n')
}
if len(r) > 1 && r[len(r)-2] != '\n' {
r = append(r, '\n')
}
r = append(r, []byte("## ")...)
r = append(r, []byte(date)...)
r = append(r, '\n')
}
// append link
r = append(r, []byte(link)...)
// if we just added a date, add an empty line after the single-element list
if len(p.Body) > loc[0] && p.Body[loc[0]] != '*' {
r = append(r, '\n')
}
// append the rest
r = append(r, p.Body[loc[0]:]...)
p.Body = r
}
// only save if something changed
if string(p.Body) != org {
return p.save()
}
return nil
}
// addLink adds a link to a named page, if the page exists and doesn't contain the link. If the link exists but with a
// different title, the title is fixed.
func addLink(name string, mandatory bool, link string, re *regexp.Regexp) error {
p, err := loadPage(name)
if err != nil {
if mandatory {
p = &Page{Name: name, Body: []byte(link)}
return p.save()
} else {
// Skip non-existing files: no error
return nil
}
}
org := string(p.Body)
// if a link exists, that's the place to insert the new link (in which case loc[0] and loc[1] differ)
loc := re.FindIndex(p.Body)
// if no link exists, find a good place to insert it
if loc == nil {
// locate the beginning of the list to insert the line
re = regexp.MustCompile(`(?m)^\* \[[^\]]+\]\([^\)]+\)\n`)
loc = re.FindIndex(p.Body)
if loc == nil {
// if no list was found, use the end of the page
m := len(p.Body)
loc = []int{m, m}
} else {
// if a list item was found, use just the beginning as insertion point
loc[1] = loc[0]
}
}
// start with new page content
r := []byte("")
// append up to the insertion point
r = append(r, p.Body[:loc[0]]...)
// append link
r = append(r, []byte(link)...)
// append the rest
r = append(r, p.Body[loc[1]:]...)
p.Body = r
// only save if something changed
if string(p.Body) != org {
return p.save()
}
return nil
}

206
changes_test.go Normal file
View File

@@ -0,0 +1,206 @@
package main
import (
"github.com/stretchr/testify/assert"
"os"
"testing"
"time"
)
// Note TestEditSaveChanges and TestAddAppendChanges.
func TestChanges(t *testing.T) {
cleanup(t, "testdata/washing")
today := time.Now().Format(time.DateOnly)
p := &Page{Name: "testdata/washing/" + today + "-machine",
Body: []byte(`# Washing machine
Churning growling thing
Water spraying in a box
Out of sight and dark`)}
p.notify()
// Link added to changes.md file
s, err := os.ReadFile("testdata/washing/changes.md")
assert.NoError(t, err)
assert.Contains(t, string(s), "[Washing machine]("+today+"-machine)")
// Link added to index.md file
s, err = os.ReadFile("testdata/washing/index.md")
assert.NoError(t, err)
// New index contains just the link
assert.Equal(t, string(s), "* [Washing machine]("+today+"-machine)\n")
}
func TestChangesWithHashtag(t *testing.T) {
cleanup(t, "testdata/changes")
intro := "# Haiku\n"
line := "* [Hotel room](2023-10-27-hotel)\n"
h := &Page{Name: "testdata/changes/Haiku", Body: []byte(intro)}
h.save()
p := &Page{Name: "testdata/changes/2023-10-27-hotel",
Body: []byte(`# Hotel room
White linen and white light
Wooden floor and painted walls
Home away from home
#Haiku #Poetry`)}
p.notify()
s, err := os.ReadFile("testdata/changes/changes.md")
assert.NoError(t, err)
assert.Contains(t, string(s), line)
s, err = os.ReadFile("testdata/changes/Haiku.md")
assert.NoError(t, err)
assert.Equal(t, intro+line, string(s))
assert.NoFileExists(t, "testdata/changes/Poetry.md")
}
func TestChangesWithList(t *testing.T) {
cleanup(t, "testdata/changes")
intro := "# Changes\n\nThis is a paragraph.\n\n"
d := "## " + time.Now().Format(time.DateOnly) + "\n"
line := "* [a change](change)\n"
assert.NoError(t, os.MkdirAll("testdata/changes", 0755))
assert.NoError(t, os.WriteFile("testdata/changes/changes.md", []byte(intro+d+line), 0644))
p := &Page{Name: "testdata/changes/alex", Body: []byte(`Hallo!`)}
p.notify()
s, err := os.ReadFile("testdata/changes/changes.md")
assert.NoError(t, err)
new_line := "* [testdata/changes/alex](alex)\n"
// new line was added at the beginning of the list
assert.Equal(t, intro+d+new_line+line, string(s))
}
func TestChangesWithOldList(t *testing.T) {
cleanup(t, "testdata/changes")
intro := "# Changes\n\nThis is a paragraph.\n\n"
line := "* [a change](change)\n"
y := "## " + time.Now().Add(-24*time.Hour).Format(time.DateOnly) + "\n"
d := "## " + time.Now().Format(time.DateOnly) + "\n"
assert.NoError(t, os.MkdirAll("testdata/changes", 0755))
assert.NoError(t, os.WriteFile("testdata/changes/changes.md", []byte(intro+y+line), 0644))
p := &Page{Name: "testdata/changes/alex", Body: []byte(`Hallo!`)}
p.notify()
s, err := os.ReadFile("testdata/changes/changes.md")
assert.NoError(t, err)
new_line := "* [testdata/changes/alex](alex)\n"
// new line was added at the beginning of the list
assert.Equal(t, intro+d+new_line+"\n"+y+line, string(s))
}
func TestChangesWithOldDisappearingListAtTheEnd(t *testing.T) {
cleanup(t, "testdata/changes")
intro := "# Changes\n\nThis is a paragraph.\n\n"
line := "* [a change](alex)\n"
y := "## " + time.Now().Add(-24*time.Hour).Format(time.DateOnly) + "\n"
d := "## " + time.Now().Format(time.DateOnly) + "\n"
assert.NoError(t, os.MkdirAll("testdata/changes", 0755))
assert.NoError(t, os.WriteFile("testdata/changes/changes.md", []byte(intro+y+line), 0644))
p := &Page{Name: "testdata/changes/alex", Body: []byte(`Hallo!`)}
p.notify()
s, err := os.ReadFile("testdata/changes/changes.md")
assert.NoError(t, err)
new_line := "* [testdata/changes/alex](alex)\n"
// new line was added at the beginning of the list, with the new date, and the old date disappeared
assert.Equal(t, intro+d+new_line, string(s))
}
func TestChangesWithOldDisappearingListInTheMiddle(t *testing.T) {
cleanup(t, "testdata/changes")
intro := "# Changes\n\nThis is a paragraph.\n\n"
line := "* [a change](alex)\n"
other := "* [other change](whatever)\n"
yy := "## " + time.Now().Add(-48*time.Hour).Format(time.DateOnly) + "\n"
y := "## " + time.Now().Add(-24*time.Hour).Format(time.DateOnly) + "\n"
d := "## " + time.Now().Format(time.DateOnly) + "\n"
assert.NoError(t, os.MkdirAll("testdata/changes", 0755))
assert.NoError(t, os.WriteFile("testdata/changes/changes.md", []byte(intro+y+line+"\n"+yy+other), 0644))
p := &Page{Name: "testdata/changes/alex", Body: []byte(`Hallo!`)}
p.notify()
s, err := os.ReadFile("testdata/changes/changes.md")
assert.NoError(t, err)
new_line := "* [testdata/changes/alex](alex)\n"
// new line was added at the beginning of the list, with the new date, and the old date disappeared
assert.Equal(t, intro+d+new_line+"\n"+yy+other, string(s))
}
func TestChangesWithListAtTheTop(t *testing.T) {
cleanup(t, "testdata/changes")
line := "* [a change](change)\n"
d := "## " + time.Now().Format(time.DateOnly) + "\n"
assert.NoError(t, os.MkdirAll("testdata/changes", 0755))
assert.NoError(t, os.WriteFile("testdata/changes/changes.md", []byte(line), 0644))
p := &Page{Name: "testdata/changes/alex", Body: []byte(`Hallo!`)}
p.notify()
s, err := os.ReadFile("testdata/changes/changes.md")
assert.NoError(t, err)
new_line := "* [testdata/changes/alex](alex)\n"
// new line was added at the top, no error due to missing introduction
assert.Equal(t, d+new_line+line, string(s))
}
func TestChangesWithNoList(t *testing.T) {
cleanup(t, "testdata/changes")
intro := "# Changes\n\nThis is a paragraph."
d := "## " + time.Now().Format(time.DateOnly) + "\n"
assert.NoError(t, os.MkdirAll("testdata/changes", 0755))
assert.NoError(t, os.WriteFile("testdata/changes/changes.md", []byte(intro), 0644))
p := &Page{Name: "testdata/changes/alex", Body: []byte(`Hallo!`)}
p.notify()
s, err := os.ReadFile("testdata/changes/changes.md")
assert.NoError(t, err)
new_line := "* [testdata/changes/alex](alex)\n"
// into is still there and a new list was started
assert.Equal(t, intro+"\n\n"+d+new_line, string(s))
}
func TestChangesWithUpdate(t *testing.T) {
cleanup(t, "testdata/changes")
intro := "# Changes\n\nThis is a paragraph.\n\n"
other := "* [other change](whatever)\n"
d := "## " + time.Now().Format(time.DateOnly) + "\n"
line := "* [a change](alex)\n"
assert.NoError(t, os.MkdirAll("testdata/changes", 0755))
assert.NoError(t, os.WriteFile("testdata/changes/changes.md", []byte(intro+d+other+line), 0644))
p := &Page{Name: "testdata/changes/alex", Body: []byte(`Hallo!`)}
p.notify()
s, err := os.ReadFile("testdata/changes/changes.md")
assert.NoError(t, err)
new_line := "* [testdata/changes/alex](alex)\n"
// the change was already listed, but now it moved up and has a new title
assert.Equal(t, intro+d+new_line+other, string(s))
}
func TestChangesWithNoChangeToTheOrder(t *testing.T) {
cleanup(t, "testdata/changes")
intro := "# Changes\n\nThis is a paragraph.\n\n"
d := "## " + time.Now().Format(time.DateOnly) + "\n"
line := "* [a change](alex)\n"
other := "* [other change](whatever)\n"
assert.NoError(t, os.MkdirAll("testdata/changes", 0755))
assert.NoError(t, os.WriteFile("testdata/changes/changes.md", []byte(intro+d+line+other), 0644))
p := &Page{Name: "testdata/changes/alex", Body: []byte(`Hallo!`)}
p.notify()
s, err := os.ReadFile("testdata/changes/changes.md")
assert.NoError(t, err)
new_line := "* [testdata/changes/alex](alex)\n"
// the change was already listed at the top, so just use the new title
assert.Equal(t, intro+d+new_line+other, string(s))
// since the file has changed, a backup was necessary
assert.FileExists(t, "testdata/changes/changes.md~")
}
func TestChangesWithNoChanges(t *testing.T) {
cleanup(t, "testdata/changes")
intro := "# Changes\n\nThis is a paragraph.\n\n"
d := "## " + time.Now().Format(time.DateOnly) + "\n"
line := "* [a change](alex)\n"
other := "* [other change](whatever)\n"
assert.NoError(t, os.MkdirAll("testdata/changes", 0755))
assert.NoError(t, os.WriteFile("testdata/changes/changes.md", []byte(intro+d+line+other), 0644))
p := &Page{Name: "testdata/changes/alex", Body: []byte("# a change\nHallo!")}
p.notify()
s, err := os.ReadFile("testdata/changes/changes.md")
assert.NoError(t, err)
// the change was already listed at the top, so no change was necessary
assert.Equal(t, intro+d+line+other, string(s))
// since the file hasn't changed, no backup was necessary
assert.NoFileExists(t, "testdata/changes/changes.md~")
}

View File

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

View File

@@ -1,15 +0,0 @@
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)
assert.Zero(t, len(pages))
}

68
diff.go Normal file
View File

@@ -0,0 +1,68 @@
package main
import (
"bytes"
"github.com/sergi/go-diff/diffmatchpatch"
"html"
"html/template"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
)
func diffHandler(w http.ResponseWriter, r *http.Request, name string) {
p, err := loadPage(name)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
p.handleTitle(true)
p.renderHtml()
renderTemplate(w, p.Dir(), "diff", p)
}
// Diff computes the diff for a page. At this point, renderHtml has already been called so the Name is escaped.
func (p *Page) Diff() template.HTML {
path, err := url.PathUnescape(p.Name)
if err != nil {
return template.HTML("Cannot unescape " + p.Name)
}
fp := filepath.FromSlash(path)
a := fp + ".md~"
t1, err := os.ReadFile(a)
if err != nil {
return template.HTML("Cannot read " + a + ", so the page is new.")
}
b := fp + ".md"
t2, err := os.ReadFile(b)
if err != nil {
return template.HTML("Cannot read " + b + ", so the page was deleted.")
}
dmp := diffmatchpatch.New()
diffs := dmp.DiffMain(string(t1), string(t2), false)
return template.HTML(diff2html(dmp.DiffCleanupSemantic(diffs)))
}
func diff2html(diffs []diffmatchpatch.Diff) string {
var buff bytes.Buffer
for _, item := range diffs {
text := strings.ReplaceAll(html.EscapeString(item.Text), "\n", "<br>")
switch item.Type {
case diffmatchpatch.DiffInsert:
_, _ = buff.WriteString("<ins>")
_, _ = buff.WriteString(text)
_, _ = buff.WriteString("</ins>")
case diffmatchpatch.DiffDelete:
_, _ = buff.WriteString("<del>")
_, _ = buff.WriteString(text)
_, _ = buff.WriteString("</del>")
case diffmatchpatch.DiffEqual:
_, _ = buff.WriteString("<span>")
_, _ = buff.WriteString(text)
_, _ = buff.WriteString("</span>")
}
}
return buff.String()
}

28
diff.html Normal file
View File

@@ -0,0 +1,28 @@
<!DOCTYPE html>
<html lang="{{.Language}}">
<head>
<meta charset="utf-8">
<meta name="format-detection" content="telephone=no">
<meta name="viewport" content="width=device-width">
<title>{{.Title}}</title>
<style>
html { max-width: 70ch; padding: 1ch; margin: auto; color: #111; background-color: #ffe; }
body { hyphens: auto; }
del { background-color: #fab }
ins { background-color: #af8 }
pre { white-space: normal; background-color: white; border: 1px solid #eee; padding: 1ch }
</style>
</head>
<body>
<header>
<a href="/view/{{.Name}}">Back</a>
</header>
<main id="main">
<h1>{{.Title}}</h1>
<p>This is the diff between <a href="/view/{{.Name}}.md~">the backup</a> and <a href="/view/{{.Name}}.md">the current copy</a>.</p>
<pre>
{{.Diff}}
</pre>
</main>
</body>
</html>

96
diff_test.go Normal file
View File

@@ -0,0 +1,96 @@
package main
import (
"github.com/stretchr/testify/assert"
"os"
"testing"
"time"
)
func TestDiff(t *testing.T) {
cleanup(t, "testdata/diff")
index.load()
s := `# Bread
The oven breathes
Fills us with the thought of bread
Oh so fresh, so warm.`
r := `# Bread
The oven whispers
Fills us with the thought of bread
Oh so fresh, so warm.`
p := &Page{Name: "testdata/diff/bread", Body: []byte(s)}
p.save()
p.Body = []byte(r)
p.save()
body := assert.HTTPBody(makeHandler(diffHandler, true),
"GET", "/diff/testdata/diff/bread", nil)
assert.Contains(t, body, `<del>breathe</del>`)
assert.Contains(t, body, `<ins>whisper</ins>`)
}
func TestDiffPercentEncoded(t *testing.T) {
cleanup(t, "testdata/diff")
index.load()
s := `# Coup de Gras
Playing D&D
We talk about a killing
Mispronouncing words`
r := `# Coup de Grace
Playing D&D
We talk about a killing
Mispronouncing words`
p := &Page{Name: "testdata/diff/coup de grace", Body: []byte(s)}
p.save()
p.Body = []byte(r)
p.save()
body := assert.HTTPBody(makeHandler(diffHandler, true),
"GET", "/diff/testdata/diff/coup%20de%20grace", nil)
assert.Contains(t, body, `<del>s</del>`)
assert.Contains(t, body, `<ins>ce</ins>`)
}
func TestDiffBackup(t *testing.T) {
cleanup(t, "testdata/backup")
s := `# Cold Rooms
I shiver at home
the monitor glares and moans
fear or cold, who knows?`
r := `# Cold Rooms
I shiver at home
the monitor glares and moans
I hate the machine!`
u := `# Cold Rooms
I shiver at home
the monitor glares and moans
my grey heart grows cold`
p := &Page{Name: "testdata/backup/cold", Body: []byte(s)}
p.save()
p = &Page{Name: "testdata/backup/cold", Body: []byte(r)}
p.save()
body := string(p.Diff())
// diff from s to r:
assert.Contains(t, body, `<del>fear or cold, who knows?</del>`)
assert.Contains(t, body, `<ins>I hate the machine!</ins>`)
p = &Page{Name: "testdata/backup/cold", Body: []byte(u)}
p.save()
body = string(p.Diff())
// diff from s to u since r was not 60 min or older
assert.Contains(t, body, `<del>fear or cold, who knows?</del>`)
assert.Contains(t, body, `<ins>my grey heart grows cold</ins>`)
// set timestamp 2h in the past
ts := time.Now().Add(-2 * time.Hour)
assert.NoError(t, os.Chtimes("testdata/backup/cold.md~", ts, ts))
p = &Page{Name: "testdata/backup/cold", Body: []byte(r)}
p.save()
body = string(p.Diff())
// diff from u to r:
assert.Contains(t, body, `<del>my grey heart grows cold</del>`)
assert.Contains(t, body, `<ins>I hate the machine!</ins>`)
}

View File

@@ -1,12 +1,12 @@
<!DOCTYPE html>
<html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="format-detection" content="telephone=no">
<meta name="viewport" content="width=device-width">
<title>Editing {{.Title}}</title>
<style>
html { max-width: 70ch; padding: 2ch; margin: auto; color: #111; background: #ffe; }
html { max-width: 70ch; padding: 2ch; margin: auto; color: #111; background-color: #ffe; }
form, textarea { width: 100%; }
</style>
</head>
@@ -15,7 +15,8 @@ form, textarea { width: 100%; }
<form action="/save/{{.Name}}" method="POST">
<textarea name="body" rows="20" cols="80" placeholder="# Title
Text" autofocus>{{printf "%s" .Body}}</textarea>
Text" lang="" autofocus>{{printf "%s" .Body}}</textarea>
<p><label><input type="checkbox" name="notify" checked> Add link to <a href="changes">the list of changes</a>.</label></p>
<p><input type="submit" value="Save">
<a href="/view/{{.Name}}"><button type="button">Cancel</button></a></p>
</form>

View File

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

View File

@@ -4,24 +4,73 @@ import (
"github.com/stretchr/testify/assert"
"net/url"
"os"
"regexp"
"testing"
"time"
)
// wipes testdata
func TestEditSave(t *testing.T) {
_ = os.RemoveAll("testdata")
cleanup(t, "testdata/save")
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")
})
// View of the non-existing page redirects to the edit page
HTTPRedirectTo(t, makeHandler(viewHandler, true),
"GET", "/view/testdata/save/alex", nil, "/edit/testdata/save/alex")
// Edit page can be fetched
assert.HTTPStatusCode(t, makeHandler(editHandler, true),
"GET", "/edit/testdata/save/alex", nil, 200)
// Posting to the save URL saves a page
HTTPRedirectTo(t, makeHandler(saveHandler, true),
"POST", "/save/testdata/save/alex", data, "/view/testdata/save/alex")
// Page now contains the text
assert.Contains(t, assert.HTTPBody(makeHandler(viewHandler, true),
"GET", "/view/testdata/save/alex", nil),
"Hallo!")
// Delete the page and you're sent to the empty page
data.Set("body", "")
HTTPRedirectTo(t, makeHandler(saveHandler, true),
"POST", "/save/testdata/save/alex", data, "/view/testdata/save/alex")
// Viewing the non-existing page redirects to the edit page (like in the beginning)
HTTPRedirectTo(t, makeHandler(viewHandler, true),
"GET", "/view/testdata/save/alex", nil, "/edit/testdata/save/alex")
}
func TestEditSaveChanges(t *testing.T) {
cleanup(t, "testdata/notification")
data := url.Values{}
data.Set("body", "Hallo!")
data.Add("notify", "on")
today := time.Now().Format("2006-01-02")
// Posting to the save URL saves a page
HTTPRedirectTo(t, makeHandler(saveHandler, true),
"POST", "/save/testdata/notification/"+today,
data, "/view/testdata/notification/"+today)
// The changes.md file was created
s, err := os.ReadFile("testdata/notification/changes.md")
assert.NoError(t, err)
d := time.Now().Format(time.DateOnly)
assert.Equal(t, "# Changes\n\n## "+d+
"\n* [testdata/notification/"+today+"]("+today+")\n",
string(s))
// Link added to index.md file
s, err = os.ReadFile("testdata/notification/index.md")
assert.NoError(t, err)
// New index contains just the link
assert.Equal(t, string(s), "* [testdata/notification/"+today+"]("+today+")\n")
}
// Test the following view.html:
// <form action="/edit/" method="GET">
//
// <label for="id">New page:</label>
// <input id="id" type="text" spellcheck="false" name="id" accesskey="g" value="{{.Dir}}/{{.Today}}" required>
// <button>Edit</button>
//
// </form>
func TestEditId(t *testing.T) {
cleanup(t, "testdata/id")
assert.Contains(t, assert.HTTPBody(makeHandler(editHandler, true),
"GET", "/edit/?id=testdata/id/alex", nil),
"Editing testdata/id/alex")
}

73
feed.go Normal file
View File

@@ -0,0 +1,73 @@
package main
import (
"bytes"
"github.com/gomarkdown/markdown"
"github.com/gomarkdown/markdown/ast"
"html/template"
"os"
"path"
"path/filepath"
"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, _ := wikiParser()
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(filepath.FromSlash(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
View 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>

49
feed_test.go Normal file
View File

@@ -0,0 +1,49 @@
package main
import (
"github.com/stretchr/testify/assert"
"testing"
)
func TestFeed(t *testing.T) {
assert.Contains(t,
assert.HTTPBody(makeHandler(viewHandler, true), "GET", "/view/index.rss", nil),
"Welcome to Oddµ")
}
func TestFeedItems(t *testing.T) {
cleanup(t, "testdata/feed")
index.load()
p1 := &Page{Name: "testdata/feed/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/feed/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/feed/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/feed/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, "&lt;h1&gt;Cactus&lt;/h1&gt;")
assert.Contains(t, body, "&lt;h1&gt;Dragon&lt;/h1&gt;")
assert.Contains(t, body, "<category>Succulent</category>")
assert.Contains(t, body, "<category>Palmtree</category>")
}

30
go.mod
View File

@@ -3,21 +3,37 @@ module alexschroeder.ch/cgit/oddmu
go 1.21.0
require (
github.com/dgryski/go-trigram v0.0.0-20160407183937-79ec494e1ad0
github.com/gomarkdown/markdown v0.0.0-20230912175223-14b07df9d538
github.com/microcosm-cc/bluemonday v1.0.25
github.com/anthonynsimon/bild v0.13.0
github.com/bashdrew/goheif v0.0.0-20230406184952-7a08ca9c9bdd
github.com/charmbracelet/lipgloss v0.9.1
github.com/gomarkdown/markdown v0.0.0-20231222211730-1d6d20845b47
github.com/google/subcommands v1.2.0
github.com/hexops/gotextdiff v1.0.3
github.com/microcosm-cc/bluemonday v1.0.26
github.com/pemistahl/lingua-go v1.4.0
github.com/sergi/go-diff v1.3.1
github.com/stretchr/testify v1.8.4
golang.org/x/exp v0.0.0-20240119083558-1b970713d09a
)
require (
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/aymerick/douceur v0.2.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/gorilla/css v1.0.0 // indirect
github.com/fsnotify/fsnotify v1.7.0 // indirect
github.com/gorilla/css v1.0.1 // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.15 // indirect
github.com/muesli/reflow v0.3.0 // indirect
github.com/muesli/termenv v0.15.2 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/rivo/uniseg v0.4.6 // indirect
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd // indirect
github.com/shopspring/decimal v1.3.1 // indirect
golang.org/x/exp v0.0.0-20221106115401-f9659909a136 // indirect
golang.org/x/net v0.12.0 // indirect
google.golang.org/protobuf v1.31.0 // indirect
golang.org/x/image v0.15.0 // indirect
golang.org/x/net v0.20.0 // indirect
golang.org/x/sys v0.16.0 // indirect
google.golang.org/protobuf v1.32.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

110
go.sum
View File

@@ -1,38 +1,106 @@
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/anthonynsimon/bild v0.13.0 h1:mN3tMaNds1wBWi1BrJq0ipDBhpkooYfu7ZFSMhXt1C8=
github.com/anthonynsimon/bild v0.13.0/go.mod h1:tpzzp0aYkAsMi1zmfhimaDyX1xjn2OUc1AJZK/TF0AE=
github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
github.com/bashdrew/goheif v0.0.0-20230406184952-7a08ca9c9bdd h1:SxkQeH4jjXT0zMgiRgkiIQjIvWfe9vXuTAmE3cfcQrU=
github.com/bashdrew/goheif v0.0.0-20230406184952-7a08ca9c9bdd/go.mod h1:p1sbxRy+MY71fEWHcfRmerC8WUYXDFCExF9A7aXwp98=
github.com/charmbracelet/lipgloss v0.9.1 h1:PNyd3jvaJbg4jRHKWXnCj1akQm4rh8dbEzN1p/u1KWg=
github.com/charmbracelet/lipgloss v0.9.1/go.mod h1:1mPmG4cxScwUQALAAnacHaigiiHB9Pmr+v1VEawJl6I=
github.com/coreos/etcd v3.3.10+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE=
github.com/coreos/go-etcd v2.0.0+incompatible/go.mod h1:Jez6KQU2B/sWsbdaef3ED8NzMklzPG4d5KIOhIy30Tk=
github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk=
github.com/cpuguy83/go-md2man v1.0.10/go.mod h1:SmD6nW6nTyfqj6ABTjUi3V3JVMnlJmwcJI5acqYI6dE=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/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/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/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
github.com/gomarkdown/markdown v0.0.0-20231222211730-1d6d20845b47 h1:k4Tw0nt6lwro3Uin8eqoET7MDA4JnT8YgbCjc/g5E3k=
github.com/gomarkdown/markdown v0.0.0-20231222211730-1d6d20845b47/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA=
github.com/google/go-cmp v0.5.8 h1:e6P7q2lk1O+qJJb4BtCQXlK8vWEO8V1ZeuEdJNOqZyg=
github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/gorilla/css v1.0.0 h1:BQqNyPTi50JCFMTw/b67hByjMVXZRwGha6wxVGkeihY=
github.com/gorilla/css v1.0.0/go.mod h1:Dn721qIggHpt4+EFCcTLTU/vk5ySda2ReITrtgBl60c=
github.com/microcosm-cc/bluemonday v1.0.25 h1:4NEwSfiJ+Wva0VxN5B8OwMicaJvD8r9tlJWm9rtloEg=
github.com/microcosm-cc/bluemonday v1.0.25/go.mod h1:ZIOjCQp1OrzBBPIJmfX4qDYFuhU02nx4bn030ixfHLE=
github.com/google/subcommands v1.2.0 h1:vWQspBTo2nEqTUFita5/KeEWlUL8kQObDFbub/EN9oE=
github.com/google/subcommands v1.2.0/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk=
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=
github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U=
github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/microcosm-cc/bluemonday v1.0.26 h1:xbqSvqzQMeEHCqMi64VAs4d8uy6Mequs3rQ0k/Khz58=
github.com/microcosm-cc/bluemonday v1.0.26/go.mod h1:JyzOCs9gkyQyjs+6h10UEVSe02CGwkhd72Xdqh78TWs=
github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s=
github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8=
github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo=
github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8=
github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic=
github.com/pemistahl/lingua-go v1.4.0 h1:ifYhthrlW7iO4icdubwlduYnmwU37V1sbNrwhKBR4rM=
github.com/pemistahl/lingua-go v1.4.0/go.mod h1:ECuM1Hp/3hvyh7k8aWSqNCPlTxLemFZsRjocUf3KgME=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.6 h1:Sovz9sDSwbOz9tgUy8JpT+KgCkPYJEN/oYzlJiYTNLg=
github.com/rivo/uniseg v0.4.6/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g=
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd h1:CmH9+J6ZSsIjUK3dcGsnCnO41eRBOnY12zwkn5qVwgc=
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd/go.mod h1:hPqNNc0+uJM6H+SuU8sEs5K5IQeKccPqeSjfgcKGgPk=
github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8=
github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I=
github.com/shopspring/decimal v1.3.1 h1:2Usl1nmF/WZucqkFZhnfFYxxxu8LG21F6nPQBE5gKV8=
github.com/shopspring/decimal v1.3.1/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o=
github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ=
github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE=
github.com/spf13/cobra v0.0.5/go.mod h1:3K3wKZymM7VvHMDS9+Akkh4K60UwM26emMESw8tLCHU=
github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo=
github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=
github.com/spf13/viper v1.3.2/go.mod h1:ZiWeW+zYFKm7srdB9IoDzzZXaJaI5eL9QjNiN/DMA2s=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
golang.org/x/exp v0.0.0-20221106115401-f9659909a136 h1:Fq7F/w7MAa1KJ5bt2aJ62ihqp9HDcRuyILskkpIAurw=
golang.org/x/exp v0.0.0-20221106115401-f9659909a136/go.mod h1:CxIveKay+FTh1D0yPZemJVgC/95VzuuOLq5Qi4xnoYc=
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/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8=
google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
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-20240119083558-1b970713d09a h1:Q8/wZp0KX97QFTc2ywcOE0YRjZPVIx+MXInMzdvQqcA=
golang.org/x/exp v0.0.0-20240119083558-1b970713d09a/go.mod h1:idGWGoKP1toJGkd5/ig9ZLuPcZBC3ewk7SzmH0uou08=
golang.org/x/image v0.0.0-20190703141733-d6a02ce849c9/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.15.0 h1:kOELfmgrmJlw4Cdb7g/QGuB3CvDrXbqEIww/pNtNBm8=
golang.org/x/image v0.15.0/go.mod h1:HUYqC05R2ZcZ3ejNQsIHQDQiwWM4JBqmm6MKANTp4LE=
golang.org/x/net v0.20.0 h1:aCL9BSgETF1k+blQaYUBx9hJ9LOGP3gAVemcZlf1Kpo=
golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY=
golang.org/x/sys v0.0.0-20181205085412-a5c9d58dba9a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU=
golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
google.golang.org/protobuf v1.32.0 h1:pPC6BG5ex8PDFnkbrGU3EixyhKcQ2aDuBS36lqK/C7I=
google.golang.org/protobuf v1.32.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
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.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
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
View 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 one or more pages 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(os.Stderr, "Cannot load %s: %s\n", arg, err)
return subcommands.ExitFailure
}
if useTemplate {
p.handleTitle(true)
p.renderHtml()
t := "view.html"
loadTemplates()
err := templates.template[t].Execute(w, p)
if err != nil {
fmt.Fprintf(os.Stderr, "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
}

24
html_cmd_test.go Normal file
View File

@@ -0,0 +1,24 @@
package main
import (
"bytes"
"github.com/google/subcommands"
"github.com/stretchr/testify/assert"
"testing"
)
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">README</a>.</p>
<p>Or <a href="test">create a new page</a>.</p>
`
assert.Equal(t, r, b.String())
}

260
index.go
View File

@@ -1,112 +1,234 @@
// Read Artem Krylysov's blog post on full text search as an
// introduction.
// https://artem.krylysov.com/blog/2020/07/28/lets-build-a-full-text-search-engine/
package main
import (
trigram "github.com/dgryski/go-trigram"
"golang.org/x/exp/constraints"
"io/fs"
"log"
"path/filepath"
"sort"
"strings"
"sync"
)
type docid uint
// 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
// next_id is the number of the next document added to the index
next_id docid
// documents is a map, mapping document ids of the index to
// page names.
documents map[trigram.DocID]string
// index is an inverted index mapping tokens to document ids.
token map[string][]docid
// documents is a map, mapping document ids to page names.
documents map[docid]string
// titles 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 init() {
index.reset()
}
// reset the index. This assumes that the index is locked. It's useful for tests.
func (idx *Index) reset() {
idx.index = nil
idx.documents = nil
idx.next_id = 0
idx.token = make(map[string][]docid)
idx.documents = make(map[docid]string)
idx.titles = make(map[string]string)
}
// 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
// addDocument adds the text as a new document. This assumes that the index is locked!
func (idx *Index) addDocument(text []byte) docid {
id := idx.next_id
idx.next_id++
for _, token := range hashtags(text) {
ids := idx.token[token]
// Don't add same ID more than once. Checking the last
// position of the []docid works because the id is
// always a new one, i.e. the last one, if at all.
if len(ids) > 0 && ids[len(ids)-1] == id {
continue
}
idx.token[token] = append(ids, id)
}
filename := path
if info.IsDir() || strings.HasPrefix(filename, ".") || !strings.HasSuffix(filename, ".md") {
return nil
}
name := strings.TrimSuffix(filename, ".md")
p, err := loadPage(name)
if err != nil {
return err
}
id := idx.index.Add(strings.ToLower(string(p.Body)))
idx.documents[id] = p.Name
return nil
return id
}
// load loads all the pages and indexes them. This takes a while.
// It returns the number of pages indexed.
// deleteDocument deletes all references to the id. The id can no longer be used. This assumes that the index is locked.
func (idx *Index) deleteDocument(id docid) {
// Looping through all tokens makes sense if there are few tokens (like hashtags). It doesn't make sense if the
// number of tokens is large (like for full-text search or a trigram index).
for token, ids := range idx.token {
// If the token appears only in this document, remove the whole entry.
if len(ids) == 1 && ids[0] == id {
delete(idx.token, token)
continue
}
// Otherwise, remove the token from the index.
i := sort.Search(len(ids), func(i int) bool { return ids[i] >= id })
if i != -1 && i < len(ids) && ids[i] == id {
copy(ids[i:], ids[i+1:])
idx.token[token] = ids[:len(ids)-1]
continue
}
}
}
// deletePageName determines the document id based on the page name and calls deleteDocument to delete all references.
// This assumes that the index is unlocked.
func (idx *Index) deletePageName(name string) {
idx.Lock()
defer idx.Unlock()
var id docid
// Reverse lookup! At least it's in memory.
for key, value := range idx.documents {
if value == name {
id = key
break
}
}
if id != 0 {
idx.deleteDocument(id)
delete(idx.documents, id)
}
delete(idx.titles, name)
}
// remove the page from the index. Do this when deleting a page. This assumes that the index is unlocked.
func (idx *Index) remove(p *Page) {
idx.deletePageName(p.Name)
}
// load loads all the pages and indexes them. This takes a while. It returns the number of pages indexed.
func (idx *Index) load() (int, error) {
idx.Lock()
defer idx.Unlock()
idx.index = make(trigram.Index)
idx.documents = make(map[trigram.DocID]string)
err := filepath.Walk(".", idx.add)
err := filepath.Walk(".", idx.walk)
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
// walk reads a file and adds it to the index. This assumes that the index is locked.
func (idx *Index) walk(path string, info fs.FileInfo, err error) error {
if err != nil {
return err
}
// skip hidden directories and files
if path != "." && strings.HasPrefix(filepath.Base(path), ".") {
if info.IsDir() {
return filepath.SkipDir
} else {
return nil
}
}
if id == 0 {
id = index.index.Add(strings.ToLower(string(p.Body)))
index.documents[id] = p.Name
} else {
o, err := loadPage(p.Name)
if err == nil {
index.index.Delete(strings.ToLower(string(o.Body)), id)
}
index.index.Insert(strings.ToLower(string(p.Body)), id)
// skipp all but page files
if !strings.HasSuffix(path, ".md") {
return nil
}
p, err := loadPage(strings.TrimSuffix(path, ".md"))
if err != nil {
return err
}
p.handleTitle(false)
idx.addPage(p)
return nil
}
// addPage adds a page to the index. This assumes that the index is locked.
func (idx *Index) addPage(p *Page) {
id := idx.addDocument(p.Body)
idx.documents[id] = p.Name
p.handleTitle(false)
idx.titles[p.Name] = p.Title
}
// add a page to the index. This assumes that the index is unlocked.
func (idx *Index) add(p *Page) {
idx.Lock()
defer idx.Unlock()
idx.addPage(p)
}
// dump prints the index to the log for debugging.
func (idx *Index) dump() {
idx.RLock()
defer idx.RUnlock()
for token, ids := range idx.token {
log.Printf("%s: %v", token, ids)
}
}
func searchDocuments(q string) []string {
words := strings.Fields(strings.ToLower(q))
var trigrams []trigram.T
for _, word := range words {
trigrams = trigram.Extract(word, trigrams)
// updateIndex updates the index for a single page.
func (idx *Index) update(p *Page) {
idx.remove(p)
idx.add(p)
}
// search searches the index for a query string and returns page
// names.
func (idx *Index) search(q string) []string {
idx.RLock()
defer idx.RUnlock()
names := make([]string, 0)
hashtags := hashtags([]byte(q))
if len(hashtags) > 0 {
var r []docid
for _, token := range hashtags {
if ids, ok := idx.token[token]; ok {
if r == nil {
r = ids
} else {
r = intersection(r, ids)
}
} else {
// Token doesn't exist therefore abort search.
return nil
}
}
for _, id := range r {
names = append(names, idx.documents[id])
}
} else {
for _, name := range idx.documents {
names = append(names, name)
}
}
index.RLock()
ids := index.index.QueryTrigrams(trigrams)
names := make([]string, len(ids))
for i, id := range ids {
names[i] = index.documents[id]
}
index.RUnlock()
return names
}
// intersection returns the set intersection between a and b.
// a and b have to be sorted in ascending order and contain no duplicates.
func intersection[T constraints.Ordered](a []T, b []T) []T {
maxLen := len(a)
if len(b) > maxLen {
maxLen = len(b)
}
r := make([]T, 0, maxLen)
var i, j int
for i < len(a) && j < len(b) {
if a[i] < b[j] {
i++
} else if a[i] > b[j] {
j++
} else {
r = append(r, a[i])
i++
j++
}
}
return r
}

View File

@@ -2,4 +2,6 @@
Hello! 🙃
Check out the [README](README).
Check out the [[README]].
Or [create a new page](test).

View File

@@ -2,40 +2,51 @@ package main
import (
"github.com/stretchr/testify/assert"
"os"
"strings"
"testing"
)
func TestIndexAdd(t *testing.T) {
idx := &Index{}
idx.reset()
idx.Lock()
defer idx.Unlock()
tag := "#hello"
id := idx.addDocument([]byte("oh hi " + tag))
assert.Contains(t, idx.token, tag)
idx.deleteDocument(id)
assert.NotContains(t, idx.token, tag)
}
// TestIndex relies on README.md being indexed
func TestIndex(t *testing.T) {
index.load()
q := "Oddµ"
pages := search(q)
pages, _ := search(q, "", "", 1, false)
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)
assert.NotZero(t, p.Score, "Score %d for %s", p.Score, p.Name)
}
}
func TestSearchHashtag(t *testing.T) {
index.load()
q := "#Another_Tag"
pages := search(q)
q := "#like_this"
pages, _ := search(q, "", "", 1, false)
assert.NotZero(t, len(pages))
}
func TestIndexUpdates(t *testing.T) {
name := "test"
_ = os.Remove(name + ".md")
cleanup(t, "testdata/update")
name := "testdata/update/test"
index.load()
p := &Page{Name: name, Body: []byte("This is a test.")}
p := &Page{Name: name, Body: []byte("#Old Name\nThis is a test.")}
p.save()
// Find the phrase
pages := search("This is a test")
pages, _ := search("This is a test", "", "", 1, false)
found := false
for _, p := range pages {
if p.Name == name {
@@ -46,7 +57,7 @@ func TestIndexUpdates(t *testing.T) {
assert.True(t, found)
// Find the phrase, case insensitive
pages = search("this is a test")
pages, _ = search("this is a test", "", "", 1, false)
found = false
for _, p := range pages {
if p.Name == name {
@@ -57,7 +68,7 @@ func TestIndexUpdates(t *testing.T) {
assert.True(t, found)
// Find some words
pages = search("this test")
pages, _ = search("this test", "", "", 1, false)
found = false
for _, p := range pages {
if p.Name == name {
@@ -68,9 +79,9 @@ func TestIndexUpdates(t *testing.T) {
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 = &Page{Name: name, Body: []byte("# New page\nGuvf vf n grfg.")}
p.save()
pages = search("This is a test")
pages, _ = search("This is a test", "", "", 1, false)
found = false
for _, p := range pages {
if p.Name == name {
@@ -81,7 +92,7 @@ func TestIndexUpdates(t *testing.T) {
assert.False(t, found)
// Find page using a new word
pages = search("Guvf")
pages, _ = search("Guvf", "", "", 1, false)
found = false
for _, p := range pages {
if p.Name == name {
@@ -91,7 +102,8 @@ func TestIndexUpdates(t *testing.T) {
}
assert.True(t, found)
t.Cleanup(func() {
_ = os.Remove(name + ".md")
})
// Make sure the title was updated
index.RLock()
defer index.RUnlock()
assert.Equal(t, "New page", index.titles[name])
}

View File

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

71
list_cmd.go Normal file
View File

@@ -0,0 +1,71 @@
package main
import (
"context"
"flag"
"fmt"
"github.com/google/subcommands"
"io"
"os"
"path/filepath"
"strings"
)
type listCmd struct {
dir string
}
func (cmd *listCmd) SetFlags(f *flag.FlagSet) {
f.StringVar(&cmd.dir, "dir", "", "list only pages within this sub-directory")
}
func (*listCmd) Name() string { return "list" }
func (*listCmd) Synopsis() string { return "List pages with name and title." }
func (*listCmd) Usage() string {
return `list [-dir string]:
List all pages with name and title, separated by a tabulator.
`
}
func (cmd *listCmd) Execute(_ context.Context, f *flag.FlagSet, _ ...interface{}) subcommands.ExitStatus {
return listCli(os.Stdout, cmd.dir, f.Args())
}
// listCli runs the list command on the command line. It is used
// here with an io.Writer for easy testing.
func listCli(w io.Writer, dir string, args []string) subcommands.ExitStatus {
dir, err := checkDir(dir)
if err != nil {
return subcommands.ExitFailure
}
index.load()
index.RLock()
defer index.RUnlock()
for name, title := range index.titles {
if strings.HasPrefix(name, dir) {
name = strings.Replace(name, dir, "", 1)
fmt.Fprintf(w, "%s\t%s\n", name, title)
}
}
return subcommands.ExitSuccess
}
// checkDir returns an error if the directory doesn't exist. If if exists, it returns a copy ending in a slash suiteable
// for substring matching of page names.
func checkDir(dir string) (string, error) {
if dir != "" {
fi, err := os.Stat(filepath.FromSlash(dir))
if err != nil {
fmt.Println(err)
return "", err
}
if !fi.IsDir() {
fmt.Println("This is not a sub-directory:", dir)
return "", err
}
if !strings.HasSuffix(dir, "/") {
dir += "/"
}
}
return dir, nil
}

31
list_cmd_test.go Normal file
View File

@@ -0,0 +1,31 @@
package main
import (
"bytes"
"github.com/google/subcommands"
"github.com/stretchr/testify/assert"
"testing"
)
func TestListCmd(t *testing.T) {
b := new(bytes.Buffer)
s := listCli(b, "", nil)
assert.Equal(t, subcommands.ExitSuccess, s)
x := b.String()
assert.Contains(t, x, "README\tOddµ: A minimal wiki\n")
assert.Contains(t, x, "index\tWelcome to Oddµ\n")
}
func TestListSubdirCmd(t *testing.T) {
cleanup(t, "testdata/list")
p := &Page{Name: "testdata/list/red", Body: []byte(`# Red
Shifting darkness waits
I open my eyes in fear
And see the red dot`)}
p.save()
b := new(bytes.Buffer)
s := listCli(b, "testdata/list", nil)
assert.Equal(t, subcommands.ExitSuccess, s)
x := b.String()
assert.Contains(t, x, "red\tRed\n")
}

1
man/.gitignore vendored Normal file
View File

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

31
man/Makefile Normal file
View File

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

386
man/oddmu-apache.5 Normal file
View File

@@ -0,0 +1,386 @@
.\" Generated by scdoc 1.11.2
.\" Complete documentation for this program is not available as a GNU info page
.ie \n(.g .ds Aq \(aq
.el .ds Aq '
.nh
.ad l
.\" Begin generated content:
.TH "ODDMU-APACHE" "5" "2024-02-13"
.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 user account can do this.\&
Alternatively, you can reverse proxy HTTP over a Unix-domain socket,
as shown later.\&
.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 Oddmu.\& You probably want to configure this in your
webserver.\& I guess you could use stunnel, too.\& If you'\&re using Apache, you can
use "mod_md" to manage your domain.\&
.PP
The examples below use the domain "transjovian.\&org" and the Apache installation
is the one that comes with Debian.\&
.PP
The site itself is configured in a file called
"/etc/apache2/sites-available/transjovian.\&conf" and a link points there from
"/etc/apache2/sites-enabled".\& Create this link using \fIa2ensite\fR(1).\&
.PP
.nf
.RS 4
MDomain transjovian\&.org
MDCertificateAgreement accepted
ServerAdmin alex@alexschroeder\&.ch
<VirtualHost *:80>
ServerName transjovian\&.org
Redirect "/" "https://transjovian\&.org/"
</VirtualHost>
<VirtualHost *:443>
ServerName transjovian\&.org
SSLEngine on
ProxyPassMatch "^/((view|diff|edit|save|add|append|upload|drop|search)/(\&.*))?$"
"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 Oddmu to port 8080.\& Importantly, it
doesn'\&t send \fIall\fR the requests to Oddmu.\& This allows us to still host static
files using the web server (see \fBServe static files\fR).\&
.PP
This is what happens:
.PP
.PD 0
.IP \(bu 4
The user tells the browser to visit "transjovian.\&org"
.IP \(bu 4
The browser sends a request for "http://transjovian.\&org" (on port 80)
.IP \(bu 4
Apache redirects this to "https://transjovian.\&org/" by default (now on port 443)
.IP \(bu 4
This is proxied to "http://transjovian.\&org:8080/" (now on port 8080)
.PD
.PP
Restart the server, gracefully:
.PP
.nf
.RS 4
apachectl graceful
.fi
.RE
.PP
.SS Allow HTTP for viewing
.PP
When looking at pages, you might want to allow HTTP since no password is
required.\& Therefore, proxy the read-only requests from the virtual host on port
80 to the wiki instead of redirecting them to port 443.\&
.PP
.nf
.RS 4
MDomain transjovian\&.org
MDCertificateAgreement accepted
ServerAdmin alex@alexschroeder\&.ch
<VirtualHost *:80>
ServerName transjovian\&.org
ProxyPassMatch "^/((view|diff|search)/(\&.*))?$"
"http://localhost:8080/$1"
RedirectMatch "^/((edit|save|add|append|upload|drop)/(\&.*))?$"
"https://transjovian\&.org/$1"
</VirtualHost>
<VirtualHost *:443>
ServerName transjovian\&.org
SSLEngine on
ProxyPassMatch "^/((view|diff|edit|save|add|append|upload|drop|search)/(\&.*))?$"
"http://localhost:8080/$1"
</VirtualHost>
.fi
.RE
.PP
.SS Using a Unix-domain Socket
.PP
Instead of having Oddmu listen on a TCP port, you can have it listen on a
Unix-domain socket.\& This requires socket activation.\& An example of configuring
the service is given in \fIoddmu.\&service(5)\fR.\&
.PP
To test just the unix domain socket, use \fIncat(1)\fR:
.PP
.nf
.RS 4
echo -e "GET /view/index HTTP/1\&.1rnHost: localhostrnrn"
| ncat --unixsock /run/oddmu/oddmu\&.sock
.fi
.RE
.PP
On the Apache side, you can proxy to the socket directly.\& This sends all
requests to the socket:
.PP
.nf
.RS 4
ProxyPass "/" "unix:/run/oddmu/oddmu\&.sock|http://localhost/"
.fi
.RE
.PP
Now, all traffic between the web server and the wiki goes over the socket at
"/run/oddmu/oddmu.\&sock".\&
.PP
To test it on the command-line, use a tool like \fIcurl(1)\fR.\& Make sure to provide
the correct servername!\&
.PP
.nf
.RS 4
curl http://transjovian\&.org/view/index
.fi
.RE
.PP
You probably want to serve some static files as well (see \fBServe static files\fR).\&
In that case, you need to use the ProxyPassMatch directive.\&
.PP
.nf
.RS 4
ProxyPassMatch "^/((view|diff|edit|save|add|append|upload|drop|search)/(\&.*))?$"
"unix:/run/oddmu/oddmu\&.sock|http://localhost/$1"
.fi
.RE
.PP
There'\&s a curious problem with this expression, however.\& If you use \fIcurl(1)\fR to
get the root path, Apache hangs:
.PP
.nf
.RS 4
curl http://transjovian\&.org/
.fi
.RE
.PP
A workaround is to add the redirect manually and drop the question-mark:
.PP
.nf
.RS 4
RedirectMatch "^/$" "/view/index"
ProxyPassMatch "^/((view|diff|edit|save|add|append|upload|drop|search)/(\&.*))$"
"unix:/run/oddmu/oddmu\&.sock|http://localhost/$1"
.fi
.RE
.PP
If you know why this is happening, let me know.\&
.PP
.SS Access
.PP
Access control is not part of Oddmu.\& By default, the wiki is editable by all.\&
This is most likely not what you want unless you'\&re running it stand-alone,
unconnected to the Internet a personal memex on your laptop, for example.\&
.PP
The following instructions create user accounts with passwords just for Oddmu.\&
These users are not real users on the web server and don'\&t have access to a
shell, mail, or any other service.\&
.PP
Create a new password file called ".\&htpasswd" and add the user "alex".\& The "-c"
flag creates the file.\&
.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 the existing
file.\& To add another user, use no option at all.\&
.PP
.nf
.RS 4
htpasswd \&.htpasswd berta
.fi
.RE
.PP
To remove a user, use the "-D" option.\&
.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
The way Oddmu handles subdirectories is that all files and directories are
visible, except for "hidden" files and directories (whose name starts with a
period).\& Specifically, do not rely on Apache to hide locations in subdirectories
from public view.\& Search reveals the existence of these pages and produces an
extract, even if users cannot follow the links.\& Archive links pack all the
subdirectories, including locations you may have hidden from view using Apache.\&
.PP
If you want private subdirectories, you need to set the environment variable
ODDMU_FILTER to a regular expression matching the private directories.\& If search
starts in a directory matching the regular expression, the directory and its
subdirectories are searched.\& If search starts in a directory that doesn'\&t match
the regular expression, all directories matching the regular expression are
excluded.\&
.PP
In the following example, ODDMU_FILTER is set to "^secret/".\&
.PP
http://transjovian.\&org/search/index?\&q=something does not search the "secret"
directory and its subdirectories are excluded.\&
.PP
http://transjovian.\&org/search/secret/index?\&q=something searches just the
"secret" directory and its subdirectories.\&
.PP
Now you need to ensure that the "secret" directory are not public.\&
.PP
.nf
.RS 4
<LocationMatch "^/(edit|save|add|append|upload|drop|view/secret|search/secret)/">
AuthType Basic
AuthName "Password Required"
AuthUserFile /home/oddmu/\&.htpasswd
Require valid-user
</LocationMatch>
.fi
.RE
.PP
.SS Serve static files
.PP
If you want to serve static files as well, add a document root to your webserver
configuration.\& In this case, the document root is the directory where all the
data files are.\& Apache does 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/",
"/diff/", "/edit/", "/save/", "/add/", "/append/", "/upload/", "/drop/" or
"/search/".\& For example, create a file called "robots.\&txt" containing the
following, telling all robots that they'\&re not welcome.\&
.PP
.nf
.RS 4
User-agent: *
Disallow: /
.fi
.RE
.PP
Your site now serves "/robots.\&txt" without interfering with the wiki, and
without needing a wiki page.\&
.PP
Another option would be to create a CSS file and use it with a <link> element in
all the templates instead of relying on the <style> element.\&
.PP
The "view.\&html" template would start as follows:
.PP
.nf
.RS 4
<!DOCTYPE html>
<html lang="{{\&.Language}}">
<head>
<meta charset="utf-8">
<meta name="format-detection" content="telephone=no">
<meta name="viewport" content="width=device-width">
<title>{{\&.Title}}</title>
<link href="/css/oddmu-2023\&.css" rel="stylesheet" />
<link rel="alternate" type="application/rss+xml" title="Alex Schroeder: {{\&.Title}}" href="/view/{{\&.Name}}\&.rss" />
</head>
.fi
.RE
.PP
In this case, "/css/oddmu-2023.\&css" would be the name of your stylesheet.\& If
your document root is "/home/oddmu", then the filename of your stylesheet would
have to be "/home/oddmu/css/oddmu-2023.\&css" for this to work.\&
.PP
.SS Different logins for different access rights
.PP
What if you have a site with various subdirectories and each subdirectory is for
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 all the URLs in order to protect everything.\&
.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
"<style>: The Style Information element"
https://developer.\&mozilla.\&org/en-US/docs/Web/HTML/Element/style
.PP
"<link>: The External Resource Link element"
https://developer.\&mozilla.\&org/en-US/docs/Web/HTML/Element/link
.PP
.SH AUTHORS
.PP
Maintained by Alex Schroeder <alex@gnu.\&org>.\&

335
man/oddmu-apache.5.txt Normal file
View File

@@ -0,0 +1,335 @@
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 user account can do this.
Alternatively, you can reverse proxy HTTP over a Unix-domain socket,
as shown later.
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 Oddmu. You probably want to configure this in your
webserver. I guess you could use stunnel, too. If you're using Apache, you can
use "mod_md" to manage your domain.
The examples below use the domain "transjovian.org" and the Apache installation
is the one that comes with Debian.
The site itself is configured in a file called
"/etc/apache2/sites-available/transjovian.conf" and a link points there from
"/etc/apache2/sites-enabled". Create this link using _a2ensite_(1).
```
MDomain transjovian.org
MDCertificateAgreement accepted
ServerAdmin alex@alexschroeder.ch
<VirtualHost *:80>
ServerName transjovian.org
Redirect "/" "https://transjovian.org/"
</VirtualHost>
<VirtualHost *:443>
ServerName transjovian.org
SSLEngine on
ProxyPassMatch "^/((view|diff|edit|save|add|append|upload|drop|search)/(.*))?$" \
"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 Oddmu to port 8080. Importantly, it
doesn't send _all_ the requests to Oddmu. This allows us to still host static
files using the web server (see *Serve static files*).
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/" (now on port 8080)
Restart the server, gracefully:
```
apachectl graceful
```
## Allow HTTP for viewing
When looking at pages, you might want to allow HTTP since no password is
required. Therefore, proxy the read-only requests from the virtual host on port
80 to the wiki instead of redirecting them to port 443.
```
MDomain transjovian.org
MDCertificateAgreement accepted
ServerAdmin alex@alexschroeder.ch
<VirtualHost *:80>
ServerName transjovian.org
ProxyPassMatch "^/((view|diff|search)/(.*))?$" \
"http://localhost:8080/$1"
RedirectMatch "^/((edit|save|add|append|upload|drop)/(.*))?$" \
"https://transjovian.org/$1"
</VirtualHost>
<VirtualHost *:443>
ServerName transjovian.org
SSLEngine on
ProxyPassMatch "^/((view|diff|edit|save|add|append|upload|drop|search)/(.*))?$" \
"http://localhost:8080/$1"
</VirtualHost>
```
## Using a Unix-domain Socket
Instead of having Oddmu listen on a TCP port, you can have it listen on a
Unix-domain socket. This requires socket activation. An example of configuring
the service is given in _oddmu.service(5)_.
To test just the unix domain socket, use _ncat(1)_:
```
echo -e "GET /view/index HTTP/1.1\r\nHost: localhost\r\n\r\n" \
| ncat --unixsock /run/oddmu/oddmu.sock
```
On the Apache side, you can proxy to the socket directly. This sends all
requests to the socket:
```
ProxyPass "/" "unix:/run/oddmu/oddmu.sock|http://localhost/"
```
Now, all traffic between the web server and the wiki goes over the socket at
"/run/oddmu/oddmu.sock".
To test it on the command-line, use a tool like _curl(1)_. Make sure to provide
the correct servername!
```
curl http://transjovian.org/view/index
```
You probably want to serve some static files as well (see *Serve static files*).
In that case, you need to use the ProxyPassMatch directive.
```
ProxyPassMatch "^/((view|diff|edit|save|add|append|upload|drop|search)/(.*))?$" \
"unix:/run/oddmu/oddmu.sock|http://localhost/$1"
```
There's a curious problem with this expression, however. If you use _curl(1)_ to
get the root path, Apache hangs:
```
curl http://transjovian.org/
```
A workaround is to add the redirect manually and drop the question-mark:
```
RedirectMatch "^/$" "/view/index"
ProxyPassMatch "^/((view|diff|edit|save|add|append|upload|drop|search)/(.*))$" \
"unix:/run/oddmu/oddmu.sock|http://localhost/$1"
```
If you know why this is happening, let me know.
## Access
Access control is not part of Oddmu. By default, the wiki is editable by all.
This is most likely not what you want unless you're running it stand-alone,
unconnected to the Internet a personal memex on your laptop, for example.
The following instructions create user accounts with passwords just for Oddmu.
These users are not real users on the web server and don't have access to a
shell, mail, or any other service.
Create a new password file called ".htpasswd" and add the user "alex". The "-c"
flag creates the file.
```
cd /home/oddmu
htpasswd -c .htpasswd alex
```
To add more users, don't use the "-c" option or you will overwrite the existing
file. To add another user, use no option at all.
```
htpasswd .htpasswd berta
```
To remove a user, use the "-D" option.
```
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>
```
The way Oddmu handles subdirectories is that all files and directories are
visible, except for "hidden" files and directories (whose name starts with a
period). Specifically, do not rely on Apache to hide locations in subdirectories
from public view. Search reveals the existence of these pages and produces an
extract, even if users cannot follow the links. Archive links pack all the
subdirectories, including locations you may have hidden from view using Apache.
If you want private subdirectories, you need to set the environment variable
ODDMU_FILTER to a regular expression matching the private directories. If search
starts in a directory matching the regular expression, the directory and its
subdirectories are searched. If search starts in a directory that doesn't match
the regular expression, all directories matching the regular expression are
excluded.
In the following example, ODDMU_FILTER is set to "^secret/".
http://transjovian.org/search/index?q=something does not search the "secret"
directory and its subdirectories are excluded.
http://transjovian.org/search/secret/index?q=something searches just the
"secret" directory and its subdirectories.
Now you need to ensure that the "secret" directory are not public.
```
<LocationMatch "^/(edit|save|add|append|upload|drop|view/secret|search/secret)/">
AuthType Basic
AuthName "Password Required"
AuthUserFile /home/oddmu/.htpasswd
Require valid-user
</LocationMatch>
```
## Serve static files
If you want to serve static files as well, add a document root to your webserver
configuration. In this case, the document root is the directory where all the
data files are. Apache does 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/",
"/diff/", "/edit/", "/save/", "/add/", "/append/", "/upload/", "/drop/" or
"/search/". For example, create a file called "robots.txt" containing the
following, telling all robots that they're not welcome.
```
User-agent: *
Disallow: /
```
Your site now serves "/robots.txt" without interfering with the wiki, and
without needing a wiki page.
Another option would be to create a CSS file and use it with a <link> element in
all the templates instead of relying on the <style> element.
The "view.html" template would start as follows:
```
<!DOCTYPE html>
<html lang="{{.Language}}">
<head>
<meta charset="utf-8">
<meta name="format-detection" content="telephone=no">
<meta name="viewport" content="width=device-width">
<title>{{.Title}}</title>
<link href="/css/oddmu-2023.css" rel="stylesheet" />
<link rel="alternate" type="application/rss+xml" title="Alex Schroeder: {{.Title}}" href="/view/{{.Name}}.rss" />
</head>
```
In this case, "/css/oddmu-2023.css" would be the name of your stylesheet. If
your document root is "/home/oddmu", then the filename of your stylesheet would
have to be "/home/oddmu/css/oddmu-2023.css" for this to work.
## Different logins for different access rights
What if you have a site with various subdirectories and each subdirectory is for
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 all the 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
"<style>: The Style Information element"
https://developer.mozilla.org/en-US/docs/Web/HTML/Element/style
"<link>: The External Resource Link element"
https://developer.mozilla.org/en-US/docs/Web/HTML/Element/link
# AUTHORS
Maintained by Alex Schroeder <alex@gnu.org>.

53
man/oddmu-html.1 Normal file
View 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-10-09"
.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
lacks 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
View 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
lacks 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>.

56
man/oddmu-list.1 Normal file
View File

@@ -0,0 +1,56 @@
.\" Generated by scdoc 1.11.2
.\" Complete documentation for this program is not available as a GNU info page
.ie \n(.g .ds Aq \(aq
.el .ds Aq '
.nh
.ad l
.\" Begin generated content:
.TH "ODDMU-LIST" "1" "2023-12-20"
.PP
.SH NAME
.PP
oddmu-list - list page names and titles from the command-line
.PP
.SH SYNOPSIS
.PP
\fBoddmu list\fR [-dir string]
.PP
.SH DESCRIPTION
.PP
The "list" subcommand lists page names and their titles, separated by a TAB
character.\& This saves you from opening and parsing all the files yourself if you
need the page titles.\&
.PP
If a directory is provided, only files from the tree starting at that
subdirectory are listed, and the directory is stripped from the page name.\&
.PP
.SH OPTIONS
.PP
\fB-dir\fR \fIstring\fR
.RS 4
Limit the list to a particular directory.\&
.PP
.RE
.SH EXAMPLE
.PP
Create list of links to pages in the "dad" directory, filter it for date pages
(starting with "2"), format it as a list of links and sort in reverse order.\&
This is a list of links you could append to "dad/index.\&md" if it doesn'\&t already
have a list of links.\&
.PP
.nf
.RS 4
oddmu list -dir dad
| grep \&'^2\&'
| awk -F "t" -e \&'{ print "* [" $2 "](" $1 ")" }\&'
| sort -r
.fi
.RE
.PP
.SH SEE ALSO
.PP
\fIoddmu\fR(1), \fIoddmu-search\fR(1)
.PP
.SH AUTHORS
.PP
Maintained by Alex Schroeder <alex@gnu.\&org>.\&

45
man/oddmu-list.1.txt Normal file
View File

@@ -0,0 +1,45 @@
ODDMU-LIST(1)
# NAME
oddmu-list - list page names and titles from the command-line
# SYNOPSIS
*oddmu list* [-dir string]
# DESCRIPTION
The "list" subcommand lists page names and their titles, separated by a TAB
character. This saves you from opening and parsing all the files yourself if you
need the page titles.
If a directory is provided, only files from the tree starting at that
subdirectory are listed, and the directory is stripped from the page name.
# OPTIONS
*-dir* _string_
Limit the list to a particular directory.
# EXAMPLE
Create list of links to pages in the "dad" directory, filter it for date pages
(starting with "2"), format it as a list of links and sort in reverse order.
This is a list of links you could append to "dad/index.md" if it doesn't already
have a list of links.
```
oddmu list -dir dad \
| grep '^2' \
| awk -F "\t" -e '{ print "* [" $2 "](" $1 ")" }' \
| sort -r
```
# SEE ALSO
_oddmu_(1), _oddmu-search_(1)
# AUTHORS
Maintained by Alex Schroeder <alex@gnu.org>.

57
man/oddmu-missing.1 Normal file
View File

@@ -0,0 +1,57 @@
.\" Generated by scdoc 1.11.2
.\" Complete documentation for this program is not available as a GNU info page
.ie \n(.g .ds Aq \(aq
.el .ds Aq '
.nh
.ad l
.\" Begin generated content:
.TH "ODDMU-MISSING" "1" "2023-11-05"
.PP
.SH NAME
.PP
oddmu-missing - list missing pages from the command-line
.PP
.SH SYNOPSIS
.PP
\fBoddmu missing\fR
.PP
.SH DESCRIPTION
.PP
The "missing" subcommand lists pages and their local links that are missing.\&
.PP
Any links that seem like they might point outside the wiki are ignored: links
that start with a slash "/" and links that start with a known URL schema
(currently: "http:", "https:", "ftp:", "mailto:", "gopher:", "gemini:",
"finger:").\&
.PP
Notably, links that start with ".\&.\&/" are reported as missing.\&
.PP
.SH EXAMPLE
.PP
Looking for broken links:
.PP
.nf
.RS 4
oddmu missing
.fi
.RE
.PP
Result:
.PP
.nf
.RS 4
Page Missing
README github\&.com/pemistahl/lingua-go
.fi
.RE
.PP
This shows how the README file had a link where the URL was missing the scheme
"https://".\&
.PP
.SH SEE ALSO
.PP
\fIoddmu\fR(1), \fIoddmu-replace\fR(1), \fIoddmu-missing\fR(7)
.PP
.SH AUTHORS
.PP
Maintained by Alex Schroeder <alex@gnu.\&org>.\&

46
man/oddmu-missing.1.txt Normal file
View File

@@ -0,0 +1,46 @@
ODDMU-MISSING(1)
# NAME
oddmu-missing - list missing pages from the command-line
# SYNOPSIS
*oddmu missing*
# DESCRIPTION
The "missing" subcommand lists pages and their local links that are missing.
Any links that seem like they might point outside the wiki are ignored: links
that start with a slash "/" and links that start with a known URL schema
(currently: "http:", "https:", "ftp:", "mailto:", "gopher:", "gemini:",
"finger:").
Notably, links that start with "../" are reported as missing.
# EXAMPLE
Looking for broken links:
```
oddmu missing
```
Result:
```
Page Missing
README github.com/pemistahl/lingua-go
```
This shows how the README file had a link where the URL was missing the scheme
"https://".
# SEE ALSO
_oddmu_(1), _oddmu-replace_(1), _oddmu-missing_(7)
# AUTHORS
Maintained by Alex Schroeder <alex@gnu.org>.

108
man/oddmu-notify.1 Normal file
View File

@@ -0,0 +1,108 @@
.\" Generated by scdoc 1.11.2
.\" Complete documentation for this program is not available as a GNU info page
.ie \n(.g .ds Aq \(aq
.el .ds Aq '
.nh
.ad l
.\" Begin generated content:
.TH "ODDMU-NOTIFY" "1" "2024-01-17"
.PP
.SH NAME
.PP
oddmu-notify - add links to changes.\&md, index.\&md, and hashtag pages
.PP
.SH SYNOPSIS
.PP
\fBoddmu notify\fR \fIpage names.\&.\&.\&\fR
.PP
.SH DESCRIPTION
.PP
The "notify" subcommand takes all the page names provided (without the ".\&md"
extension) and adds links to it from other pages.\&
.PP
A new link is added to the \fBchanges\fR page in the current directory if it doesn'\&t
exist.\& The current date of the machine Oddmu is running on is used as the
heading.\& If the requested link already exists on the changes page, it is moved
up to the current date.\& If that leaves an old date without any links, that date
heading is removed.\&
.PP
A page whose name starts with an ISO date (YYYY-MM-DD, e.\&g.\& "2023-10-28") is
called a \fBblog\fR page.\&
.PP
A link is created from the \fBindex\fR page in the current directory to blog pages
if and only if the blog pages are from the current year.\& The idea is that the
front page contains a lot of links to blog posts but eventually the blog post
links are moved onto archive pages (one per year, for example), or simply
deleted.\& As when editing older pages, links to those pages should not get added
to the index as if those older pages were new again.\& A link on the changes page
is enough.\&
.PP
For every \fBhashtag\fR used on the pages named, another link might be created.\& If a
page named like the hashtag exists, a backlink is added to it.\& A hashtag
consists of a number sign ('\&#'\&) followed by Unicode letters, numbers or the
underscore ('\&_'\&).\& Thus, a hashtag ends with punctuation or whitespace.\&
.PP
If a link already exists but it'\&s title is no longer correct, it is updated.\&
.PP
New links added for blog pages are added at the top of the first unnumbered list
using the asterisk ('\&*'\&).\& If no such list exists, a new one is started at the
bottom of the page.\& This allows you to have a different unnumbered list further
up on the page, as long as it uses the minus for items ('\&-'\&).\&
.PP
.SH EXAMPLE
.PP
After writing the file "2023-11-05-climate.\&md" containing the hashtag
"#Climate", add links to it from "index.\&md", "changes.\&md", and "Climate.\&md" (if
it exists):
.PP
.nf
.RS 4
oddmu notify 2023-11-05-climate
.fi
.RE
.PP
The changes file might look as follows:
.PP
.nf
.RS 4
# Changes
This page lists all the changes made to the wiki\&.
## 2023-11-05
* [Global warming](2023-11-05-climate)
.fi
.RE
.PP
The index file might look as follows:
.PP
.nf
.RS 4
# Blog
This page links to all the blog posts\&.
* [Global warming](2023-11-05-climate)
.fi
.RE
.PP
The hashtag file might look as follows:
.PP
.nf
.RS 4
# Climate
This page links to all the blog posts tagged #Climate\&.
* [Global warming](2023-11-05-climate)
.fi
.RE
.PP
.SH SEE ALSO
.PP
\fIoddmu\fR(1)
.PP
.SH AUTHORS
.PP
Maintained by Alex Schroeder <alex@gnu.\&org>.\&

93
man/oddmu-notify.1.txt Normal file
View File

@@ -0,0 +1,93 @@
ODDMU-NOTIFY(1)
# NAME
oddmu-notify - add links to changes.md, index.md, and hashtag pages
# SYNOPSIS
*oddmu notify* _page names..._
# DESCRIPTION
The "notify" subcommand takes all the page names provided (without the ".md"
extension) and adds links to it from other pages.
A new link is added to the *changes* page in the current directory if it doesn't
exist. The current date of the machine Oddmu is running on is used as the
heading. If the requested link already exists on the changes page, it is moved
up to the current date. If that leaves an old date without any links, that date
heading is removed.
A page whose name starts with an ISO date (YYYY-MM-DD, e.g. "2023-10-28") is
called a *blog* page.
A link is created from the *index* page in the current directory to blog pages
if and only if the blog pages are from the current year. The idea is that the
front page contains a lot of links to blog posts but eventually the blog post
links are moved onto archive pages (one per year, for example), or simply
deleted. As when editing older pages, links to those pages should not get added
to the index as if those older pages were new again. A link on the changes page
is enough.
For every *hashtag* used on the pages named, another link might be created. If a
page named like the hashtag exists, a backlink is added to it. A hashtag
consists of a number sign ('#') followed by Unicode letters, numbers or the
underscore ('\_'). Thus, a hashtag ends with punctuation or whitespace.
If a link already exists but it's title is no longer correct, it is updated.
New links added for blog pages are added at the top of the first unnumbered list
using the asterisk ('\*'). If no such list exists, a new one is started at the
bottom of the page. This allows you to have a different unnumbered list further
up on the page, as long as it uses the minus for items ('-').
# EXAMPLE
After writing the file "2023-11-05-climate.md" containing the hashtag
"#Climate", add links to it from "index.md", "changes.md", and "Climate.md" (if
it exists):
```
oddmu notify 2023-11-05-climate
```
The changes file might look as follows:
```
# Changes
This page lists all the changes made to the wiki.
## 2023-11-05
* [Global warming](2023-11-05-climate)
```
The index file might look as follows:
```
# Blog
This page links to all the blog posts.
* [Global warming](2023-11-05-climate)
```
The hashtag file might look as follows:
```
# Climate
This page links to all the blog posts tagged #Climate.
* [Global warming](2023-11-05-climate)
```
# SEE ALSO
_oddmu_(1)
# AUTHORS
Maintained by Alex Schroeder <alex@gnu.org>.

105
man/oddmu-replace.1 Normal file
View File

@@ -0,0 +1,105 @@
.\" 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-11-24"
.PP
.SH NAME
.PP
oddmu-replace - replace text in Oddmu pages from the command-line
.PP
.SH SYNOPSIS
.PP
\fBoddmu replace\fR [-confirm] [-regexp] \fIterm\fR \fIreplacement\fR
.PP
.SH DESCRIPTION
.PP
The "replace" subcommand does a search and replace on all the Markdown files in
the current directory and its subdirectories.\&
.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
\fB-regexp\fR
.RS 4
By default, the term to be replaced is just a string.\& With this flag,
the term is a regular expression and the replacement can contain
backreferences ($1, $2, $3, etc.\&) to capture groups.\&
.PP
.RE
.SH EXAMPLE
.PP
Replace "Oddmu" in the Markdown files of the current directory:
.PP
.nf
.RS 4
oddmu replace Oddmu Oddµ
.fi
.RE
.PP
Result:
.PP
.nf
.RS 4
--- README\&.md~
+++ README\&.md
(diff omitted)
1 file would be changed\&.
This is a dry run\&. Use -confirm to make it happen\&.
.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
The search is case-sensitive.\& To make it case-insensitive, search for a regular
expression that sets the case-insensitive flag, e.\&g.\& "(?\&i)oddmu".\&
.PP
.SH SECURITY
.PP
Consider creating a backup before doing replacements!\&
.PP
The following Bash script creates a copy of the current directory using hard
links.\& If you'\&re in a directory called "wiki", it creates a sibling directory
called "wiki-2023-11-24" (using the current date) full of links.\& This takes
little space and time.\& It works as a backup as long as you don'\&t use an
application that edits files in place.\& Most programs overwrite old files by
creating new files with the same name, so you should be safe.\&
.PP
.nf
.RS 4
#!/usr/bin/bash
d=$(basename $(pwd))
t=$(date --iso-8601)
echo Creating a snapshot of $d in \&.\&./$d-$t
rsync --link-dest "\&.\&./$d" --archive \&. "\&.\&./$d-$t/"
.fi
.RE
.PP
The above wouldn'\&t work for database files, for example.\& There, the database
changes the file in place thus the file is changed in the backup directory as
well.\& For Oddmu and the usual text editors, it works.\& If you use Emacs, don'\&t
set \fIbackup-by-copying\fR, \fIbackup-by-copying-when-linked\fR and related variables.\&
.PP
.SH SEE ALSO
.PP
\fIoddmu\fR(1), \fIoddmu-search\fR(7)
.PP
.SH AUTHORS
.PP
Maintained by Alex Schroeder <alex@gnu.\&org>.\&

88
man/oddmu-replace.1.txt Normal file
View File

@@ -0,0 +1,88 @@
ODDMU-REPLACE(1)
# NAME
oddmu-replace - replace text in Oddmu pages from the command-line
# SYNOPSIS
*oddmu replace* [-confirm] [-regexp] _term_ _replacement_
# DESCRIPTION
The "replace" subcommand does a search and replace on all the Markdown files in
the current directory and its subdirectories.
# 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.
*-regexp*
By default, the term to be replaced is just a string. With this flag,
the term is a regular expression and the replacement can contain
backreferences ($1, $2, $3, etc.) to capture groups.
# EXAMPLE
Replace "Oddmu" in the Markdown files of the current directory:
```
oddmu replace Oddmu Oddµ
```
Result:
```
--- README.md~
+++ README.md
(diff omitted)
1 file would be changed.
This is a dry run. Use -confirm to make it happen.
```
# 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.
The search is case-sensitive. To make it case-insensitive, search for a regular
expression that sets the case-insensitive flag, e.g. "(?i)oddmu".
# SECURITY
Consider creating a backup before doing replacements!
The following Bash script creates a copy of the current directory using hard
links. If you're in a directory called "wiki", it creates a sibling directory
called "wiki-2023-11-24" (using the current date) full of links. This takes
little space and time. It works as a backup as long as you don't use an
application that edits files in place. Most programs overwrite old files by
creating new files with the same name, so you should be safe.
```
#!/usr/bin/bash
d=$(basename $(pwd))
t=$(date --iso-8601)
echo Creating a snapshot of $d in ../$d-$t
rsync --link-dest "../$d" --archive . "../$d-$t/"
```
The above wouldn't work for database files, for example. There, the database
changes the file in place thus the file is changed in the backup directory as
well. For Oddmu and the usual text editors, it works. If you use Emacs, don't
set _backup-by-copying_, _backup-by-copying-when-linked_ and related variables.
# SEE ALSO
_oddmu_(1), _oddmu-search_(7)
# AUTHORS
Maintained by Alex Schroeder <alex@gnu.org>.

77
man/oddmu-search.1 Normal file
View File

@@ -0,0 +1,77 @@
.\" 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-12-20"
.PP
.SH NAME
.PP
oddmu-search - search the Oddmu pages from the command-line
.PP
.SH SYNOPSIS
.PP
\fBoddmu search\fR [-extract] [-page \fIn\fR] \fIterms.\&.\&.\&\fR
.PP
.SH DESCRIPTION
.PP
The "search" subcommand searches the Markdown files in the current
directory.\&
.PP
Be default, this returns a Markdown-formatted list suitable for pasting into
Oddmu pages.\&
.PP
If a directory is provided, only files from the tree starting at that
subdirectory are listed, and the directory is stripped from the page name.\&
.PP
See \fIoddmu-search\fR(7) for more information of how pages are searched, sorted and
scored.\&
.PP
.SH OPTIONS
.PP
\fB-dir\fR \fIstring\fR
.RS 4
Limit search to a particular directory.\&
.RE
\fB-extract\fR
.RS 4
Print search extracts for interactive use from the command-line.\&
.RE
\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.\&
.RE
\fB-all\fR
.RS 4
Ignore pagination and just print a long list of results.\&
.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>.\&

58
man/oddmu-search.1.txt Normal file
View File

@@ -0,0 +1,58 @@
ODDMU-SEARCH(1)
# NAME
oddmu-search - search the Oddmu pages from the command-line
# SYNOPSIS
*oddmu search* [-extract] [-page _n_] _terms..._
# DESCRIPTION
The "search" subcommand searches the Markdown files in the current
directory.
Be default, this returns a Markdown-formatted list suitable for pasting into
Oddmu pages.
If a directory is provided, only files from the tree starting at that
subdirectory are listed, and the directory is stripped from the page name.
See _oddmu-search_(7) for more information of how pages are searched, sorted and
scored.
# OPTIONS
*-dir* _string_
Limit search to a particular directory.
*-extract*
Print search extracts for interactive use from the command-line.
*-page* _n_
Search results are paginated and by default only the first page is
shown. This option allows you to view other pages.
*-all*
Ignore pagination and just print a long list of results.
# 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>.

98
man/oddmu-search.7 Normal file
View File

@@ -0,0 +1,98 @@
.\" 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-10-28"
.PP
.SH NAME
.PP
oddmu-search - understanding the Oddmu search engine
.PP
.SH SYNOPSIS
.PP
\fBoddmu search\fR \fIterms\fR.\&.\&.\&
.PP
.SH DESCRIPTION
.PP
The wiki keeps an index of all the hash tags and page titles in memory.\& Using
hashtags and predicates in your queries speeds them up because fewer files are
opened.\&
.PP
A hashtag starts with a number sign ('\&#'\&) and contains numbers, letters, and the
underscore ('\&_'\&).\&
.PP
Example: #old_school random encounter
.PP
The title predicate filters for pages where the term is contained in the page
title.\&
.PP
Example: title:geo title:cache zürich
.PP
The blog predicate filters for pages where the page name begins with an ISO date
like "2023-09-26" if true, or doesn'\&t begin with an ISO date if false.\&
.PP
Example: blog:false fountain
.PP
The sorting of all the pages does not depend on the number of matches or any
kind of score because computing the score is expensive as this requires the page
to be loaded from disk.\& Therefore, results are sorted by title:
.PP
.PD 0
.IP \(bu 4
If a page title matches the query string exactly, it gets sorted first.\&
.IP \(bu 4
If the page title contains the query string, it gets sorted next.\&
.IP \(bu 4
If the page name starts 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
When searching for a hashtag, a page name (not the title!\&) matching the hashtag
exactly (without the leading '\&#'\&) is listed first, even if it doesn'\&t contain
the hashtag.\& It is assumed that this page offers some kind of introduction to
people searching for the hashtag.\&
.PP
Example: When people click on the hashtag "#Oddµ" and a page named "Oddµ" exists
(in other words, the file "Oddµ.\&md" exists), it is prepended to the results even
if it doesn'\&t have the hashtag "#Oddµ" and even if it has a title of "Oddµ, a
minimal wiki" (which wouldn'\&t be an exact match).\&
.PP
The score and highlighting of snippets is used to help visitors decide which
links to click.\&
.PP
Each document found is 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
.SH SEE ALSO
.PP
\fIoddmu\fR(1), \fIoddmu-search\fR(1)
.PP
.SH AUTHORS
.PP
Maintained by Alex Schroeder <alex@gnu.\&org>.\&

78
man/oddmu-search.7.txt Normal file
View File

@@ -0,0 +1,78 @@
ODDMU-SEARCH(7)
# NAME
oddmu-search - understanding the Oddmu search engine
# SYNOPSIS
*oddmu search* _terms_...
# DESCRIPTION
The wiki keeps an index of all the hash tags and page titles in memory. Using
hashtags and predicates in your queries speeds them up because fewer files are
opened.
A hashtag starts with a number sign ('#') and contains numbers, letters, and the
underscore ('\_').
Example: #old_school random encounter
The title predicate filters for pages where the term is contained in the page
title.
Example: title:geo title:cache zürich
The blog predicate filters for pages where the page name begins with an ISO date
like "2023-09-26" if true, or doesn't begin with an ISO date if false.
Example: blog:false fountain
The sorting of all the pages does not depend on the number of matches or any
kind of score because computing the score is expensive as this requires the page
to be loaded from disk. Therefore, results are sorted by title:
- If a page title matches the query string exactly, it gets sorted first.
- If the page title contains the query string, it gets sorted next.
- If the page name starts 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".
When searching for a hashtag, a page name (not the title!) matching the hashtag
exactly (without the leading '#') is listed first, even if it doesn't contain
the hashtag. It is assumed that this page offers some kind of introduction to
people searching for the hashtag.
Example: When people click on the hashtag "#Oddµ" and a page named "Oddµ" exists
(in other words, the file "Oddµ.md" exists), it is prepended to the results even
if it doesn't have the hashtag "#Oddµ" and even if it has a title of "Oddµ, a
minimal wiki" (which wouldn't be an exact match).
The score and highlighting of snippets is used to help visitors decide which
links to click.
Each document found is 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.
# SEE ALSO
_oddmu_(1), _oddmu-search_(1)
# AUTHORS
Maintained by Alex Schroeder <alex@gnu.org>.

85
man/oddmu-static.1 Normal file
View File

@@ -0,0 +1,85 @@
.\" Generated by scdoc 1.11.2
.\" Complete documentation for this program is not available as a GNU info page
.ie \n(.g .ds Aq \(aq
.el .ds Aq '
.nh
.ad l
.\" Begin generated content:
.TH "ODDMU-STATIC" "1" "2024-02-09"
.PP
.SH NAME
.PP
oddmu-static - create a static copy of the site
.PP
.SH SYNOPSIS
.PP
\fBoddmu static\fR \fIdir-name\fR
.PP
.SH DESCRIPTION
.PP
The "static" subcommand generates a static copy of the pages in the current
directory and saves them in the given destination directory.\& The destination
directory must not exist.\&
.PP
All pages (files with the ".\&md" extension) are turned into HTML files (with the
".\&html" extension) using the "static.\&html" template.\& Links pointing to existing
pages get ".\&html" appended.\&
.PP
Hidden files and directories (starting with a ".\&") and backup files (ending with
a "~") are skipped.\&
.PP
All other files are \fIhard linked\fR.\& This is done to save space: on a typical blog
the images take a lot more space than the text.\& On my blog in 2023 I had 2.\&62
GiB of JPG files and 0.\&02 GiB of Markdown files.\& There is no point in copying
all those images, most of the time.\&
.PP
Note, however: Hard links cannot span filesystems.\& A hard link is just an extra
name for the same file.\& This is why the destination directory for the static
site has to be on same filesystem as the current directory, if it contains any
other files besides Markdown files.\&
.PP
Furthermore, in-place editing changes the file for all names.\& Avoid editing the
hard-linked files (anything that'\&s not a HTML file) in the destination
directory, just to be on the safe side.\& Usually you should be fine, as an editor
moves the file that'\&s being edited to a backup file and creates a new file.\& But
then again, who knows.\& A SQLite file, for example, would change in-place, and
therefore making changes to it in the destination directory would change the
original, too.\&
.PP
.SH EXAMPLE
.PP
Generate a static copy of the site:
.PP
.nf
.RS 4
oddmu static \&.\&./archive
.fi
.RE
.PP
.SH LIMITATIONS
.PP
Links from files to pages do not get ".\&html" appended.\& This affects existing
HTML or XML files including SVG files.\&
.PP
Links to absolute URLs (starting with "/") are not changed at all.\& It is up to
you to migrate static folders and applications.\&
.PP
.SH ENVIRONMENT
.PP
The ODDMU_WEBFINGER environment variable has no effect in this situation.\&
Fediverse accounts are not linked to their profile pages.\& Since the data isn'\&t
cached, every run of this command would trigger a webfinger request for every
fediverse account mentioned.\&
.PP
If the site is large, determining the language of a page slows things down.\& Set
the ODDMU_LANGUAGES environment variable to a comma-separated list of ISO 639-1
codes, e.\&g.\& "en" or "en,de,fr,pt" to limit the languages loaded and thereby
speed language determination up.\&
.PP
.SH SEE ALSO
.PP
\fIoddmu\fR(1), \fIoddmu-templates\fR(5)
.PP
.SH AUTHORS
.PP
Maintained by Alex Schroeder <alex@gnu.\&org>.\&

76
man/oddmu-static.1.txt Normal file
View File

@@ -0,0 +1,76 @@
ODDMU-STATIC(1)
# NAME
oddmu-static - create a static copy of the site
# SYNOPSIS
*oddmu static* _dir-name_
# DESCRIPTION
The "static" subcommand generates a static copy of the pages in the current
directory and saves them in the given destination directory. The destination
directory must not exist.
All pages (files with the ".md" extension) are turned into HTML files (with the
".html" extension) using the "static.html" template. Links pointing to existing
pages get ".html" appended.
Hidden files and directories (starting with a ".") and backup files (ending with
a "~") are skipped.
All other files are _hard linked_. This is done to save space: on a typical blog
the images take a lot more space than the text. On my blog in 2023 I had 2.62
GiB of JPG files and 0.02 GiB of Markdown files. There is no point in copying
all those images, most of the time.
Note, however: Hard links cannot span filesystems. A hard link is just an extra
name for the same file. This is why the destination directory for the static
site has to be on same filesystem as the current directory, if it contains any
other files besides Markdown files.
Furthermore, in-place editing changes the file for all names. Avoid editing the
hard-linked files (anything that's not a HTML file) in the destination
directory, just to be on the safe side. Usually you should be fine, as an editor
moves the file that's being edited to a backup file and creates a new file. But
then again, who knows. A SQLite file, for example, would change in-place, and
therefore making changes to it in the destination directory would change the
original, too.
# EXAMPLE
Generate a static copy of the site:
```
oddmu static ../archive
```
# LIMITATIONS
Links from files to pages do not get ".html" appended. This affects existing
HTML or XML files including SVG files.
Links to absolute URLs (starting with "/") are not changed at all. It is up to
you to migrate static folders and applications.
# ENVIRONMENT
The ODDMU_WEBFINGER environment variable has no effect in this situation.
Fediverse accounts are not linked to their profile pages. Since the data isn't
cached, every run of this command would trigger a webfinger request for every
fediverse account mentioned.
If the site is large, determining the language of a page slows things down. Set
the ODDMU_LANGUAGES environment variable to a comma-separated list of ISO 639-1
codes, e.g. "en" or "en,de,fr,pt" to limit the languages loaded and thereby
speed language determination up.
# SEE ALSO
_oddmu_(1), _oddmu-templates_(5)
# AUTHORS
Maintained by Alex Schroeder <alex@gnu.org>.

219
man/oddmu-templates.5 Normal file
View File

@@ -0,0 +1,219 @@
.\" Generated by scdoc 1.11.2
.\" Complete documentation for this program is not available as a GNU info page
.ie \n(.g .ds Aq \(aq
.el .ds Aq '
.nh
.ad l
.\" Begin generated content:
.TH "ODDMU-TEMPLATES" "5" "2024-02-06" "File Formats Manual"
.PP
.SH NAME
.PP
oddmu-templates - how to write the templates
.PP
.SH SYNOPSIS
.PP
These files act as HTML templates: add.\&html, diff.\&html, edit.\&html, feed.\&html,
search.\&html, static.\&html, upload.\&html and view.\&html.\& They contain special
placeholders in double bracers {{like this}}.\&
.PP
.SH SYNTAX
.PP
The templates can refer to the following properties of a page:
.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
percent-escaped except for the slashes.\& The page name doesn'\&t include the \fI.\&md\fR
extension.\&
.PP
\fI{{.\&Dir}}\fR is the page directory, percent-escaped except for the slashes.\&
.PP
\fI{{.\&Base}}\fR is the basename of the current file (without the directory and
without the \fI.\&md\fR extension), escaped for use in URLs.\&
.PP
\fI{{.\&Today}}\fR is the current date, in ISO format.\& This is useful for "new page"
like links or forms (see \fBEXAMPLE\fR below).\&
.PP
For the \fIview.\&html\fR and \fIstatic.\&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 \fIdiff.\&html\fR template:
.PP
\fI{{.\&Diff}}\fR is the diff for this page.\& This is only computed on demand so it can
be used in other templates, too.\& It probably doesn'\&t make much sense to do so,
however.\&
.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 and \fI{{.\&Next}}\fR are the previous, current and next
page number in the results since doing arithmetics in templates is hard.\& The
first page number is 1.\& The last page is expensive to dermine and so that isn'\&t
done.\&
.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 escaped (!\&) 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 query parameter \fIq\fR.\&
.PP
.nf
.RS 4
curl \&'http://localhost:8080/search/?q=towel\&'
.fi
.RE
.PP
The page name to act upon is optionally taken from the query parameter \fIid\fR.\& In
this case, the directory must also be part of the query parameter and not of the
URL path.\&
.PP
.nf
.RS 4
curl \&'http://localhost:8080/view/?id=man/oddmu\&.1\&.txt\&'
.fi
.RE
.PP
.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
.SH EXAMPLE
.PP
The following link in a template takes people to today'\&s page.\& If no such page
exists, they are redirected to the edit form where it can be created.\&
.PP
.nf
.RS 4
<a href="/view/{{\&.Today}}" accesskey="t">Today</a>
.fi
.RE
.PP
The following form allows people to edit the suggested page name.\&
.PP
.nf
.RS 4
<form role="new" action="/edit/{{\&.Dir}}" method="GET">
<label for="id">New page:</label>
<input id="id" type="text" spellcheck="false" name="id"
accesskey="g" value="{{\&.Today}}" required>
<button>Edit</button>
</form>
.fi
.RE
.PP
.SH NOTES
.PP
The templates are always used as-is, irrespective of the current directory.\&
Therefore, a link to a specific page must be \fIabsolute\fR or it'\&ll point to a
different page depending on the current directory.\&
.PP
Consider the link to "/view/index".\& No matter what page a visitor is looking,
this takes visitors to the top "index" page.\& If the link points to "index"
instead, it takes a visitor to the "index" page of the current directory.\& In
this case, a visitor looking at "/view/projects/wiki" following a link to
"index" ends up on "/view/projects/index", not on "/view/index".\&
.PP
It'\&s up to you to decide what'\&s best for your site, of course.\&
.PP
Templates can be changed by uploading new copies of the template files.\&
.PP
Subdirectories can have their own copies of template files.\& One example use for
this is that they can point to a different CSS file.\&
.PP
.SH 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>.\&

202
man/oddmu-templates.5.txt Normal file
View File

@@ -0,0 +1,202 @@
ODDMU-TEMPLATES(5) "File Formats Manual"
# NAME
oddmu-templates - how to write the templates
# SYNOPSIS
These files act as HTML templates: add.html, diff.html, edit.html, feed.html,
search.html, static.html, upload.html and view.html. They contain special
placeholders in double bracers {{like this}}.
# SYNTAX
The templates can refer to the following properties of a page:
_{{.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
percent-escaped except for the slashes. The page name doesn't include the _.md_
extension.
_{{.Dir}}_ is the page directory, percent-escaped except for the slashes.
_{{.Base}}_ is the basename of the current file (without the directory and
without the _.md_ extension), escaped for use in URLs.
_{{.Today}}_ is the current date, in ISO format. This is useful for "new page"
like links or forms (see *EXAMPLE* below).
For the _view.html_ and _static.html_ template:
_{{.Html}}_ is the rendered Markdown, as HTML.
_{{.Hashtags}}_ is an array of strings.
For the _diff.html_ template:
_{{.Diff}}_ is the diff for this page. This is only computed on demand so it can
be used in other templates, too. It probably doesn't make much sense to do so,
however.
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}}_ and _{{.Next}}_ are the previous, current and next
page number in the results since doing arithmetics in templates is hard. The
first page number is 1. The last page is expensive to dermine and so that isn't
done.
_{{.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 query parameter _q_.
```
curl 'http://localhost:8080/search/?q=towel'
```
The page name to act upon is optionally taken from the query parameter _id_. In
this case, the directory must also be part of the query parameter and not of the
URL path.
```
curl 'http://localhost:8080/view/?id=man/oddmu.1.txt'
```
## 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.
# EXAMPLE
The following link in a template takes people to today's page. If no such page
exists, they are redirected to the edit form where it can be created.
```
<a href="/view/{{.Today}}" accesskey="t">Today</a>
```
The following form allows people to edit the suggested page name.
```
<form role="new" action="/edit/{{.Dir}}" method="GET">
<label for="id">New page:</label>
<input id="id" type="text" spellcheck="false" name="id"
accesskey="g" value="{{.Today}}" required>
<button>Edit</button>
</form>
```
# NOTES
The templates are always used as-is, irrespective of the current directory.
Therefore, a link to a specific page must be _absolute_ or it'll point to a
different page depending on the current directory.
Consider the link to "/view/index". No matter what page a visitor is looking,
this takes visitors to the top "index" page. If the link points to "index"
instead, it takes a visitor to the "index" page of the current directory. In
this case, a visitor looking at "/view/projects/wiki" following a link to
"index" ends up on "/view/projects/index", not on "/view/index".
It's up to you to decide what's best for your site, of course.
Templates can be changed by uploading new copies of the template files.
Subdirectories can have their own copies of template files. One example use for
this is that they can point to a different CSS file.
# 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>.

38
man/oddmu-version.1 Normal file
View File

@@ -0,0 +1,38 @@
.\" 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-VERSION" "1" "2024-02-13"
.PP
.SH NAME
.PP
oddmu-version - print build info on the command-line
.PP
.SH SYNOPSIS
.PP
\fBoddmu version\fR
.PP
.SH DESCRIPTION
.PP
The "version" subcommand prints a lot of stuff used to build the binary,
including the git revision, git repository, versions of dependencies used and
more.\&
.PP
It'\&s the equivalent of running this:
.PP
.nf
.RS 4
go version -m oddmu
.fi
.RE
.PP
.SH SEE ALSO
.PP
\fIoddmu\fR(1)
.PP
.SH AUTHORS
.PP
Maintained by Alex Schroeder <alex@gnu.\&org>.\&

29
man/oddmu-version.1.txt Normal file
View File

@@ -0,0 +1,29 @@
ODDMU-VERSION(1)
# NAME
oddmu-version - print build info on the command-line
# SYNOPSIS
*oddmu version*
# DESCRIPTION
The "version" subcommand prints a lot of stuff used to build the binary,
including the git revision, git repository, versions of dependencies used and
more.
It's the equivalent of running this:
```
go version -m oddmu
```
# SEE ALSO
_oddmu_(1)
# AUTHORS
Maintained by Alex Schroeder <alex@gnu.org>.

267
man/oddmu.1 Normal file
View File

@@ -0,0 +1,267 @@
.\" Generated by scdoc 1.11.2
.\" Complete documentation for this program is not available as a GNU info page
.ie \n(.g .ds Aq \(aq
.el .ds Aq '
.nh
.ad l
.\" Begin generated content:
.TH "ODDMU" "1" "2024-02-13"
.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
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
("*").\&
.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", "diff.\&html", "edit.\&html", "search.\&html", "upload.\&html" and
"view.\&html".\& Please change them!\&
.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 its ".\&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
You can change the address served by setting the ODDMU_ADDRESS environment
variable to either an IPv4 address or an IPv6 address.\& If ODDMU_ADDRESS is
unset, then the program listens on all available unicast addresses, both IPv4
and IPv6.\& Here are a few example addresses:
.PP
ODDMU_ADDRESS=127.\&0.\&0.\&1 # The loopback IPv4 address.\&
ODDMU_ADDRESS=2001:db8::3:1 # An IPv6 address.\&
.PP
See the Socket Activation section for an alternative method of listening which
supports Unix-domain sockets.\&
.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
If you use secret subdirectories, you cannot rely on the web server to hide
those pages because search includes subdirectories.\& In order to protect those
pages from search, you need to use the ODDMU_FILTER to a regular exression such
as "^secret/".\& See \fIoddmu-apache\fR(5).\&
.PP
.SH Socket Activation
.PP
Instead of specifying ODDMU_ADDRESS or ODDMU_PORT, you can start the service
through socket activation.\& The advantage of this method is that you can use a
Unix-domain socket instead of a TCP socket, and the permissions and ownership of
the socket are set before the program starts.\& See \fIoddmu.\&service\fR(5) and
\fIoddmu-apache\fR(5) for an example of how to use socket activation with a
Unix-domain socket under systemd and Apache.\&
.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
Oddmu assumes that all the users that can edit pages or upload files are trusted
users and therefore their content is trusted.\& Therefore, Oddmu doesn'\&t perform
HTML sanitization!\&
.PP
For an extra dose of security, consider using a Unix-domain socket.\&
.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 single page, see \fIoddmu-html\fR(1)
.IP \(bu 4
to generate the HTML for the entire site, using Oddmu as a static site
generator, see \fIoddmu-static\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)
.IP \(bu 4
to find missing pages (local links that go nowhere), see \fIoddmu-missing\fR(1)
.IP \(bu 4
to add links to changes, index and hashtag pages to pages you created locally,
see \fIoddmu-notify\fR(1)
.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 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.\& Filenames can contain slashes and oddmu creates
subdirectories as necessary.\&
.PP
Files may not end with a tilde ('\&~'\&) these are backup files.\& When saving pages
and file uploads, the old file renamed to the backup file unless the backup file
is less than an hour old, thus collapsing all edits made in an hour into a
single diff when comparing backup and current version.\&
.PP
The \fBindex\fR page is the default page.\& People visiting the "root" of the site are
redirected to "/view/index".\&
.PP
The \fBchanges\fR page is where links to new and changed files are added.\& As an
author, you can prevent this from happening by deselecting the checkbox "Add
link to the list of changes.\&" The changes page can be edited like every other
page, so it'\&s easy to undo mistakes.\&
.PP
Links on the changes page are grouped by date.\& When new links are added, the
current date of the machine Oddmu is running on is used.\& If a link already
exists on the changes page, it is moved up to the current date.\& If that leaves
an old date without any links, that date heading is removed.\&
.PP
If you want to link to the changes page, you need to do this yourself.\& Add a
link from the index, for example.\& The "view.\&html" template currently doesn'\&t do
it.\& See \fIoddmu-templates\fR(5) if you want to add the link to the template.\&
.PP
A page whose name starts with an ISO date (YYYY-MM-DD, e.\&g.\& "2023-10-28") is
called a \fBblog\fR page.\& When creating or editing blog pages, links to it are added
from other pages.\&
.PP
If the blog page name starts with the current year, a link is created from the
index page back to the blog page being created or edited.\& Again, you can prevent
this from happening by deselecting the checkbox "Add link to the list of
changes.\&" The index page can be edited like every other page, so it'\&s easy to
undo mistakes.\&
.PP
For every \fBhashtag\fR used, another link might be created.\& If a page named like
the hashtag exists, a backlink is added to it, linking to the new or edited blog
page.\&
.PP
If a link to the new or edited blog page already exists but it'\&s title is no
longer correct, it is updated.\&
.PP
New links added for blog pages are added at the top of the first unnumbered list
using the asterisk ('\&*'\&).\& If no such list exists, a new one is started at the
bottom of the page.\& This allows you to have a different unnumbered list further
up on the page, as long as it uses the minus for items ('\&-'\&).\&
.PP
Changes made locally do not create any links on the changes page, the index page
or on any hashtag pages.\& See \fIoddmu-notify\fR(1) for a way to add the necessary
links to the changes page and possibly to the index and hashtag pages.\&
.PP
A hashtag consists of a number sign ('\&#'\&) followed by Unicode letters, numbers
or the underscore ('\&_'\&).\& Thus, a hashtag ends with punctuation or whitespace.\&
.PP
The page names, titles and hashtags are loaded into memory when the server
starts.\& If you have a lot of pages, this takes a lot of memory.\&
.PP
Oddmu watches the working directory and any subdirectories for changes made
directly.\& Thus, in theory, it'\&s not necessary to restart it after making such
changes.\&
.PP
You cannot edit uploaded files.\& If you upload a file called "hello.\&txt" and
attempt to edit it by using "/edit/hello.\&txt" you create a page with the name
"hello.\&txt.\&md" instead.\&
.PP
In order to delete uploaded files via the web, create an empty file and upload
it.\& In order to delete a wiki page, save an empty page.\&
.PP
Note that some HTML file names are special: they act as templates.\& See
\fIoddmu-templates\fR(5) for their names and their use.\&
.PP
.SH SEE ALSO
.PP
.PD 0
.IP \(bu 4
\fIoddmu\fR(5), about the markup syntax and how feeds are generated based on link lists
.IP \(bu 4
\fIoddmu.\&service\fR(5), on how to run the service under systemd
.IP \(bu 4
\fIoddmu-apache\fR(5), on how to set up a web server such as Apache
.IP \(bu 4
\fIoddmu-html\fR(1), on how to render a page from the command-line
.IP \(bu 4
\fIoddmu-list\fR(1), on how to list pages and titles from the command-line
.IP \(bu 4
\fIoddmu-missing\fR(1), on how to find broken local links from the command-line
.IP \(bu 4
\fIoddmu-replace\fR(1), on how to search and replace text from the command-line
.IP \(bu 4
\fIoddmu-search\fR(1), on how to run a search from the command-line
.IP \(bu 4
\fIoddmu-search\fR(7), on how search works
.IP \(bu 4
\fIoddmu-static\fR(1), on generating a static site from the command-line
.IP \(bu 4
\fIoddmu-notify\fR(1), on updating index, changes and hashtag pages from the
command-line
.IP \(bu 4
\fIoddmu-templates\fR(5), on how to write the HTML templates
.IP \(bu 4
\fIoddmu-version\fR(1), on how to get all the build information from the binary
.PD
.PP
.SH AUTHORS
.PP
Maintained by Alex Schroeder <alex@gnu.\&org>.\&

235
man/oddmu.1.txt Normal file
View File

@@ -0,0 +1,235 @@
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", "diff.html", "edit.html", "search.html", "upload.html" and
"view.html". Please change them!
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 its ".html" extension).
See _oddmu-templates_(5) for more.
# ENVIRONMENT
You can change the port served by setting the ODDMU_PORT environment variable.
You can change the address served by setting the ODDMU_ADDRESS environment
variable to either an IPv4 address or an IPv6 address. If ODDMU_ADDRESS is
unset, then the program listens on all available unicast addresses, both IPv4
and IPv6. Here are a few example addresses:
ODDMU_ADDRESS=127.0.0.1 # The loopback IPv4 address.
ODDMU_ADDRESS=2001:db8::3:1 # An IPv6 address.
See the Socket Activation section for an alternative method of listening which
supports Unix-domain sockets.
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).
If you use secret subdirectories, you cannot rely on the web server to hide
those pages because search includes subdirectories. In order to protect those
pages from search, you need to use the ODDMU_FILTER to a regular exression such
as "^secret/". See _oddmu-apache_(5).
# Socket Activation
Instead of specifying ODDMU_ADDRESS or ODDMU_PORT, you can start the service
through socket activation. The advantage of this method is that you can use a
Unix-domain socket instead of a TCP socket, and the permissions and ownership of
the socket are set before the program starts. See _oddmu.service_(5) and
_oddmu-apache_(5) for an example of how to use socket activation with a
Unix-domain socket under systemd and Apache.
# 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.
Oddmu assumes that all the users that can edit pages or upload files are trusted
users and therefore their content is trusted. Therefore, Oddmu doesn't perform
HTML sanitization!
For an extra dose of security, consider using a Unix-domain socket.
# OPTIONS
The oddmu program can be run on the command-line using various subcommands.
- to generate the HTML for a single page, see _oddmu-html_(1)
- to generate the HTML for the entire site, using Oddmu as a static site
generator, see _oddmu-static_(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)
- to find missing pages (local links that go nowhere), see _oddmu-missing_(1)
- to add links to changes, index and hashtag pages to pages you created locally,
see _oddmu-notify_(1)
# 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 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. Filenames can contain slashes and oddmu creates
subdirectories as necessary.
Files may not end with a tilde ('~') these are backup files. When saving pages
and file uploads, the old file renamed to the backup file unless the backup file
is less than an hour old, thus collapsing all edits made in an hour into a
single diff when comparing backup and current version.
The *index* page is the default page. People visiting the "root" of the site are
redirected to "/view/index".
The *changes* page is where links to new and changed files are added. As an
author, you can prevent this from happening by deselecting the checkbox "Add
link to the list of changes." The changes page can be edited like every other
page, so it's easy to undo mistakes.
Links on the changes page are grouped by date. When new links are added, the
current date of the machine Oddmu is running on is used. If a link already
exists on the changes page, it is moved up to the current date. If that leaves
an old date without any links, that date heading is removed.
If you want to link to the changes page, you need to do this yourself. Add a
link from the index, for example. The "view.html" template currently doesn't do
it. See _oddmu-templates_(5) if you want to add the link to the template.
A page whose name starts with an ISO date (YYYY-MM-DD, e.g. "2023-10-28") is
called a *blog* page. When creating or editing blog pages, links to it are added
from other pages.
If the blog page name starts with the current year, a link is created from the
index page back to the blog page being created or edited. Again, you can prevent
this from happening by deselecting the checkbox "Add link to the list of
changes." The index page can be edited like every other page, so it's easy to
undo mistakes.
For every *hashtag* used, another link might be created. If a page named like
the hashtag exists, a backlink is added to it, linking to the new or edited blog
page.
If a link to the new or edited blog page already exists but it's title is no
longer correct, it is updated.
New links added for blog pages are added at the top of the first unnumbered list
using the asterisk ('\*'). If no such list exists, a new one is started at the
bottom of the page. This allows you to have a different unnumbered list further
up on the page, as long as it uses the minus for items ('-').
Changes made locally do not create any links on the changes page, the index page
or on any hashtag pages. See _oddmu-notify_(1) for a way to add the necessary
links to the changes page and possibly to the index and hashtag pages.
A hashtag consists of a number sign ('#') followed by Unicode letters, numbers
or the underscore ('\_'). Thus, a hashtag ends with punctuation or whitespace.
The page names, titles and hashtags are loaded into memory when the server
starts. If you have a lot of pages, this takes a lot of memory.
Oddmu watches the working directory and any subdirectories for changes made
directly. Thus, in theory, it's not necessary to restart it after making such
changes.
You cannot edit uploaded files. If you upload a file called "hello.txt" and
attempt to edit it by using "/edit/hello.txt" you create a page with the name
"hello.txt.md" instead.
In order to delete uploaded files via the web, create an empty file and upload
it. In order to delete a wiki page, save an empty page.
Note that some HTML file names are special: they act as templates. See
_oddmu-templates_(5) for their names and their use.
# SEE ALSO
- _oddmu_(5), about the markup syntax and how feeds are generated based on link lists
- _oddmu.service_(5), on how to run the service under systemd
- _oddmu-apache_(5), on how to set up a web server such as Apache
- _oddmu-html_(1), on how to render a page from the command-line
- _oddmu-list_(1), on how to list pages and titles from the command-line
- _oddmu-missing_(1), on how to find broken local links from the command-line
- _oddmu-replace_(1), on how to search and replace text from the command-line
- _oddmu-search_(1), on how to run a search from the command-line
- _oddmu-search_(7), on how search works
- _oddmu-static_(1), on generating a static site from the command-line
- _oddmu-notify_(1), on updating index, changes and hashtag pages from the
command-line
- _oddmu-templates_(5), on how to write the HTML templates
- _oddmu-version_(1), on how to get all the build information from the binary
# AUTHORS
Maintained by Alex Schroeder <alex@gnu.org>.

211
man/oddmu.5 Normal file
View File

@@ -0,0 +1,211 @@
.\" 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-11-12" "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 (with the ".\&md" extension).\&
Oddmu links are regular Markdown links to page names (without the ".\&md"
extension):
.PP
.nf
.RS 4
[link text](page-name)
.fi
.RE
.PP
The page name has to be percent-encoded.\& See the section "Percent Encoding".\&
.PP
If you link to the actual Markdown file (with the ".\&md" extension), then Oddmu
serves the Markdown file!\&
.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]].\& Oddmu does not treat
underscores like spaces, so [[like this]] and [[like_this]] link to different
destinations and are served by different files: "like this.\&md" and
"like_this.\&md".\&
.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
# Example
Text
#Tag #Another_Tag
.fi
.RE
.PP
When a page containing hashtags is saved, a link to that page is added to every
page with the same name as the hashtag, if it exists.\& In the example above, if
the file "Tag.\&md" or the file "Another_Tag.\&md" exists, a link to the Example
page is added.\&
.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 works 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
.PD 0
.IP \(bu 4
emphasis markers inside words are ignored
.IP \(bu 4
fenced code blocks are supported
.IP \(bu 4
autolinking of "naked" URLs are supported
.IP \(bu 4
strikethrough using two tildes is supported (~~like this~~)
.IP \(bu 4
it is strict about prefix heading rules
.IP \(bu 4
you can specify an id for headings ({#id})
.IP \(bu 4
trailing backslashes turn into line breaks
.PD
.PP
.SH FEEDS
.PP
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
("//").\&
.PP
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.\&
.PP
.nf
.RS 4
# Main Page
Hello and welcome! Here are some important links:
- [Feed](index\&.rss)
- [About](about)
Recent posts:
* [Arianism](arianism)
* [Donatism](donatism)
* [Monophysitism](monophysitism)
.fi
.RE
.PP
The feed contains at most 10 items, starting at the top.\&
.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
Another thing that'\&s common is that your page name contains a colon.\&
This is legal.\& The URL parser might still reject it.\& If you run the
"missing" subcommand, you'\&ll get to see error: "first path segment in
URL cannot contain colon".\& The solution is to prepend ".\&/"!\&
.PP
Example:
.PP
.nf
.RS 4
[2021-10-15 Re: Mark It Down](2021-10-15_Re:_Mark_It_Down)
.fi
.RE
.PP
Fixed:
.PP
.nf
.RS 4
[2021-10-15 Re: Mark It Down](\&./2021-10-15_Re:_Mark_It_Down)
.fi
.RE
.PP
.SH SEE ALSO
.PP
\fIoddmu\fR(1), \fIoddmu-missing\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>.\&

181
man/oddmu.5.txt Normal file
View File

@@ -0,0 +1,181 @@
ODDMU(5) "File Formats Manual"
# NAME
oddmu - text formatting of wiki pages
# SYNTAX
The wiki pages are UTF-8 encoded Markdown files (with the ".md" extension).
Oddmu links are regular Markdown links to page names (without the ".md"
extension):
```
[link text](page-name)
```
The page name has to be percent-encoded. See the section "Percent Encoding".
If you link to the actual Markdown file (with the ".md" extension), then Oddmu
serves the Markdown file!
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]]. Oddmu does not treat
underscores like spaces, so [[like this]] and [[like_this]] link to different
destinations and are served by different files: "like this.md" and
"like_this.md".
## 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.
```
# Example
Text
#Tag #Another_Tag
```
When a page containing hashtags is saved, a link to that page is added to every
page with the same name as the hashtag, if it exists. In the example above, if
the file "Tag.md" or the file "Another_Tag.md" exists, a link to the Example
page is added.
## 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 works 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
# 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)".
Another thing that's common is that your page name contains a colon.
This is legal. The URL parser might still reject it. If you run the
"missing" subcommand, you'll get to see error: "first path segment in
URL cannot contain colon". The solution is to prepend "./"!
Example:
```
[2021-10-15 Re: Mark It Down](2021-10-15_Re:_Mark_It_Down)
```
Fixed:
```
[2021-10-15 Re: Mark It Down](./2021-10-15_Re:_Mark_It_Down)
```
# SEE ALSO
_oddmu_(1), _oddmu-missing_(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>.

110
man/oddmu.service.5 Normal file
View File

@@ -0,0 +1,110 @@
.\" Generated by scdoc 1.11.2
.\" Complete documentation for this program is not available as a GNU info page
.ie \n(.g .ds Aq \(aq
.el .ds Aq '
.nh
.ad l
.\" Begin generated content:
.TH "ODDMU.SERVICE" "5" "2024-01-17"
.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 templates 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
For it to restart when the server reboots:
.PP
.nf
.RS 4
sudo ln -sf /home/oddmu/oddmu\&.service
/etc/systemd/system/multi-user\&.target\&.wants/
.fi
.RE
.PP
.SH Socket Activation
.PP
Alternatively, you can let systemd handle the creation of the listening socket,
passing it to Oddmu.\& See "oddmu-unix-domain.\&service" and
"oddmu-unix-domain.\&socket" for a fully worked example of how to do this with a
Unix domain socket.\& Take note of "Accept=no" in the .\&socket file and
"StandardInput=socket" in the .\&service file.\& The option "StandardInput=socket"
tells systemd to pass the socket to the service as its standard input.\&
"Accept=no" tells systemd to pass a listening socket, rather than to try calling
Oddmu for each connection.\&
.PP
The instructions for starting and enabling the systemd service are almost
exactly the same as those in the previous section, with "oddmu.\&service" replaced
by "oddmu-unix-domain.\&service".\& You'\&ll also need to run the following:
.PP
.nf
.RS 4
ln -s /home/oddmu/oddmu-unix-domain\&.socket /etc/systemd/system
.fi
.RE
.PP
.SH SEE ALSO
.PP
\fIoddmu\fR(1), \fIsystemd.\&exec\fR(5), \fIsystemd.\&socket(5), \fRcapabilities_(7)
.PP
.SH AUTHORS
.PP
Maintained by Alex Schroeder <alex@gnu.\&org>.\&

89
man/oddmu.service.5.txt Normal file
View File

@@ -0,0 +1,89 @@
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 templates 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
```
For it to restart when the server reboots:
```
sudo ln -sf /home/oddmu/oddmu.service \
/etc/systemd/system/multi-user.target.wants/
```
# Socket Activation
Alternatively, you can let systemd handle the creation of the listening socket,
passing it to Oddmu. See "oddmu-unix-domain.service" and
"oddmu-unix-domain.socket" for a fully worked example of how to do this with a
Unix domain socket. Take note of "Accept=no" in the .socket file and
"StandardInput=socket" in the .service file. The option "StandardInput=socket"
tells systemd to pass the socket to the service as its standard input.
"Accept=no" tells systemd to pass a listening socket, rather than to try calling
Oddmu for each connection.
The instructions for starting and enabling the systemd service are almost
exactly the same as those in the previous section, with "oddmu.service" replaced
by "oddmu-unix-domain.service". You'll also need to run the following:
```
ln -s /home/oddmu/oddmu-unix-domain.socket /etc/systemd/system
```
# SEE ALSO
_oddmu_(1), _systemd.exec_(5), _systemd.socket(5), _capabilities_(7)
# AUTHORS
Maintained by Alex Schroeder <alex@gnu.org>.

123
missing_cmd.go Normal file
View File

@@ -0,0 +1,123 @@
package main
import (
"context"
"flag"
"fmt"
"github.com/gomarkdown/markdown"
"github.com/gomarkdown/markdown/ast"
"github.com/google/subcommands"
"io"
"io/fs"
"net/url"
"os"
"path/filepath"
"strings"
)
type missingCmd struct {
}
func (*missingCmd) Name() string { return "missing" }
func (*missingCmd) Synopsis() string { return "list missing pages" }
func (*missingCmd) Usage() string {
return `missing:
Listing pages with links to missing pages.
`
}
func (cmd *missingCmd) SetFlags(f *flag.FlagSet) {
}
func (cmd *missingCmd) Execute(_ context.Context, f *flag.FlagSet, _ ...interface{}) subcommands.ExitStatus {
return missingCli(os.Stdout, f.Args())
}
func missingCli(w io.Writer, args []string) subcommands.ExitStatus {
names := make(map[string]bool)
err := filepath.Walk(".", func(path string, info fs.FileInfo, err error) error {
if err != nil {
return err
}
// skip hidden directories and files
if path != "." && strings.HasPrefix(filepath.Base(path), ".") {
if info.IsDir() {
return filepath.SkipDir
} else {
return nil
}
}
if strings.HasSuffix(path, ".md") {
name := filepath.ToSlash(strings.TrimSuffix(path, ".md"))
names[name] = true
} else {
names[path] = false
}
return nil
})
if err != nil {
fmt.Fprintln(w, err)
return subcommands.ExitFailure
}
found := false
for name, isPage := range names {
if !isPage {
continue
}
p, err := loadPage(name)
if err != nil {
fmt.Fprintf(os.Stderr, "Loading %s: %s\n", p.Name, err)
return subcommands.ExitFailure
}
for _, link := range p.links() {
u, err := url.Parse(link)
if err != nil {
fmt.Fprintln(os.Stderr, p.Name, err)
return subcommands.ExitFailure
}
if u.Scheme == "" && u.Path != "" && !strings.HasPrefix(u.Path, "/") {
// feeds can work if the matching page works
u.Path = strings.TrimSuffix(u.Path, ".rss")
// links to the source file can work
u.Path = strings.TrimSuffix(u.Path, ".md")
// pages containing a colon need the ./ prefix
u.Path = strings.TrimPrefix(u.Path, "./")
// check whether the destinatino is a known page
destination, err := url.PathUnescape(u.Path)
if err != nil {
fmt.Fprintf(os.Stderr, "Cannot decode %s: %s\n", link, err)
return subcommands.ExitFailure
}
_, ok := names[destination]
if !ok {
if !found {
fmt.Fprintln(w, "Page\tMissing")
found = true
}
fmt.Fprintf(w, "%s\t%s\n", p.Name, link)
}
}
}
}
if !found {
fmt.Fprintln(w, "No missing pages found.")
}
return subcommands.ExitSuccess
}
// links parses the page content and returns an array of link destinations.
func (p *Page) links() []string {
var links []string
parser, _ := wikiParser()
doc := markdown.Parse(p.Body, parser)
ast.WalkFunc(doc, func(node ast.Node, entering bool) ast.WalkStatus {
if entering {
switch v := node.(type) {
case *ast.Link:
links = append(links, string(v.Destination))
}
}
return ast.GoToNext
})
return links
}

18
missing_cmd_test.go Normal file
View File

@@ -0,0 +1,18 @@
package main
import (
"bytes"
"github.com/google/subcommands"
"github.com/stretchr/testify/assert"
"testing"
)
func TestMissingCmd(t *testing.T) {
b := new(bytes.Buffer)
s := missingCli(b, nil)
assert.Equal(t, subcommands.ExitSuccess, s)
r := `Page Missing
index test
`
assert.Equal(t, r, b.String())
}

47
notify_cmd.go Normal file
View File

@@ -0,0 +1,47 @@
package main
import (
"context"
"flag"
"fmt"
"github.com/google/subcommands"
"io"
"os"
)
type notifyCmd struct {
}
func (*notifyCmd) Name() string { return "notify" }
func (*notifyCmd) Synopsis() string { return "add links to changes.md, index.md, and hashtag pages" }
func (*notifyCmd) Usage() string {
return `notify <page name> ...:
For each page, add entries to changes.md, index.md, and hashtag pages.
This is useful when writing pages offline and replicates the behaviour
triggered by the "Add link to the list of changes" checkbox, online.
`
}
func (cmd *notifyCmd) SetFlags(f *flag.FlagSet) {
}
func (cmd *notifyCmd) Execute(_ context.Context, f *flag.FlagSet, _ ...interface{}) subcommands.ExitStatus {
return notifyCli(os.Stdout, f.Args())
}
func notifyCli(w io.Writer, args []string) subcommands.ExitStatus {
index.load()
for _, name := range args {
p, err := loadPage(name)
if err != nil {
fmt.Fprintf(os.Stderr, "Loading %s: %s\n", name, err)
return subcommands.ExitFailure
}
err = p.notify()
if err != nil {
fmt.Fprintf(os.Stderr, "%s: %s\n", name, err)
return subcommands.ExitFailure
}
}
return subcommands.ExitSuccess
}

53
oddmu-unix-domain.service Normal file
View File

@@ -0,0 +1,53 @@
[Unit]
Description=Oddmu
After=network.target
Requires=oddmu-unix-domain.socket
[Install]
WantedBy=multi-user.target
[Service]
Type=simple
Restart=always
StandardInput=socket
StandardOutput=journal
StandardError=journal
DynamicUser=true
MemoryMax=256M
MemoryHigh=128M
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
RemoveIPC=yes
MemoryDenyWriteExecute=yes
# Sandboxing options to harden security
NoNewPrivileges=yes
PrivateTmp=yes
PrivateDevices=yes
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
RestrictNamespaces=yes
RestrictRealtime=yes
DevicePolicy=closed
ProtectSystem=full
ProtectControlGroups=yes
ProtectKernelModules=yes
ProtectKernelTunables=yes
LockPersonality=yes
SystemCallFilter=~@clock @debug @module @mount @obsolete @reboot @setuid @swap
# Denying access to capabilities that should not be relevant
# (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
CapabilityBoundingSet=~CAP_LEASE CAP_LINUX_IMMUTABLE CAP_IPC_LOCK
CapabilityBoundingSet=~CAP_BLOCK_SUSPEND CAP_WAKE_ALARM
CapabilityBoundingSet=~CAP_SYS_TTY_CONFIG
CapabilityBoundingSet=~CAP_MAC_ADMIN CAP_MAC_OVERRIDE
CapabilityBoundingSet=~CAP_NET_ADMIN CAP_NET_BROADCAST CAP_NET_RAW
CapabilityBoundingSet=~CAP_SYS_ADMIN CAP_SYS_PTRACE CAP_SYSLOG

14
oddmu-unix-domain.socket Normal file
View File

@@ -0,0 +1,14 @@
[Unit]
Description=Oddmu server socket
[Socket]
ListenStream=/run/oddmu/oddmu.sock
SocketGroup=www-data
# Systemd manages the socket, so may as well let it be owned by root.
SocketUser=root
# But it needs to be readable and writable by the web server.
SocketMode=0660
Accept=no
[Install]
WantedBy=sockets.target

View File

@@ -12,6 +12,7 @@ MemoryHigh=120M
ExecStart=/home/oddmu/oddmu
WorkingDirectory=/home/oddmu
Environment="ODDMU_PORT=8080"
Environment="ODDMU_WEBFINGER=1"
# (man "systemd.exec")
ReadWritePaths=/home/oddmu
@@ -21,7 +22,6 @@ 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
@@ -37,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

192
page.go
View File

@@ -2,21 +2,21 @@ package main
import (
"bytes"
"fmt"
"github.com/gomarkdown/markdown"
"github.com/gomarkdown/markdown/ast"
"github.com/gomarkdown/markdown/parser"
"github.com/microcosm-cc/bluemonday"
"html/template"
"log"
"net/url"
"os"
"path"
"path/filepath"
"regexp"
"strings"
"time"
)
// 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"
// Name is the path without extension (so a path of "foo.md"
// results in the Name "foo"). Body is the Markdown content of the
// page and Html is the rendered HTML for that Markdown. Score is a
// number indicating how well the page matched for a search query.
@@ -27,20 +27,27 @@ type Page struct {
Body []byte
Html template.HTML
Score int
Hashtags []string
}
// santize uses bluemonday to sanitize the HTML.
func sanitize(s string) template.HTML {
return template.HTML(bluemonday.UGCPolicy().Sanitize(s))
var blogRe = regexp.MustCompile(`^\d\d\d\d-\d\d-\d\d`)
// santizeStrict uses bluemonday to sanitize the HTML away. No elements are allowed except for the b tag because this is
// used for snippets.
func sanitizeStrict(s string) template.HTML {
policy := bluemonday.StrictPolicy()
policy.AllowElements("b")
return template.HTML(policy.Sanitize(s))
}
// santizeBytes uses bluemonday to sanitize the HTML.
func sanitizeBytes(bytes []byte) template.HTML {
return template.HTML(bluemonday.UGCPolicy().SanitizeBytes(bytes))
// unsafeBytes does not use bluemonday to sanitize the HTML used for pages. This is where you make changes if you want
// to be more lenient. If you look at the git repository, there are older versions containing the function sanitizeBytes
// which would do elaborate checking.
func unsafeBytes(bytes []byte) template.HTML {
return template.HTML(bytes)
}
// nameEscape returns the page name safe for use in URLs. That is,
// percent escaping is used except for the slashes.
// nameEscape returns the page name safe for use in URLs. That is, percent escaping is used except for the slashes.
func nameEscape(s string) string {
parts := strings.Split(s, "/")
for i, part := range parts {
@@ -49,50 +56,65 @@ func nameEscape(s string) string {
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"). 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.
// save saves a Page. The path is based on the Page.Name and gets the ".md" extension. Page.Body is saved, without any
// carriage return characters ("\r"). Page.Title and Page.Html are not saved. There is no caching. Before removing or
// writing a file, the old copy is renamed to a backup, appending "~". Errors are not logged but returned.
func (p *Page) save() error {
filename := p.Name + ".md"
fp := filepath.FromSlash(p.Name + ".md")
watches.ignore(fp)
s := bytes.ReplaceAll(p.Body, []byte{'\r'}, []byte{})
if len(s) == 0 {
_ = os.Rename(filename, filename + "~")
return os.Remove(filename)
log.Println("Delete", p.Name)
index.remove(p)
return os.Rename(fp, fp+"~")
}
p.Body = s
p.updateIndex()
d := filepath.Dir(filename)
index.update(p)
d := filepath.Dir(fp)
if d != "." {
err := os.MkdirAll(d, 0755)
if err != nil {
fmt.Printf("Creating directory %s failed", d)
return err
}
}
_ = os.Rename(filename, filename + "~")
return os.WriteFile(filename, s, 0644)
err := backup(fp)
if err != nil {
return err
}
return os.WriteFile(fp, s, 0644)
}
// loadPage loads a Page given a name. The filename loaded is that
// Page.Name with the ".md" extension. The Page.Title is set to the
// Page.Name (and possibly changed, later). The Page.Body is set to
// the file content. The Page.Html remains undefined (there is no
// caching).
func loadPage(name string) (*Page, error) {
filename := name + ".md"
body, err := os.ReadFile(filename)
// backup a file by renaming (!) it unless the existing backup is less than an hour old. A backup gets a tilde appended
// to it ("~"). This is true even if the file refers to a binary file like "image.png" and most applications don't know
// what to do with a file called "image.png~". This expects a file path. Use filepath.FromSlash(path) if necessary.
func backup(fp string) error {
_, err := os.Stat(fp)
if err != nil {
return nil
}
bp := fp + "~"
fi, err := os.Stat(bp)
if err != nil || time.Since(fi.ModTime()).Minutes() >= 60 {
return os.Rename(fp, bp)
}
return nil
}
// loadPage loads a Page given a name. The path loaded is that Page.Name with the ".md" extension. The Page.Title is set
// to the Page.Name (and possibly changed, later). The Page.Body is set to the file content. The Page.Html remains
// undefined (there is no caching).
func loadPage(path string) (*Page, error) {
path = strings.TrimPrefix(path, "./") // result of a filepath.TreeWalk starting with "."
body, err := os.ReadFile(filepath.FromSlash(path+".md"))
if err != nil {
return nil, err
}
return &Page{Title: name, Name: name, Body: body, Language: ""}, nil
return &Page{Title: path, Name: path, Body: body, Language: ""}, nil
}
// handleTitle extracts the title from a Page and sets Page.Title, if
// any. If replace is true, the page title is also removed from
// Page.Body. Make sure not to save this! This is only for rendering.
// handleTitle extracts the title from a Page and sets Page.Title, if any. If replace is true, the page title is also
// removed from Page.Body. Make sure not to save this! This is only for rendering. In a template, the title is a
// separate attribute and is not repeated in the HTML.
func (p *Page) handleTitle(replace bool) {
s := string(p.Body)
m := titleRegexp.FindStringSubmatch(s)
@@ -104,75 +126,47 @@ func (p *Page) handleTitle(replace bool) {
}
}
func hashtag(p *parser.Parser, data []byte, offset int) (int, ast.Node) {
data = data[offset:]
i := 0
n := len(data)
for i < n && !parser.IsSpace(data[i]) {
i++
}
if i == 0 {
return 0, nil
}
link := &ast.Link{
Destination: append([]byte("/search?q=%23"), data[1:i]...),
Title: data[0:i],
}
text := bytes.ReplaceAll(data[0:i], []byte("_"), []byte(" "))
ast.AppendChild(link, &ast.Text{Leaf: ast.Leaf{Literal: text}})
return i, link
}
// renderHtml renders the Page.Body to HTML and sets Page.Html.
func (p *Page) renderHtml() {
parser := parser.New()
parser.RegisterInline('#', hashtag)
maybeUnsafeHTML := markdown.ToHTML(p.Body, parser, nil)
p.Name = nameEscape(p.Name)
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)
}
// isBlog returns true if the page name starts with an ISO date
func (p *Page) isBlog() bool {
name := path.Base(p.Name)
return blogRe.MatchString(name)
}
// Dir returns the directory the page is in. It's either the empty string if the page is in the Oddmu working directory,
// or it ends in a slash. This is used to create the upload link in "view.html", for example.
func (p *Page) Dir() string {
d := filepath.Dir(p.Name)
if d == "." {
return ""
}
return d
return d + "/"
}
// Base returns the basename of the page name: no directory and no extension. This is used to create the upload link
// in "view.html", for example.
func (p *Page) Base() string {
n := filepath.Base(p.Name)
if n == "." {
return ""
}
return n
}
// Today returns the date, as a string, for use in templates.
func (p *Page) Today() string {
return time.Now().Format(time.DateOnly)
}

View File

@@ -2,7 +2,6 @@ package main
import (
"github.com/stretchr/testify/assert"
"os"
"regexp"
"testing"
)
@@ -19,73 +18,25 @@ But yearn for blue sky`)}
assert.Regexp(t, regexp.MustCompile("^My back"), string(p.Body))
}
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 href="/search?q=%23Haiku" rel="nofollow">#Haiku</a> <a href="/search?q=%23Cold_Poets" rel="nofollow">#Cold Poets</a></p>
`
assert.Equal(t, r, string(p.Html))
}
// wipes testdata
func TestPageDir(t *testing.T) {
_ = os.RemoveAll("testdata")
cleanup(t, "testdata/dir")
index.load()
p := &Page{Name: "testdata/moon", Body: []byte(`# Moon
p := &Page{Name: "testdata/dir/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")
o, err := loadPage("testdata/dir/moon")
assert.NoError(t, err, "load page")
assert.Equal(t, p.Body, o.Body)
assert.FileExists(t, "testdata/moon.md")
assert.FileExists(t, "testdata/dir/moon.md")
// Saving an empty page deletes it.
p = &Page{Name: "testdata/moon", Body: []byte("")}
p = &Page{Name: "testdata/dir/moon", Body: []byte("")}
p.save()
assert.NoFileExists(t, "testdata/moon.md")
assert.NoFileExists(t, "testdata/dir/moon.md")
// But the backup still exists.
assert.FileExists(t, "testdata/moon.md~")
t.Cleanup(func() {
_ = os.RemoveAll("testdata")
})
assert.FileExists(t, "testdata/dir/moon.md~")
}

122
parser.go Normal file
View File

@@ -0,0 +1,122 @@
package main
import (
"bytes"
"github.com/gomarkdown/markdown"
"github.com/gomarkdown/markdown/ast"
"github.com/gomarkdown/markdown/html"
"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]...),
}
text := bytes.ReplaceAll(data[0:i], []byte("_"), []byte(" "))
ast.AppendChild(link, &ast.Text{Leaf: ast.Leaf{Literal: text}})
return i, link
}, &hashtags
}
// wikiParser returns a parser with the Oddmu specific changes. Specifically: [[wiki links]], #hash_tags,
// @webfinger@accounts. It also uses the CommonExtensions and Block Attributes, and no MathJax ($).
func wikiParser() (*parser.Parser, *[]string) {
extensions := (parser.CommonExtensions | parser.Attributes) & ^parser.MathJax
parser := parser.NewWithExtensions(extensions)
prev := parser.RegisterInline('[', nil)
parser.RegisterInline('[', wikiLink(parser, prev))
fn, hashtags := hashtag()
parser.RegisterInline('#', fn)
if useWebfinger {
parser.RegisterInline('@', account)
}
return parser, hashtags
}
// wikiRenderer is a Renderer for Markdown that adds lazy loading of images. This in turn requires an exception for the
// sanitization policy!
func wikiRenderer() *html.Renderer {
htmlFlags := html.CommonFlags | html.LazyLoadImages
opts := html.RendererOptions{Flags: htmlFlags}
renderer := html.NewRenderer(opts)
return renderer
}
// renderHtml renders the Page.Body to HTML and sets Page.Html, Page.Language, Page.Hashtags, and escapes Page.Name.
// Note: If the rendered HTML doesn't contain the attributes or elements you expect it to contain, check sanitizeBytes!
func (p *Page) renderHtml() {
parser, hashtags := wikiParser()
renderer := wikiRenderer()
maybeUnsafeHTML := markdown.ToHTML(p.Body, parser, renderer)
p.Name = nameEscape(p.Name)
p.Html = unsafeBytes(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)
}

85
parser_test.go Normal file
View File

@@ -0,0 +1,85 @@
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">#Haiku</a> <a class="tag" href="/search/?q=%23Cold_Poets">#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">ragged cliffs</a>
Our <a href="time%20together">time together</a></p>
`
assert.Equal(t, r, string(p.Html))
}
func TestPageHtmlDollar(t *testing.T) {
p := &Page{Body: []byte(`# No $dollar$ can buy this
Dragonfly hovers
darts chases turns lands and rests
A mighty jewel`)}
p.renderHtml()
r := `<h1>No $dollar$ can buy this</h1>
<p>Dragonfly hovers
darts chases turns lands and rests
A mighty jewel</p>
`
assert.Equal(t, r, string(p.Html))
}
func TestLazyLoadImages(t *testing.T) {
p := &Page{Body: []byte(`![](test.jpg)`)}
p.renderHtml()
assert.Contains(t, string(p.Html), "lazy")
}

117
replace_cmd.go Normal file
View File

@@ -0,0 +1,117 @@
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
regexp bool
}
func (cmd *replaceCmd) SetFlags(f *flag.FlagSet) {
f.BoolVar(&cmd.confirm, "confirm", false, "do the replacement instead of just doing a dry run")
f.BoolVar(&cmd.regexp, "regexp", false, "the search string is a regular expression")
}
func (*replaceCmd) Name() string { return "replace" }
func (*replaceCmd) Synopsis() string { return "search and replace in all the pages" }
func (*replaceCmd) Usage() string {
return `replace [-confirm] [-regexp] <term> <replacement>:
Search a string or a regular expression and replace it. By default,
this is a dry run and nothing is saved. If this is a regular
expression, 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, cmd.regexp, f.Args())
}
func replaceCli(w io.Writer, isConfirmed bool, isRegexp bool, args []string) subcommands.ExitStatus {
if len(args) != 2 {
fmt.Fprintln(os.Stderr, "Replace takes exactly two arguments.")
return subcommands.ExitFailure
}
var re *regexp.Regexp
if isRegexp {
re = regexp.MustCompile(args[0])
} else {
re = regexp.MustCompile(regexp.QuoteMeta(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
}
// skip hidden directories and files
if path != "." && strings.HasPrefix(filepath.Base(path), ".") {
if info.IsDir() {
return filepath.SkipDir
} else {
return nil
}
}
// skipp all but page files
if !strings.HasSuffix(path, ".md") {
return nil
}
body, err := os.ReadFile(path)
if err != nil {
return err
}
result := re.ReplaceAll(body, repl)
if !slices.Equal(result, body) {
changes++
if isConfirmed {
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(os.Stderr, err)
return subcommands.ExitFailure
}
if changes == 1 {
if isConfirmed {
fmt.Fprintln(w, "1 file was changed.")
} else {
fmt.Fprintln(w, "1 file would be changed.")
}
} else {
if isConfirmed {
fmt.Fprintf(w, "%d files were changed.\n", changes)
} else {
fmt.Fprintf(w, "%d files would be changed.\n", changes)
}
}
if !isConfirmed && changes > 0 {
fmt.Fprintln(w, "This is a dry run. Use -confirm to make it happen.")
}
return subcommands.ExitSuccess
}

38
replace_cmd_test.go Normal file
View File

@@ -0,0 +1,38 @@
package main
import (
"bytes"
"github.com/google/subcommands"
"github.com/stretchr/testify/assert"
"testing"
)
func TestReplaceCmd(t *testing.T) {
cleanup(t, "testdata/replace")
index.load()
p := &Page{Name: "testdata/replace/pluto", Body: []byte(`# Pluto
Out there is a rock
And more rocks uncountable
You are no planet`)}
p.save()
r := `--- testdata/replace/pluto.md~
+++ testdata/replace/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 file would be changed.
This is a dry run. Use -confirm to make it happen.
`
b := new(bytes.Buffer)
s := replaceCli(b, false, true, []string{`\bno planet`, `planetoid`})
assert.Equal(t, subcommands.ExitSuccess, s)
assert.Equal(t, r, b.String())
}

View File

@@ -2,7 +2,6 @@ package main
import (
"regexp"
"strings"
)
// score splits the query string q into terms and scores the text
@@ -10,7 +9,7 @@ import (
// characters quoted.
func score(q string, s string) int {
score := 0
re, err := regexp.Compile("(?i)" + q)
re, err := regexp.Compile("(?i)" + regexp.QuoteMeta(q))
if err == nil {
m := re.FindAllString(s, -1)
if m != nil {
@@ -18,8 +17,8 @@ func score(q string, s string) int {
score += len(m)
}
}
for _, v := range strings.Fields(q) {
re, err := regexp.Compile(`(?is)(\pL?)(` + v + `)(\pL?)`)
for _, token := range highlightTokens(q) {
re, err := regexp.Compile(`(?is)(\pL?)(` + regexp.QuoteMeta(token) + `)(\pL?)`)
if err != nil {
continue
}

View File

@@ -94,7 +94,7 @@ func TestScorePageAndMarkup(t *testing.T) {
s := `The Transjovian Council accepts new members. If you think we'd be a good fit, apply for an account. Contact [Alex Schroeder](https://alexschroeder.ch/wiki/Contact). Mail is best. Encrypted mail is best. [Delta Chat](https://delta.chat/de/) is a messenger app that uses encrypted mail. It's the bestest best.`
p := &Page{Title: "Test", Name: "Test", Body: []byte(s)}
q := "wiki"
p.summarize(q)
p.score(q)
// "wiki" is not visible in the plain text but the score is no affected:
// - wiki, all, whole, beginning, end (5)
if p.Score != 5 {

289
search.go
View File

@@ -1,9 +1,14 @@
package main
import (
"fmt"
"log"
"net/http"
"os"
"path"
"regexp"
"slices"
"strconv"
"strings"
"unicode"
"unicode/utf8"
)
@@ -13,76 +18,242 @@ 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
Dir string
Items []*Page
Previous int
Page int
Next int
More bool
Results bool
}
func sortItems(a, b Page) int {
// Sort by score
if a.Score < b.Score {
return 1
} else if a.Score > b.Score {
return -1
}
// If the score is the same and both page names start
// with a number (like an ISO date), sort descending.
ra, _ := utf8.DecodeRuneInString(a.Title)
rb, _ := utf8.DecodeRuneInString(b.Title)
if unicode.IsNumber(ra) && unicode.IsNumber(rb) {
if a.Title < b.Title {
return 1
} else if a.Title > b.Title {
return -1
} else {
return 0
// 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(tokens []string) func(a, b string) int {
return func(a, b string) int {
// If only one page contains the query string, it
// takes precedence.
ia := false
ib := false
for _, token := range tokens {
if !ia && strings.Contains(index.titles[a], token) {
ia = true
}
if !ib && strings.Contains(index.titles[b], token) {
ib = true
}
}
if ia && !ib {
return -1
} else if !ia && ib {
return 1
}
// Page names starting with a number come first. If
// both page names start with a number (like an ISO
// date), sort by page name, descending.
ra, _ := utf8.DecodeRuneInString(a)
na := unicode.IsNumber(ra)
rb, _ := utf8.DecodeRuneInString(b)
nb := unicode.IsNumber(rb)
if na && !nb {
return -1
} else if !na && nb {
return 1
} else if na && nb {
if a < b {
return 1
} else if a > b {
return -1
}
}
// Otherwise sort by title, ascending.
if index.titles[a] < index.titles[b] {
return -1
} else if index.titles[a] > index.titles[b] {
return 1
}
// Either the titles are equal or the index isn't
// initialized.
if a < b {
return -1
} else if a > b {
return 1
}
}
// Otherwise sort ascending.
if a.Title < b.Title {
return -1
} else if a.Title > b.Title {
return 1
} else {
return 0
}
}
// loadAndSummarize loads the pages named and summarizes them for the
// query give.
func loadAndSummarize(names []string, q string) []Page {
// Load and summarize the items.
items := make([]Page, len(names))
for i, name := range names {
p, err := loadPage(name)
if err != nil {
fmt.Printf("Error loading %s\n", name)
} else {
p.summarize(q)
items[i] = *p
// 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. Specify either the page number to return, or that all the results should be returned. Only ask for all
// results if runtime is not an issue, like on the command line. The boolean return value indicates whether there are
// more results.
func search(q, dir, filter string, page int, all bool) ([]*Page, bool) {
if len(q) == 0 {
return make([]*Page, 0), false
}
names := index.search(q) // hashtags or all names
names = filterPath(names, dir, filter)
predicates, terms := predicatesAndTokens(q)
names = filterNames(names, predicates)
index.RLock()
slices.SortFunc(names, sortNames(terms))
index.RUnlock()
names, keepFirst := prependQueryPage(names, dir, q)
from := itemsPerPage * (page - 1)
to := from + itemsPerPage - 1
items, more := grep(terms, names, from, to, all, keepFirst)
for _, p := range items {
p.score(q)
p.summarize(q)
}
return items, more
}
// filterPath filters the names by prefix and by a regular expression. A prefix of "." means that all the names are
// returned, since this is what path.Dir returns for "no directory".
//
// The regular expression can be used to ensure that search does not descend into subdirectories unless the search
// already starts there. Given the pages a, public/b and secret/c and ODDMU_FILTER=^secret/ then if search starts in the
// root directory /, search does not enter secret/, but if search starts in secret/, search does search the pages in
// secret/ it us up to the web server to ensure access to secret/ is limited. More specifically: If the current
// directory matches the regular expression, the page names must match; if the regular expression does not match the
// current directory, the page name must not match the filter either. If the filter is empty, all prefixes and all page
// names match, so no problem.
func filterPath(names []string, prefix, filter string) []string {
re, err := regexp.Compile(filter)
if err != nil {
log.Println("ODDMU_FILTER does not compile:", filter, err)
return []string{}
}
mustMatch := re.MatchString(prefix)
r := make([]string, 0)
for _, name := range names {
if strings.HasPrefix(name, prefix) &&
(mustMatch && re.MatchString(name) ||
!mustMatch && !re.MatchString(name)) {
r = append(r, name)
}
}
return items
return r
}
// 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)
// filterNames filters the names by all the predicats such as
// "title:foo" or "blog:true".
func filterNames(names, predicates []string) []string {
if len(predicates) == 0 {
return names
}
names := searchDocuments(q)
items := loadAndSummarize(names, q)
slices.SortFunc(items, sortItems)
return items
// the intersection requires sorted lists
slices.Sort(names)
index.RLock()
defer index.RUnlock()
for _, predicate := range predicates {
r := make([]string, 0)
if strings.HasPrefix(predicate, "title:") {
token := predicate[6:]
for _, name := range names {
if strings.Contains(strings.ToLower(index.titles[name]), token) {
r = append(r, name)
}
}
} else if predicate == "blog:true" || predicate == "blog:false" {
blog := predicate == "blog:true"
re := regexp.MustCompile(`(^|/)\d\d\d\d-\d\d-\d\d`)
for _, name := range names {
match := re.MatchString(name)
if blog && match || !blog && !match {
r = append(r, name)
}
}
} else {
log.Printf("Unsupported predicate: %s", predicate)
}
names = intersection(names, r)
}
return names
}
// 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)
// grep searches the files for matches to all the tokens. It returns just a single page of results based [from:to-1] and
// returns if there are more results. The all parameter ignores pagination (the from and to parameters). The keepFirst
// parameter keeps the first page in the list, even if there is no match. This is used for hashtag pages.
func grep(tokens, names []string, from, to int, all, keepFirst bool) ([]*Page, bool) {
pages := make([]*Page, 0)
i := 0
NameLoop:
for n, name := range names {
p, err := loadPage(name)
if err != nil {
log.Printf("grep: cannot load %s: %s", name, err)
continue NameLoop
}
if n != 0 || !keepFirst {
body := strings.ToLower(string(p.Body))
for _, token := range tokens {
if !strings.Contains(body, token) {
continue NameLoop
}
}
}
i++
if all || i > from {
pages = append(pages, p)
}
if !all && i > to {
return pages, true
}
}
return pages, false
}
// prependQueryPage prepends the query itself, if a matching page name exists. This helps if people remember the name
// exactly, or if searching for a hashtag. This function assumes that q is not the empty string. Return wether a page
// was prepended or not.
func prependQueryPage(names []string, dir, q string) ([]string, bool) {
index.RLock()
defer index.RUnlock()
if q[0] == '#' && !strings.Contains(q[1:], "#") {
q = q[1:]
}
q = path.Join(dir, q)
// if q exists in names, move it to the front
i := slices.Index(names, q)
if i == 0 {
return names, false
} else if i != -1 {
r := []string{q}
r = append(r, names[0:i]...)
r = append(r, names[i+1:]...)
return r, false
}
// otherwise, if q is a known page name, prepend it
_, ok := index.titles[q]
if ok {
return append([]string{q}, names...), true
}
return names, false
}
// searchHandler presents a search result. It uses the query string in the form parameter "q" and the template
// "search.html". For each page found, the HTML is just an extract of the actual body. Search is limited to a directory
// and its subdirectories.
//
// A filter can be defined using the environment variable ODDMU_FILTER. It is passed on to search.
func searchHandler(w http.ResponseWriter, r *http.Request, dir string) {
q := r.FormValue("q")
page, err := strconv.Atoi(r.FormValue("page"))
if err != nil {
page = 1
}
filter := os.Getenv("ODDMU_FILTER")
items, more := search(q, dir, filter, page, false)
s := &Search{Query: q, Dir: dir, Items: items, Previous: page - 1, Page: page, Next: page + 1,
Results: len(items) > 0, More: more}
renderTemplate(w, dir, "search", s)
}

View File

@@ -1,32 +1,40 @@
<!DOCTYPE html>
<html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="format-detection" content="telephone=no">
<meta name="viewport" content="width=device-width">
<title>Search for {{.Query}}</title>
<style>
html { max-width: 65ch; padding: 2ch; margin: auto; color: #111; background: #ffe; }
html { max-width: 70ch; padding: 2ch; margin: auto; color: #111; background-color: #ffe; }
body { hyphens: auto; }
header a { margin-right: 1ch; }
form { display: inline-block; }
input#search { width: 20ch; }
button { background-color: #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>
<form role="search" action="/search/{{.Dir}}" method="GET">
<label for="search">Search:</label>
<input id="search" type="text" value="{{.Query}}" spellcheck="false" name="q" accesskey="f" placeholder="term #tag title:term blog:true" required>
<button>Go</button>
</form>
</header>
<main id="main">
<h1>Search for {{.Query}}</h1>
{{if .Results}}
<p>
{{if gt .Page 2}}<a href="/search/{{.Dir}}?q={{.Query}}&page=1">First</a>{{end}}
{{if gt .Page 1}}<a href="/search/{{.Dir}}?q={{.Query}}&page={{.Previous}}">Previous</a>{{end}}
Page {{.Page}}
{{if .More}}<a href="/search/{{.Dir}}?q={{.Query}}&page={{.Next}}">Next</a>{{end}}
{{range .Items}}
<article lang="{{.Language}}">
<p><a class="result" href="/view/{{.Name}}">{{.Title}}</a>
@@ -34,6 +42,11 @@ img { max-width: 20%; }
<blockquote>{{.Html}}</blockquote>
</article>
{{end}}
<p>
{{if gt .Page 2}}<a href="/search/{{.Dir}}?q={{.Query}}&page=1">First</a>{{end}}
{{if gt .Page 1}}<a href="/search/{{.Dir}}?q={{.Query}}&page={{.Previous}}">Previous</a>{{end}}
Page {{.Page}}
{{if .More}}<a href="/search/{{.Dir}}?q={{.Query}}&page={{.Next}}">Next</a>{{end}}
{{else}}
<p>No results.</p>
{{end}}

98
search_cmd.go Normal file
View File

@@ -0,0 +1,98 @@
package main
import (
"context"
"flag"
"fmt"
"github.com/charmbracelet/lipgloss"
"github.com/google/subcommands"
"io"
"os"
"regexp"
"strings"
)
type searchCmd struct {
dir string
page int
all bool
extract bool
}
func (cmd *searchCmd) SetFlags(f *flag.FlagSet) {
f.StringVar(&cmd.dir, "dir", "", "search only pages within this sub-directory")
f.IntVar(&cmd.page, "page", 1, "the page in the search result set, default 1")
f.BoolVar(&cmd.all, "all", false, "show all the pages and ignore -page")
f.BoolVar(&cmd.extract, "extract", false, "print page extract instead of link list")
}
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 [-dir string] [-page <n>|-all] [-extract] <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 is exactly as
when the wiki runs.
`
}
func (cmd *searchCmd) Execute(_ context.Context, f *flag.FlagSet, _ ...interface{}) subcommands.ExitStatus {
return searchCli(os.Stdout, cmd.dir, cmd.page, cmd.all, cmd.extract, false, 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, dir string, n int, all, extract bool, quiet bool, args []string) subcommands.ExitStatus {
dir, err := checkDir(dir)
if err != nil {
return subcommands.ExitFailure
}
index.reset()
index.load()
q := strings.Join(args, " ")
items, more := search(q, dir, "", n, true)
if !quiet {
fmt.Fprint(os.Stderr, "Search for ", q)
if !all {
fmt.Fprint(os.Stderr, ", page ", n)
}
fmt.Fprint(os.Stderr, ": ", len(items))
if len(items) == 1 {
fmt.Fprint(os.Stderr, " result\n")
} else {
fmt.Fprint(os.Stderr, " results\n")
}
}
if extract {
searchExtract(w, items)
} else {
for _, p := range items {
name := p.Name
if strings.HasPrefix(name, dir) {
name = strings.Replace(name, dir, "", 1)
}
fmt.Fprintf(w, "* [%s](%s)\n", p.Title, name)
}
}
if more {
fmt.Fprintf(os.Stderr, "There are more results\n")
}
return subcommands.ExitSuccess
}
// searchExtract prints the search extracts to stdout with highlighting for a terminal.
func searchExtract(w io.Writer, items []*Page) {
heading := lipgloss.NewStyle().Bold(true).Underline(true)
quote := lipgloss.NewStyle().PaddingLeft(4).Width(78)
match := lipgloss.NewStyle().Bold(true)
re := regexp.MustCompile(`<b>(.*?)</b>`)
for _, p := range items {
s := re.ReplaceAllString(string(p.Html), match.Render(`$1`))
fmt.Fprintln(w, heading.Render(p.Title))
if p.Name != p.Title {
fmt.Fprintln(w, p.Name)
}
fmt.Fprintln(w, quote.Render(s))
}
}

33
search_cmd_test.go Normal file
View File

@@ -0,0 +1,33 @@
package main
import (
"bytes"
"github.com/google/subcommands"
"github.com/stretchr/testify/assert"
"testing"
)
func TestSearchCmd(t *testing.T) {
b := new(bytes.Buffer)
s := searchCli(b, "", 1, false, false, true, []string{"oddµ"})
assert.Equal(t, subcommands.ExitSuccess, s)
r := `* [Oddµ: A minimal wiki](README)
* [Welcome to Oddµ](index)
`
assert.Equal(t, r, b.String())
}
func TestSearchSubdirCmd(t *testing.T) {
cleanup(t, "testdata/search")
p := &Page{Name: "testdata/search/wait", Body: []byte(`# Wait
We should make it so
that before we type and speak
we hear that moment`)}
p.save()
b := new(bytes.Buffer)
s := searchCli(b, "testdata/search", 1, false, false, true, []string{"speak"})
assert.Equal(t, subcommands.ExitSuccess, s)
r := `* [Wait](wait)
`
assert.Equal(t, r, b.String())
}

View File

@@ -1,14 +1,272 @@
package main
import (
"fmt"
"github.com/stretchr/testify/assert"
"testing"
"net/url"
"slices"
"testing"
)
func TestSortNames(t *testing.T) {
index.Lock()
defer index.Unlock()
for _, s := range []string{"Alex", "Berta", "Chris", "2015-06-14", "2023-09-26"} {
index.titles[s] = s
}
terms := []string{"Z"}
fn := sortNames(terms)
assert.Equal(t, 1, fn("Berta", "Alex"), "B is after A")
assert.Equal(t, -1, fn("Alex", "Berta"), "A is before B")
assert.Equal(t, 0, fn("Berta", "Berta"), "B and B are equal")
assert.Equal(t, -1, fn("2023-09-26", "Alex"), "numbers before letters")
assert.Equal(t, 1, fn("Alex", "2023-09-26"), "numbers after letters")
assert.Equal(t, -1, fn("2023-09-26", "2015-06-14"), "higher numbers before lower numbers")
assert.Equal(t, 1, fn("2015-06-14", "2023-09-26"), "lower numbers after higher numbers")
names := []string{"Berta", "Chris", "Alex"}
slices.SortFunc(names, sortNames(terms))
assert.True(t, slices.IsSorted(names), fmt.Sprintf("Sorted: %v", names))
}
func TestPrependMatches(t *testing.T) {
index.Lock()
for _, s := range []string{"Alex", "Berta", "Chris"} {
index.titles[s] = s
}
index.Unlock()
r := []string{"Berta", "Chris"} // does not prepend
u := []string{"Alex", "Berta", "Chris"} // does prepend
v, _ := prependQueryPage(r, "", "Alex")
assert.Equal(t, u, v, "prepend q")
v, _ = prependQueryPage(r, "", "lex")
assert.Equal(t, r, v, "exact matches only")
v, _ = prependQueryPage(r, "", "#Alex")
assert.Equal(t, u, v, "prepend hashtag")
v, _ = prependQueryPage(r, "", "#Alex #Berta")
assert.Equal(t, r, v, "do not prepend two hashtags")
v, _ = prependQueryPage(r, "", "#alex")
assert.Equal(t, r, v, "do not ignore case")
v, _ = prependQueryPage(u, "", "Alex")
assert.Equal(t, u, v, "do not prepend q twice")
v, _ = prependQueryPage([]string{"Berta", "Alex", "Chris"}, "", "Alex")
assert.Equal(t, u, v, "sort q to the front")
v, _ = prependQueryPage([]string{"Berta", "Chris", "Alex"}, "", "Alex")
assert.Equal(t, u, v, "sort q to the front")
}
func TestSearch(t *testing.T) {
// working in the main directory
index.reset()
index.load()
data := url.Values{}
data.Set("q", "oddµ")
assert.Contains(t,
assert.HTTPBody(searchHandler, "GET", "/search", data), "Welcome")
body := assert.HTTPBody(makeHandler(searchHandler, false), "GET", "/search/", data)
assert.Contains(t, body, "Welcome")
assert.Contains(t, body, `<span class="score">5</span>`)
body = assert.HTTPBody(makeHandler(searchHandler, false), "GET", "/search/testdata", data)
assert.NotContains(t, body, "Welcome")
}
func TestSearchFilter(t *testing.T) {
names := []string{"a", "public/b", "secret/c"}
f := filterPath(names, "", "")
assert.Equal(t, names, f)
f = filterPath(names, "public/", "")
assert.Equal(t, []string{"public/b"}, f)
f = filterPath(names, "secret/", "")
assert.Equal(t, []string{"secret/c"}, f)
// critically, this no longer returns c
f = filterPath(names, "", "^secret/")
assert.Equal(t, []string{"a", "public/b"}, f)
// unchanged
f = filterPath(names, "public/", "^secret/")
assert.Equal(t, []string{"public/b"}, f)
// unchanged
f = filterPath(names, "secret/", "^secret/")
assert.Equal(t, []string{"secret/c"}, f)
}
func TestSearchFilterLong(t *testing.T) {
cleanup(t, "testdata/filter")
p := &Page{Name: "testdata/filter/one", Body: []byte(`# One
One day, I heard you say
Just one more day and I'd know
But that was last spring`)}
p.save()
p = &Page{Name: "testdata/filter/public/two", Body: []byte(`# Two
Oh, the two of us
Have often seen this forest
But this bird is new`)}
p.save()
p = &Page{Name: "testdata/filter/secret/three", Body: []byte(`# Three
Three years have gone by
And we're good, we live, we breathe
But we don't say it`)}
p.save()
// normal search works
items, _ := search("spring", "testdata/", "", 1, false)
assert.Equal(t, len(items), 1)
assert.Equal(t, "One", items[0].Title)
// not found because it's in /secret and we start at /
items, _ = search("year", "testdata/", "^testdata/filter/secret/", 1, false)
assert.Equal(t, 0, len(items))
// only found two because the third one is in /secret and we start at /
items, _ = search("but", "testdata/", "^testdata/filter/secret/", 1, false)
assert.Equal(t, 2, len(items))
assert.Equal(t, "One", items[0].Title)
assert.Equal(t, "Two", items[1].Title)
// starting in the public/ directory, we find only one page
items, _ = search("but", "testdata/filter/public/", "^testdata/filter/secret/", 1, false)
assert.Equal(t, 1, len(items))
assert.Equal(t, "Two", items[0].Title)
// starting in the secret/ directory, we find only one page
items, _ = search("but", "testdata/filter/secret/", "^testdata/filter/secret/", 1, false)
assert.Equal(t, 1, len(items))
assert.Contains(t, "Three", items[0].Title)
}
func TestSearchDir(t *testing.T) {
cleanup(t, "testdata/dir")
p := &Page{Name: "testdata/dir/dice", Body: []byte(`# Dice
A tiny drum roll
Dice rolling bouncing stopping
Where is lady luck?`)}
p.save()
data := url.Values{}
data.Set("q", "luck")
body := assert.HTTPBody(makeHandler(searchHandler, false), "GET", "/search/", data)
assert.Contains(t, body, "luck")
body = assert.HTTPBody(makeHandler(searchHandler, false), "GET", "/search/testdata", data)
assert.Contains(t, body, "luck")
body = assert.HTTPBody(makeHandler(searchHandler, false), "GET", "/search/testdata/dir", data)
assert.Contains(t, body, "luck")
body = assert.HTTPBody(makeHandler(searchHandler, false), "GET", "/search/testdata/other", data)
assert.Contains(t, body, "No results")
}
func TestTitleSearch(t *testing.T) {
// working in the main directory
index.reset()
index.load()
items, more := search("title:readme", "", "", 1, false)
assert.Equal(t, 0, len(items), "no page found")
assert.False(t, more)
items, more = search("title:wel", "", "", 1, false) // README also contains "wel"
assert.Equal(t, 1, len(items), "one page found")
assert.Equal(t, "index", items[0].Name, "Welcome to Oddµ")
assert.Greater(t, items[0].Score, 0, "matches result in a score")
assert.False(t, more)
items, more = search("wel", "", "", 1, false)
assert.Greater(t, len(items), 1, "two pages found")
assert.False(t, more)
}
func TestBlogSearch(t *testing.T) {
cleanup(t, "testdata/grep")
p := &Page{Name: "testdata/grep/2023-09-25", Body: []byte(`# Back then
I check the git log
Was it 2015
We met in the park?`)}
p.save()
items, _ := search("blog:false", "", "", 1, false)
for _, item := range items {
assert.NotEqual(t, "Back then", item.Title, item.Name)
}
items, _ = search("blog:true", "", "", 1, false)
assert.Equal(t, 1, len(items), "one blog page found")
assert.Equal(t, "Back then", items[0].Title, items[0].Name)
}
func TestHashtagSearch(t *testing.T) {
cleanup(t, "testdata/hashtag")
p := &Page{Name: "testdata/hashtag/Haiku", Body: []byte("# Haikus\n")}
p.save()
p = &Page{Name: "testdata/hashtag/2023-10-28", Body: []byte(`# Tea
My tongue is on fire
It looked so calm and peaceful
A quick sip too quick
#Haiku`)}
p.save()
items, _ := search("#Haiku", "testdata/hashtag", "", 1, false)
assert.Equal(t, 2, len(items), "two pages found")
assert.Equal(t, "Haikus", items[0].Title, items[0].Name)
assert.Equal(t, "Tea", items[1].Title, items[1].Name)
}
func TestSearchQuestionmark(t *testing.T) {
cleanup(t, "testdata/question")
p := &Page{Name: "testdata/question/Odd?", Body: []byte(`# Even?
We look at the plants.
They need water. We need us.
The silence streches.`)}
p.save()
data := url.Values{}
data.Set("q", "look")
body := assert.HTTPBody(makeHandler(searchHandler, false), "GET", "/search/", data)
assert.Contains(t, body, "We <b>look</b>")
assert.NotContains(t, body, "Odd?")
assert.Contains(t, body, "Even?")
}
func TestSearchPagination(t *testing.T) {
cleanup(t, "testdata/pagination")
index.load()
alphabet := "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
for _, r := range alphabet {
s := fmt.Sprintf("secret%c secretX", r)
p := &Page{Name: "testdata/pagination/" + string(r), Body: []byte(s)}
p.save()
}
items, more := search("secretA", "", "", 1, false)
assert.Equal(t, 1, len(items), "one page found, %v", items)
assert.Equal(t, "testdata/pagination/A", items[0].Name)
assert.False(t, more)
items, more = search("secretX", "", "", 1, false)
assert.Equal(t, itemsPerPage, len(items))
assert.Equal(t, "testdata/pagination/A", items[0].Name)
assert.Equal(t, "testdata/pagination/T", items[itemsPerPage-1].Name)
assert.True(t, more)
items, more = search("secretX", "", "", 2, false)
assert.Equal(t, 6, len(items))
assert.Equal(t, "testdata/pagination/U", items[0].Name)
assert.Equal(t, "testdata/pagination/Z", items[5].Name)
assert.False(t, more)
}

View File

@@ -1,20 +1,21 @@
package main
import (
"log"
"regexp"
"strings"
)
// re returns a regular expression matching any word in q.
func re(q string) (*regexp.Regexp, error) {
q = regexp.QuoteMeta(q)
re, err := regexp.Compile(`\s+`)
if err != nil {
return nil, err
fields := highlightTokens(q)
quoted := make([]string, len(fields))
for i, w := range fields {
quoted[i] = regexp.QuoteMeta(w)
}
words := re.ReplaceAllString(q, "|")
re, err = regexp.Compile(`(?i)(` + words + `)`)
re, err := regexp.Compile(`(?i)(` + strings.Join(quoted, "|") + `)`)
if err != nil {
log.Printf("Cannot compile %s %v: %s", q, quoted, err)
return nil, err
}
return re, nil
@@ -26,12 +27,16 @@ func snippets(q string, s string) string {
maxsnippets := 4
re, err := re(q)
// If the compilation didn't work, truncate and return
if err != nil || len(s) <= snippetlen {
if err != nil {
if len(s) > 400 {
s = s[0:400] + " …"
}
return s
}
// Short cut for short pages
if len(s) <= snippetlen {
return highlight(q, re, s)
}
// show a snippet from the beginning of the document
j := strings.LastIndex(s[:snippetlen], " ")
if j == -1 {
@@ -75,7 +80,7 @@ func snippets(q string, s string) string {
to = len(s)
}
end := strings.LastIndex(s[:to], " ")
if end == -1 || end <= j + wl {
if end == -1 || end <= j+wl {
// OK, look for a longer word
end = strings.Index(s[to:], " ")
if end == -1 {

View File

@@ -10,7 +10,7 @@ func TestSnippets(t *testing.T) {
h := `We are immersed in a sea of dead people. <b>All</b> the dead that have gone before us, silent now, just … to ask ourselves or them! what it was <b>all</b> about. Instead we drown ourselves in no<b>is</b>e. … surrounded by false friends claiming that <b>all</b> <b>is</b> <b>well</b>. And look at us! Yes, we are <b>well</b>. …`
q := "all is well"
q := "title:all is well"
r := snippets(q, s)
assert.Equal(t, h, r)
}
@@ -41,5 +41,5 @@ func TestSnippetsLong(t *testing.T) {
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))
}

30
static.html Normal file
View File

@@ -0,0 +1,30 @@
<!DOCTYPE html>
<html lang="{{.Language}}">
<head>
<meta charset="utf-8">
<meta name="format-detection" content="telephone=no">
<meta name="viewport" content="width=device-width">
<title>{{.Title}}</title>
<style>
html { max-width: 65ch; padding: 1ch; margin: auto; color: #111; background-color: #ffe; }
body { hyphens: auto; }
header a { margin-right: 1ch; }
form { display: inline-block; }
input#search { width: 12ch; }
button { background-color: #eee; color: inherit; border-radius: 4px; border-width: 1px; }
footer { border-top: 1px solid #888 }
img { max-width: 100%; }
</style>
</head>
<body>
<main id="main">
<h1>{{.Title}}</h1>
{{.Html}}
</main>
<footer>
<address>
Comments? Send mail to Your Name <<a href="mailto:you@example.org">you@example.org</a>>
</address>
</footer>
</body>
</html>

158
static_cmd.go Normal file
View File

@@ -0,0 +1,158 @@
package main
import (
"bytes"
"context"
"flag"
"fmt"
"github.com/gomarkdown/markdown"
"github.com/gomarkdown/markdown/ast"
"github.com/gomarkdown/markdown/html"
"github.com/google/subcommands"
"io/fs"
"net/url"
"os"
"path/filepath"
"strings"
)
type staticCmd struct {
}
func (*staticCmd) Name() string { return "static" }
func (*staticCmd) Synopsis() string { return "generate static HTML files for all pages" }
func (*staticCmd) Usage() string {
return `static <dir name>:
Create static copies in the given directory.
`
}
func (cmd *staticCmd) SetFlags(f *flag.FlagSet) {
}
func (cmd *staticCmd) Execute(_ context.Context, f *flag.FlagSet, _ ...interface{}) subcommands.ExitStatus {
args := f.Args()
if len(args) != 1 {
fmt.Println("Exactly one target directory is required")
return subcommands.ExitFailure
}
return staticCli(filepath.Clean(args[0]), false)
}
// staticCli generates a static site in the designated directory. The quiet flag is used to suppress output when running
// tests.
func staticCli(dir string, quiet bool) subcommands.ExitStatus {
loadLanguages()
loadTemplates()
n := 0
err := filepath.Walk(".", func(path string, info fs.FileInfo, err error) error {
n++
if !quiet && (n < 100 || n < 1000 && n%10 == 0 || n%100 == 0) {
fmt.Fprintf(os.Stdout, "\r%d", n)
}
return staticFile(path, dir, info, err)
})
if !quiet {
fmt.Printf("\r%d\n", n)
}
if err != nil {
fmt.Println(err)
return subcommands.ExitFailure
}
return subcommands.ExitSuccess
}
// staticFile is used to walk the file trees and do the right thing for the destination directory: create
// subdirectories, link files, render HTML files.
func staticFile(path, dir string, info fs.FileInfo, err error) error {
if err != nil {
return err
}
// skip hidden directories and files
if path != "." && strings.HasPrefix(filepath.Base(path), ".") {
if info.IsDir() {
return filepath.SkipDir
} else {
return nil
}
}
// skip backup files, avoid recursion
if strings.HasSuffix(path, "~") || strings.HasPrefix(path, dir) {
return nil
}
// recreate subdirectories
if info.IsDir() {
return os.Mkdir(filepath.Join(dir, path), 0755)
}
// render pages
if strings.HasSuffix(path, ".md") {
return staticPage(path, dir)
}
// remaining files are linked
return os.Link(path, filepath.Join(dir, path))
}
// staticPage takes the filename of a page (ending in ".md") and generates a static HTML page.
func staticPage(path, dir string) error {
name := strings.TrimSuffix(path, ".md")
p, err := loadPage(filepath.ToSlash(name))
if err != nil {
fmt.Fprintf(os.Stderr, "Cannot load %s: %s\n", name, err)
return err
}
p.handleTitle(true)
// instead of p.renderHtml() we do it all ourselves, appending ".html" to all the local links
parser, hashtags := wikiParser()
doc := markdown.Parse(p.Body, parser)
ast.WalkFunc(doc, staticLinks)
opts := html.RendererOptions{
Flags: html.CommonFlags,
}
renderer := html.NewRenderer(opts)
maybeUnsafeHTML := markdown.Render(doc, renderer)
p.Name = nameEscape(p.Name)
p.Html = unsafeBytes(maybeUnsafeHTML)
p.Language = language(p.plainText())
p.Hashtags = *hashtags
return p.write(filepath.Join(dir, name+".html"))
}
// staticLinks checks a node and if it is a link to a local page, it appends ".html" to the link destination.
func staticLinks(node ast.Node, entering bool) ast.WalkStatus {
if entering {
switch v := node.(type) {
case *ast.Link:
// not an absolute URL, not a full URL, not a mailto: URI
if !bytes.HasPrefix(v.Destination, []byte("/")) &&
!bytes.Contains(v.Destination, []byte("://")) &&
!bytes.HasPrefix(v.Destination, []byte("mailto:")) {
// pointing to a page file (instead of an image file, for example).
fn, err := url.PathUnescape(string(v.Destination))
if err != nil {
return ast.GoToNext
}
_, err = os.Stat(fn + ".md")
if err != nil {
return ast.GoToNext
}
v.Destination = append(v.Destination, []byte(".html")...)
}
}
}
return ast.GoToNext
}
func (p *Page) write(destination string) error {
t := "static.html"
f, err := os.Create(destination)
if err != nil {
fmt.Fprintf(os.Stderr, "Cannot create %s.html: %s\n", destination, err)
return err
}
err = templates.template[t].Execute(f, p)
if err != nil {
fmt.Fprintf(os.Stderr, "Cannot execute %s template for %s: %s\n", t, destination, err)
return err
}
return nil
}

19
static_cmd_test.go Normal file
View File

@@ -0,0 +1,19 @@
package main
import (
"github.com/google/subcommands"
"github.com/stretchr/testify/assert"
"testing"
)
func TestStatusCmd(t *testing.T) {
cleanup(t, "testdata/static")
s := staticCli("testdata/static", true)
assert.Equal(t, subcommands.ExitSuccess, s)
// pages
assert.FileExists(t, "testdata/static/index.html")
assert.FileExists(t, "testdata/static/README.html")
// regular files
assert.FileExists(t, "testdata/static/static_cmd.go")
assert.FileExists(t, "testdata/static/static_cmd_test.go")
}

110
templates.go Normal file
View File

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

50
templates_test.go Normal file
View File

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

95
tokenizer.go Normal file
View File

@@ -0,0 +1,95 @@
package main
import (
"bytes"
"strings"
"unicode"
"unicode/utf8"
)
// lowercaseFilter returns a slice of lower case tokens.
func lowercaseFilter(tokens []string) []string {
r := make([]string, len(tokens))
for i, token := range tokens {
r[i] = strings.ToLower(token)
}
return r
}
// tokenizeWithPredicates returns a slice of tokens for the given
// text, including punctuation. Use this to begin tokenizing the query
// string.
func tokenizeOnWhitespace(q string) []string {
return strings.Fields(q)
}
// predicateFilter returns two slices of tokens: the first with
// predicates, the other without predicates. Use this for query
// string tokens.
func predicateFilter(tokens []string) ([]string, []string) {
with := make([]string, 0)
without := make([]string, 0)
for _, token := range tokens {
if strings.Contains(token, ":") {
with = append(with, token)
} else {
without = append(without, token)
}
}
return with, without
}
// predicatesAndTokens returns two slices of tokens: the first with
// predicates, the other without predicates, all of them lower case.
// Use this for query strings.
func predicatesAndTokens(q string) ([]string, []string) {
tokens := tokenizeOnWhitespace(q)
tokens = lowercaseFilter(tokens)
return predicateFilter(tokens)
}
// noPredicateFilter returns a slice of tokens: the predicates without
// the predicate, and all the others. That is: "foo:bar baz" is turned
// into ["bar", "baz"] and the predicate "foo:" is dropped.
func noPredicateFilter(tokens []string) []string {
r := make([]string, 0)
for _, token := range tokens {
parts := strings.Split(token, ":")
r = append(r, parts[len(parts)-1])
}
return r
}
// highlightTokens returns the tokens to highlight, including title
// predicates.
func highlightTokens(q string) []string {
tokens := tokenizeOnWhitespace(q)
tokens = lowercaseFilter(tokens)
return noPredicateFilter(tokens)
}
// hashtags returns a slice of hashtags. Use this to extract hashtags
// from a page body.
func hashtags(s []byte) []string {
hashtags := make([]string, 0)
for {
i := bytes.IndexRune(s, '#')
if i == -1 {
return hashtags
}
from := i
i++
for {
r, n := utf8.DecodeRune(s[i:])
if n > 0 && (unicode.IsLetter(r) || unicode.IsNumber(r) || r == '_') {
i += n
} else {
break
}
}
if i > from+1 { // not just "#"
hashtags = append(hashtags, string(bytes.ToLower(s[from:i])))
}
s = s[i:]
}
}

16
tokenizer_test.go Normal file
View File

@@ -0,0 +1,16 @@
package main
import (
"github.com/stretchr/testify/assert"
"testing"
)
func TestHashtags(t *testing.T) {
assert.EqualValues(t, []string{"#truth"}, hashtags([]byte("This is boring. #Truth")), "hashtags")
}
func TestTokensAndPredicates(t *testing.T) {
predicates, terms := predicatesAndTokens("foo title:bar")
assert.EqualValues(t, []string{"foo"}, terms)
assert.EqualValues(t, []string{"title:bar"}, predicates)
}

View File

@@ -6,15 +6,40 @@
<meta name="viewport" content="width=device-width">
<title>Upload File</title>
<style>
html { max-width: 70ch; padding: 2ch; margin: auto; color: #111; background: #ffe; }
html { max-width: 70ch; padding: 2ch; margin: auto; color: #111; background-color: #ffe; }
body { hyphens: auto; }
form, textarea { width: 100%; }
label { display: inline-block; width: 20ch }
.last { max-width: 20% }
</style>
</head>
<body>
<body lang="en">
<h1>Upload File</h1>
<form action="/drop/{{.}}" method="POST" enctype="multipart/form-data">
<input type="text" name="name" placeholder="image.jpg" autofocus required>
<p><input type="file" name="file" required>
{{if ne .Last ""}}
<p>Previous upload: <a href="/view/{{.Last}}">{{.Last}}</a></p>
{{if .Image}}
<p><img class="last" src="/view/{{.Last}}"></p>
{{end}}
{{end}}
<form action="/drop/{{.Dir}}" method="POST" enctype="multipart/form-data">
<p>When uploading pictures from a phone, its filename is going to be something cryptic like IMG_1234.JPG.
Please provide your own filename. End the filename with "-1" to auto-increment.
<p><label for="text">Filename to use:</label>
<input id="text" name="name" value="{{.Name}}" type="text" placeholder="image-1.jpg" autofocus required>
<p>If the uploaded file is a picture from a phone, it is going to be too big for your site.
Sadly, resizing only works for JPG and PNG files. Luckily, most pictures from a phone camera are JPG images.
Feel free to specify a max width of 1200 pixels, for example.
<p><label for="maxwidth">Max width:</label>
<input id="maxwidth" name="maxwidth" value="{{.MaxWidth}}" type="number" min="10" placeholder="1200">
<p>If the uploaded file is a JPEG-encoded picture, like most pictures from a phone, you can specify a quality.
Typically, a quality of 60 is not too bad and a quality of 90 is more than enough.
<p><label for="quality">Quality:</label>
<input id="quality" name="quality" value="{{.Quality}}" type="number" min="1" max="99" placeholder="75">
<p>Finally, pick the 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>

View File

@@ -1,56 +1,195 @@
package main
import (
"github.com/anthonynsimon/bild/imgio"
"github.com/anthonynsimon/bild/transform"
"github.com/bashdrew/goheif"
"image/jpeg"
"image/png"
"io"
"log"
"net/http"
"net/url"
"os"
"path"
"path/filepath"
"regexp"
"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)
type Upload struct {
Dir string
Name string
Last string
Image bool
MaxWidth string
Quality string
}
// 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.
var lastRe = regexp.MustCompile(`^(.*)([0-9]+)(.*)$`)
// uploadHandler uses the "upload.html" template to enable uploads. The file is saved using the dropHandler. URL
// parameters are used to copy name, maxwidth and quality from the previous upload. If the previous name contains a
// number, this is incremented by one.
func uploadHandler(w http.ResponseWriter, r *http.Request, dir string) {
data := &Upload{Dir: dir}
maxwidth := r.FormValue("maxwidth")
if maxwidth != "" {
data.MaxWidth = maxwidth
}
quality := r.FormValue("quality")
if quality != "" {
data.Quality = quality
}
name := r.FormValue("filename")
if name != "" {
data.Name = name
} else if last := r.FormValue("last"); last != "" {
ext := strings.ToLower(filepath.Ext(last))
switch ext {
case ".png", ".jpg", ".jpeg":
data.Image = true
}
data.Last = path.Join(dir, last)
m := lastRe.FindStringSubmatch(last)
if m != nil {
n, err := strconv.Atoi(m[2])
if err == nil {
data.Name = m[1] + strconv.Itoa(n+1) + m[3]
}
}
}
renderTemplate(w, dir, "upload", data)
}
// dropHandler takes the "name" form field and the "file" form file and saves the file under the given name. The browser
// is redirected to the view of that file. Some errors are for the users and some are for users and the admins. Those
// later errors are printed, too.
func dropHandler(w http.ResponseWriter, r *http.Request, dir string) {
d := path.Dir(dir)
d := filepath.Dir(filepath.FromSlash(dir))
// ensure the directory exists
fi, err := os.Stat(d)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if !fi.IsDir() {
http.Error(w, "file exists", http.StatusInternalServerError)
http.Error(w, "directory does not exist", http.StatusBadRequest)
return
}
data := url.Values{}
name := r.FormValue("name")
data.Set("last", name)
filename := filepath.Base(name)
// no overwriting of hidden files or adding subdirectories
if strings.HasPrefix(filename, ".") || filepath.Dir(name) != "." {
http.Error(w, "no filename", http.StatusForbidden)
return
}
filename := r.FormValue("name")
file, _, err := r.FormFile("file")
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
defer file.Close()
// backup an existing file with the same name
_, err = os.Stat(filename)
path := filepath.Join(d, filename)
watches.ignore(path)
err = backup(path)
if err != nil {
os.Rename(filename, filename + "~")
log.Println(err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// create the new file
dst, err := os.Create(d + "/" + filename)
dst, err := os.Create(path)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
log.Println(err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
defer dst.Close()
if _, err := io.Copy(dst, file); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/view/"+filename, http.StatusFound)
// 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.StatusBadRequest)
return
}
data.Add("maxwidth", maxwidth)
// determine how the file will be written
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.StatusBadRequest)
return
}
data.Add("quality", quality)
}
encoder = imgio.JPEGEncoder(q)
default:
http.Error(w, "Resizing images requires a .png, .jpg or .jpeg extension for the filename", http.StatusBadRequest)
return
}
// try and decode the data in various formats
img, err := jpeg.Decode(file)
if err != nil {
img, err = png.Decode(file)
}
if err != nil {
img, err = goheif.Decode(file)
}
if err != nil {
http.Error(w, "The image could not be decoded (only PNG, JPG and HEIC formats are supported for resizing)", http.StatusBadRequest)
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 {
log.Println(err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
} else {
http.Error(w, "The file is too small for this", http.StatusBadRequest)
return
}
} else {
// just copy the bytes
n, err := io.Copy(dst, file)
if err != nil {
log.Println(err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// if zero bytes were copied, delete the file instead
if n == 0 {
err := os.Remove(path)
if err != nil {
log.Println(err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
log.Println("Delete", path)
}
}
username, _, ok := r.BasicAuth()
if ok {
log.Println("Save", path, "by", username)
} else {
log.Println("Save", path)
}
updateTemplate(path)
http.Redirect(w, r, "/upload/"+dir+"?"+data.Encode(), http.StatusFound)
}

View File

@@ -3,34 +3,176 @@ package main
import (
"bytes"
"github.com/stretchr/testify/assert"
"image"
"image/jpeg"
"image/png"
"mime/multipart"
"net/url"
"os"
"regexp"
"testing"
)
// wipes testdata
func TestUpload(t *testing.T) {
_ = os.RemoveAll("testdata")
cleanup(t, "testdata/files")
// for uploads, the directory is not created automatically
os.MkdirAll("testdata", 0755)
assert.HTTPStatusCode(t, makeHandler(uploadHandler, false), "GET", "/upload/", nil, 200)
os.MkdirAll("testdata/files", 0755)
assert.HTTPStatusCode(t, makeHandler(uploadHandler, false), "GET", "/upload/testdata/files/", nil, 200)
form := new(bytes.Buffer)
writer := multipart.NewWriter(form)
field, err := writer.CreateFormField("name")
assert.NoError(t, err)
_, err = field.Write([]byte("testdata/ok.txt"))
_, err = field.Write([]byte("ok.txt"))
assert.NoError(t, err)
file, err := writer.CreateFormFile("file", "example.txt");
file, err := writer.CreateFormFile("file", "example.txt")
assert.NoError(t, err)
_, err = file.Write([]byte("Hello!"))
assert.NoError(t, err)
file.Write([]byte("Hello!"))
err = writer.Close()
assert.NoError(t, err)
HTTPUploadAndRedirectTo(t, makeHandler(dropHandler, false), "/drop/",
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")
})
HTTPUploadAndRedirectTo(t, makeHandler(dropHandler, false), "/drop/testdata/files/",
writer.FormDataContentType(), form, "/upload/testdata/files/?last=ok.txt")
assert.Contains(t,
assert.HTTPBody(makeHandler(viewHandler, true), "GET", "/view/testdata/files/ok.txt", nil),
"Hello!")
}
func TestUploadPng(t *testing.T) {
cleanup(t, "testdata/png")
// for uploads, the directory is not created automatically
os.MkdirAll("testdata/png", 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/png/",
writer.FormDataContentType(), form, "/upload/testdata/png/?last=ok.png")
}
func TestUploadJpg(t *testing.T) {
cleanup(t, "testdata/jpg")
// for uploads, the directory is not created automatically
os.MkdirAll("testdata/jpg", 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/jpg/",
writer.FormDataContentType(), form, "/upload/testdata/jpg/?last=ok.jpg")
}
func TestDeleteFile(t *testing.T) {
cleanup(t, "testdata/delete")
os.MkdirAll("testdata/delete", 0755)
assert.NoError(t, os.WriteFile("testdata/delete/nothing.txt", []byte(`# Nothing
I pause and look up
Look at the mountains you say
What happened just now?`), 0644))
// check that it worked
assert.FileExists(t, "testdata/delete/nothing.txt")
// delete it by upload a zero byte file
form := new(bytes.Buffer)
writer := multipart.NewWriter(form)
field, _ := writer.CreateFormField("name")
field.Write([]byte("nothing.txt"))
file, _ := writer.CreateFormFile("file", "test.txt")
file.Write([]byte(""))
writer.Close()
HTTPUploadAndRedirectTo(t, makeHandler(dropHandler, false), "/drop/testdata/delete/",
writer.FormDataContentType(), form, "/upload/testdata/delete/?last=nothing.txt")
// check that it worked
assert.NoFileExists(t, "testdata/delete/nothing.txt")
}
func TestUploadMultiple(t *testing.T) {
cleanup(t, "testdata/multi")
p := &Page{Name: "testdata/multi/culture", Body: []byte(`# Culture
The road has walls
Iron gates and tree tops
But here: jasmin dreams`)}
p.save()
// check location for upload
body := assert.HTTPBody(makeHandler(viewHandler, true), "GET", "/view/testdata/multi/culture", nil)
assert.Contains(t, body, `href="/upload/testdata/multi/?filename=culture-1.jpg"`)
// check location for drop
body = assert.HTTPBody(makeHandler(uploadHandler, false), "GET", "/upload/testdata/multi/", nil)
assert.Contains(t, body, `action="/drop/testdata/multi/"`)
// actually do the upload
form := new(bytes.Buffer)
writer := multipart.NewWriter(form)
field, _ := writer.CreateFormField("name")
field.Write([]byte("2023-10-02-hike-1.jpg"))
field, _ = writer.CreateFormField("maxwidth")
field.Write([]byte("15"))
field, _ = writer.CreateFormField("quality")
field.Write([]byte("50"))
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()
location := HTTPUploadLocation(t, makeHandler(dropHandler, false), "/drop/testdata/multi/",
writer.FormDataContentType(), form)
url, _ := url.Parse(location)
assert.Equal(t, "/upload/testdata/multi/", url.Path, "Redirect to upload location")
values := url.Query()
assert.Equal(t, "2023-10-02-hike-1.jpg", values.Get("last"))
assert.Equal(t, "15", values.Get("maxwidth"))
assert.Equal(t, "50", values.Get("quality"))
// check the result page
body = assert.HTTPBody(makeHandler(uploadHandler, false), "GET", url.Path, values)
assert.Contains(t, body, `value="2023-10-02-hike-2.jpg"`)
assert.Contains(t, body, `value="15"`)
assert.Contains(t, body, `value="50"`)
assert.Contains(t, body, `src="/view/testdata/multi/2023-10-02-hike-1.jpg"`)
}
func TestUploadDir(t *testing.T) {
cleanup(t, "testdata/dir")
p := &Page{Name: "testdata/dir/test", Body: []byte(`# Test
Eyes are an abyss
We stare into each other
There is no answer`)}
p.save()
// check location for upload
body := assert.HTTPBody(makeHandler(viewHandler, true), "GET", "/view/testdata/dir/test", nil)
assert.Contains(t, body, `href="/upload/testdata/dir/?filename=test-1.jpg"`)
// check location for drop
body = assert.HTTPBody(makeHandler(uploadHandler, false), "GET", "/upload/testdata/dir/", nil)
assert.Contains(t, body, `action="/drop/testdata/dir/"`)
// actually do the upload
form := new(bytes.Buffer)
writer := multipart.NewWriter(form)
field, _ := writer.CreateFormField("name")
field.Write([]byte("test.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()
location := HTTPUploadLocation(t, makeHandler(dropHandler, false), "/drop/testdata/dir/",
writer.FormDataContentType(), form)
url, _ := url.Parse(location)
assert.Equal(t, "/upload/testdata/dir/", url.Path, "Redirect to upload location")
values := url.Query()
assert.Equal(t, "test.jpg", values.Get("last"))
// check the result page
body = assert.HTTPBody(makeHandler(uploadHandler, false), "GET", url.Path, values)
assert.Contains(t, body, `src="/view/testdata/dir/test.jpg"`)
}

39
version_cmd.go Normal file
View File

@@ -0,0 +1,39 @@
package main
import (
"context"
"flag"
"fmt"
"github.com/google/subcommands"
"io"
"os"
"runtime/debug"
)
type versionCmd struct {
}
func (cmd *versionCmd) SetFlags(f *flag.FlagSet) {
}
func (*versionCmd) Name() string { return "version" }
func (*versionCmd) Synopsis() string { return "report build information" }
func (*versionCmd) Usage() string {
return `version:
Report all the debug information about this build.
`
}
func (cmd *versionCmd) Execute(_ context.Context, f *flag.FlagSet, _ ...interface{}) subcommands.ExitStatus {
return versionCli(os.Stdout, f.Args())
}
func versionCli(w io.Writer, args []string) subcommands.ExitStatus {
if len(args) > 0 {
fmt.Fprintln(os.Stderr, "Version takes no arguments.")
return subcommands.ExitFailure
}
info, _ := debug.ReadBuildInfo()
fmt.Println(info)
return subcommands.ExitSuccess
}

15
version_cmd_test.go Normal file
View File

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

128
view.go
View File

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

View File

@@ -1,29 +1,33 @@
<!DOCTYPE html>
<html>
<html lang="{{.Language}}">
<head>
<meta charset="utf-8">
<meta name="format-detection" content="telephone=no">
<meta name="viewport" content="width=device-width">
<title>{{.Title}}</title>
<style>
html { max-width: 65ch; padding: 1ch; margin: auto; color: #111; background: #ffe; }
html { max-width: 70ch; padding: 1ch; margin: auto; color: #111; background-color: #ffe; }
body { hyphens: auto; }
header a { margin-right: 1ch; }
form { display: inline-block; }
input#search { width: 12ch; }
button { background-color: #eee; color: inherit; border-radius: 4px; border-width: 1px; }
footer { border-top: 1px solid #888 }
img { max-width: 100%; }
</style>
</head>
<body lang="{{.Language}}">
<body>
<header>
<a href="#main">Skip navigation</a>
<a href="/view/index">Home</a>
<a href="/edit/{{.Name}}">Edit</a>
<a href="/add/{{.Name}}">Add</a>
<a href="/upload/{{.Dir}}">Upload</a>
<form role="search" action="/search" method="GET">
<input type="text" spellcheck="false" name="q" required>
<button>Search</button>
<a href="index">Home</a>
<a href="/edit/{{.Name}}" accesskey="e">Edit</a>
<a href="/add/{{.Name}}" accesskey="a">Add</a>
<a href="/diff/{{.Name}}" accesskey="d">Diff</a>
<a href="/upload/{{.Dir}}?filename={{.Base}}-1.jpg" accesskey="u">Upload</a>
<form role="search" action="/search/{{.Dir}}" method="GET">
<label for="search">Search:</label>
<input id="search" type="text" spellcheck="false" name="q" accesskey="f" placeholder="term #tag title:term blog:true" required>
<button>Go</button>
</form>
</header>
<main id="main">
@@ -32,7 +36,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>

View File

@@ -2,8 +2,9 @@ package main
import (
"github.com/stretchr/testify/assert"
"net/http"
"net/url"
"os"
"regexp"
"testing"
)
@@ -13,42 +14,129 @@ func TestRootHandler(t *testing.T) {
// 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))
assert.Contains(t,
assert.HTTPBody(makeHandler(viewHandler, true), "GET", "/view/index", nil),
"Welcome to Oddµ")
}
func TestViewHandlerDir(t *testing.T) {
cleanup(t, "testdata/dir")
HTTPRedirectTo(t, makeHandler(viewHandler, true), "GET", "/view/", nil, "/view/index")
HTTPRedirectTo(t, makeHandler(viewHandler, true), "GET", "/view/testdata", nil, "/view/testdata/index")
HTTPRedirectTo(t, makeHandler(viewHandler, true), "GET", "/view/testdata/", nil, "/view/testdata/index")
assert.NoError(t, os.Mkdir("testdata/dir", 0755))
HTTPRedirectTo(t, makeHandler(viewHandler, true), "GET", "/view/testdata/dir", nil, "/view/testdata/dir/index")
HTTPRedirectTo(t, makeHandler(viewHandler, true), "GET", "/view/testdata/dir/", nil, "/view/testdata/dir/index")
assert.NoError(t, os.Mkdir("testdata/dir/dir", 0755))
HTTPRedirectTo(t, makeHandler(viewHandler, true), "GET", "/view/testdata/dir", nil, "/view/testdata/dir/index")
HTTPRedirectTo(t, makeHandler(viewHandler, true), "GET", "/view/testdata/dir/", nil, "/view/testdata/dir/index")
HTTPRedirectTo(t, makeHandler(viewHandler, true), "GET", "/view/testdata/dir/dir", nil, "/view/testdata/dir/dir/index")
HTTPRedirectTo(t, makeHandler(viewHandler, true), "GET", "/view/testdata/dir/dir/", nil, "/view/testdata/dir/dir/index")
assert.NoError(t, os.WriteFile("testdata/dir/dir.md", []byte(`# Blackbird
The oven hums and
the music plays, coffee smells
blackbirds sing outside
`), 0644))
assert.Contains(t, assert.HTTPBody(makeHandler(viewHandler, true), "GET", "/view/testdata/dir/dir", nil), "<h1>Blackbird</h1>")
assert.Contains(t, assert.HTTPBody(makeHandler(viewHandler, true), "GET", "/view/testdata/dir/dir.md", nil), "# Blackbird")
HTTPRedirectTo(t, makeHandler(viewHandler, true), "GET", "/view/testdata/dir/dir/", nil, "/view/testdata/dir/dir/index")
}
// relies on index.md in the current directory!
func TestViewHandlerWithId(t *testing.T) {
data := make(url.Values)
data.Set("id", "index")
assert.Contains(t,
assert.HTTPBody(makeHandler(viewHandler, true), "GET", "/view/", data),
"Welcome to Oddµ")
}
// wipes testdata
func TestPageTitleWithAmp(t *testing.T) {
_ = os.RemoveAll("testdata")
cleanup(t, "testdata/amp")
p := &Page{Name: "testdata/Rock & Roll", Body: []byte("Dancing")}
p := &Page{Name: "testdata/amp/Rock & Roll", Body: []byte("Dancing")}
p.save()
assert.Regexp(t, regexp.MustCompile("Rock &amp; Roll"),
assert.HTTPBody(makeHandler(viewHandler, true), "GET", "/view/testdata/Rock%20%26%20Roll", nil))
assert.Contains(t,
assert.HTTPBody(makeHandler(viewHandler, true), "GET", "/view/testdata/amp/Rock%20%26%20Roll", nil),
"Rock &amp; Roll")
p = &Page{Name: "testdata/Rock & Roll", Body: []byte("# Sex & Drugs & Rock'n'Roll\nOh no!")}
p = &Page{Name: "testdata/amp/Rock & Roll", Body: []byte("# Sex & Drugs & Rock'n'Roll\nOh no!")}
p.save()
assert.Regexp(t, regexp.MustCompile("Sex &amp; Drugs"),
assert.HTTPBody(makeHandler(viewHandler, true), "GET", "/view/testdata/Rock%20%26%20Roll", nil))
t.Cleanup(func() {
_ = os.RemoveAll("testdata")
})
assert.Contains(t,
assert.HTTPBody(makeHandler(viewHandler, true), "GET", "/view/testdata/amp/Rock%20%26%20Roll", nil),
"Sex &amp; Drugs")
}
func TestPageTitleWithQuestionMark(t *testing.T) {
_ = os.RemoveAll("testdata")
cleanup(t, "testdata/q")
p := &Page{Name: "testdata/How about no?", Body: []byte("No means no")}
p := &Page{Name: "testdata/q/How about no?", Body: []byte("No means no")}
p.save()
body := assert.HTTPBody(makeHandler(viewHandler, true), "GET", "/view/testdata/How%20about%20no%3F", nil)
body := assert.HTTPBody(makeHandler(viewHandler, true), "GET", "/view/testdata/q/How%20about%20no%3F", nil)
assert.Contains(t, body, "No means no")
assert.Contains(t, body, "<a href=\"/edit/testdata/How%20about%20no%3F\">Edit</a>")
t.Cleanup(func() {
_ = os.RemoveAll("testdata")
})
assert.Contains(t, body, "<a href=\"/edit/testdata/q/How%20about%20no%3F\" accesskey=\"e\">Edit</a>")
}
func TestFileLastModified(t *testing.T) {
cleanup(t, "testdata/file-mod")
assert.NoError(t, os.Mkdir("testdata/file-mod", 0755))
assert.NoError(t, os.WriteFile("testdata/file-mod/now.txt", []byte(`
A spider sitting
Unmoving and still
In the autumn chill
`), 0644))
fi, err := os.Stat("testdata/file-mod/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/file-mod/now.txt", nil, "Last-Modified"))
HTTPStatusCodeIfModifiedSince(t, h, "/view/testdata/file-mod/now.txt", fi.ModTime())
}
func TestForbidden(t *testing.T) {
assert.HTTPStatusCode(t, makeHandler(viewHandler, true), "GET", "/view/", nil, http.StatusFound)
assert.HTTPStatusCode(t, makeHandler(viewHandler, true), "GET", "/view/.", nil, http.StatusForbidden)
assert.HTTPStatusCode(t, makeHandler(viewHandler, true), "GET", "/view/.htaccess", nil, http.StatusForbidden)
assert.HTTPStatusCode(t, makeHandler(viewHandler, true), "GET", "/view/.git/description", nil, http.StatusForbidden)
}
func TestPageLastModified(t *testing.T) {
cleanup(t, "testdata/page-mod")
p := &Page{Name: "testdata/page-mod/now", Body: []byte(`
The sky glows softly
Sadly, the birds are quiet
I like spring better
`)}
p.save()
fi, err := os.Stat("testdata/page-mod/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/page-mod/now", nil, "Last-Modified"))
HTTPStatusCodeIfModifiedSince(t, h, "/view/testdata/page-mod/now", fi.ModTime())
}
func TestPageHead(t *testing.T) {
cleanup(t, "testdata/head")
p := &Page{Name: "testdata/head/peace", Body: []byte(`
No urgent typing
No todos, no list, no queue.
Just me and the birds.
`)}
p.save()
fi, err := os.Stat("testdata/head/peace.md")
assert.NoError(t, err)
h := makeHandler(viewHandler, true)
assert.Equal(t, []string(nil),
HTTPHeaders(h, "HEAD", "/view/testdata/head/war", nil, "Last-Modified"))
assert.Equal(t, []string(nil),
HTTPHeaders(h, "GET", "/view/testdata/head/war", nil, "Last-Modified"))
assert.Equal(t, []string{fi.ModTime().UTC().Format(http.TimeFormat)},
HTTPHeaders(h, "HEAD", "/view/testdata/head/peace", nil, "Last-Modified"))
assert.Equal(t, "",
assert.HTTPBody(h, "HEAD", "/view/testdata/head/peace", nil))
}

206
watch.go Normal file
View File

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

89
watch_test.go Normal file
View File

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

181
wiki.go
View File

@@ -1,59 +1,69 @@
package main
import (
"context"
"flag"
"fmt"
"html/template"
"github.com/google/subcommands"
"io/fs"
"log"
"net"
"net/http"
"os"
"regexp"
"strings"
)
// Templates are parsed at startup.
var templates = template.Must(
template.ParseFiles("edit.html", "add.html", "view.html",
"search.html", "upload.html"))
// validPath is a regular expression where the second group matches a
// page, so when the 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).
// validPath is a regular expression where the second group matches a page, so when the editHandler is called, a URL
// path of "/edit/foo" results in the editHandler being called with title "foo". The regular expression doesn't define
// the handlers (this happens in the main function).
var validPath = regexp.MustCompile("^/([^/]+)/(.*)$")
// titleRegexp is a regular expression matching a level 1 header line
// in a Markdown document. The first group matches the actual text and
// is used to provide an title for pages. If no title exists in the
// document, the page name is used instead.
// titleRegexp is a regular expression matching a level 1 header line in a Markdown document. The first group matches
// the actual text and is used to provide an title for pages. If no title exists in the document, the page name is used
// instead.
var titleRegexp = regexp.MustCompile("(?m)^#\\s*(.*)\n+")
// renderTemplate is the helper that is used render the templates with
// data.
func renderTemplate(w http.ResponseWriter, tmpl string, data any) {
err := templates.ExecuteTemplate(w, tmpl+".html", data)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
// makeHandler returns a handler that uses the URL path without the
// first path element as its argument, e.g. if the URL path is
// /edit/foo/bar, the editHandler is called with "foo/bar" as its
// argument. This uses the second group from the validPath regular
// expression. The boolean argument indicates whether the following
// path is required. When false, a URL /upload/ is OK.
// makeHandler returns a handler that uses the URL path without the first path element as its argument, e.g. if the URL
// path is /edit/foo/bar, the editHandler is called with "foo/bar" as its argument. This uses the second group from the
// validPath regular expression. The boolean argument indicates whether the following path is required. When false, a
// URL like /upload/ is OK. The argument can also be provided using a form parameter, i.e. call /edit/?id=foo/bar.
func makeHandler(fn func(http.ResponseWriter, *http.Request, string), required bool) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// no hidden files or directories
for _, segment := range strings.Split(r.URL.Path, "/") {
if strings.HasPrefix(segment, ".") {
http.Error(w, "can neither confirm nor deny the existence of this resource", http.StatusForbidden)
return
}
}
m := validPath.FindStringSubmatch(r.URL.Path)
if m != nil && (!required || len(m[2]) > 0) {
fn(w, r, m[2])
} else {
http.NotFound(w, r)
return
}
err := r.ParseForm()
if err != nil {
http.Error(w, "Cannot parse form", 400)
return
}
id := r.Form.Get("id")
// no hidden files or directories
for _, segment := range strings.Split(id, "/") {
if strings.HasPrefix(segment, ".") {
http.Error(w, "can neither confirm nor deny the existence of this resource", http.StatusForbidden)
return
}
}
if m != nil {
fn(w, r, id)
return
}
http.NotFound(w, r)
}
}
// getPort returns the environment variable ODDMU_PORT or the default
// port, "8080".
// getPort returns the environment variable ODDMU_PORT or the default port, "8080".
func getPort() string {
port := os.Getenv("ODDMU_PORT")
if port == "" {
@@ -62,43 +72,116 @@ func getPort() string {
return port
}
// scheduleLoadIndex calls index.load and prints some messages before
// and after. For testing, call index.load directly and skip the
// messages.
// When stdin is a socket, getListener returns a listener that listens
// on the socket passed as stdin. This allows systemd-style socket
// activation.
// Otherwise, getListener returns a net.Listener listening on the address from
// ODDMU_ADDRESS and the port from ODDMU_PORT.
// ODDMU_ADDRESS may be either an IPV4 address or an IPv6 address.
// If ODDMU_ADDRESS is unspecified, then the
// listener listens on all available unicast addresses, both IPv4 and IPv6.
func getListener() (net.Listener, error) {
address := os.Getenv("ODDMU_ADDRESS")
port := getPort()
stat, err := os.Stdin.Stat()
if stat == nil {
return nil, err
}
if stat.Mode().Type() == fs.ModeSocket {
// Listening socket passed on stdin, through systemd socket
// activation or similar:
log.Println("Serving a wiki on a listening socket passed by systemd.")
return net.FileListener(os.Stdin)
}
if strings.ContainsRune(address, ':') {
address = fmt.Sprintf("[%s]:%s", address, port)
} else {
address = fmt.Sprintf("%s:%s", address, port)
}
log.Printf("Serving a wiki at address %s", address)
return net.Listen("tcp", address)
}
// 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")
log.Print("Indexing pages")
n, err := index.load()
if err == nil {
fmt.Printf("Indexed %d pages\n", n)
log.Printf("Indexed %d pages", n)
} else {
fmt.Println("Indexing failed")
log.Printf("Indexing failed: %s", err)
}
}
// scheduleLoadLanguages calls loadLanguages and prints some messages before
// and after. For testing, call loadLanguages directly and skip the
// messages.
// 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")
log.Print("Loading languages")
n := loadLanguages()
fmt.Printf("Loaded %d languages\n", n)
log.Printf("Loaded %d languages", n)
}
// scheduleInstallWatcher calls watches.install and prints some messages before and after. For testing, call watch.init
// directly and skip the messages.
func scheduleInstallWatcher() {
log.Print("Installing watcher")
n, err := watches.install()
if err == nil {
if n == 1 {
log.Println("Installed watchers for one directory")
} else {
log.Printf("Installed watchers for %d directories", n)
}
} else {
log.Printf("Installing watcher failed: %s", err)
}
}
func serve() {
http.HandleFunc("/", rootHandler)
http.HandleFunc("/view/", makeHandler(viewHandler, true))
http.HandleFunc("/diff/", makeHandler(diffHandler, 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)
http.HandleFunc("/search/", makeHandler(searchHandler, false))
go scheduleLoadIndex()
go scheduleLoadLanguages()
port := getPort()
fmt.Printf("Serving a wiki on port %s\n", port)
http.ListenAndServe(":"+port, nil)
go scheduleInstallWatcher()
listener, err := getListener()
if listener == nil {
log.Println(err)
} else {
err := http.Serve(listener, nil)
if err != nil {
log.Println(err)
}
}
}
// 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(&listCmd{}, "")
subcommands.Register(&missingCmd{}, "")
subcommands.Register(&notifyCmd{}, "")
subcommands.Register(&replaceCmd{}, "")
subcommands.Register(&searchCmd{}, "")
subcommands.Register(&staticCmd{}, "")
subcommands.Register(&versionCmd{}, "")
flag.Parse()
ctx := context.Background()
os.Exit(int(subcommands.Execute(ctx)))
}
func main() {

Some files were not shown because too many files have changed in this diff Show More