forked from mirror/oddmu
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9e6d59cefa | ||
|
|
2a4902b1b4 | ||
|
|
efc54f1524 | ||
|
|
8fc5bd30e3 | ||
|
|
40855ea442 | ||
|
|
29af9a4cfa | ||
|
|
146f4c9f57 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,2 +1,3 @@
|
||||
/oddmu
|
||||
test.md
|
||||
/testdata/
|
||||
|
||||
41
README.md
41
README.md
@@ -22,6 +22,9 @@ Feel free to change the templates `view.html` and `edit.html` and
|
||||
restart the server. Modifying the styles in the templates would be a
|
||||
good start to get a feel for it.
|
||||
|
||||
The first change you should make is to replace the email address in
|
||||
`view.html`. 😄
|
||||
|
||||
The templates can refer to the following properties of a page:
|
||||
|
||||
`{{.Title}}` is the page title. If the page doesn't provide its own
|
||||
@@ -77,9 +80,11 @@ As root, on your server:
|
||||
adduser --system --home /home/oddmu oddmu
|
||||
```
|
||||
|
||||
Copy all the files into `/home/oddmu` to your server: `oddmu`, `oddmu.service`, `view.html` and `edit.html`.
|
||||
Copy all the files into `/home/oddmu` to your server: `oddmu`,
|
||||
`oddmu.service`, `view.html` and `edit.html`.
|
||||
|
||||
Edit the `oddmu.service` file. These are the three lines you most likely have to take care of:
|
||||
Edit the `oddmu.service` file. These are the three lines you most
|
||||
likely have to take care of:
|
||||
|
||||
```
|
||||
ExecStart=/home/oddmu/oddmu
|
||||
@@ -236,6 +241,38 @@ and without needing a wiki page.
|
||||
[Wikipedia](https://en.wikipedia.org/wiki/Robot_exclusion_standard)
|
||||
has more information.
|
||||
|
||||
## Different logins for different access rights
|
||||
|
||||
What if you have a site with various subdirectories and each
|
||||
subdirectory is for a different group of friends? You can set this up
|
||||
using your webserver. One way to do this is to require specific
|
||||
usernames (which must have a password in the password file mentioned
|
||||
above.
|
||||
|
||||
This requires a valid login by the user "alex" or "berta":
|
||||
|
||||
```apache
|
||||
<LocationMatch "^/(edit|save)/intetebi/">
|
||||
Require user alex berta
|
||||
</LocationMatch>
|
||||
```
|
||||
|
||||
## Private wikis
|
||||
|
||||
Based on the above, you can prevent people from reading the wiki, too.
|
||||
The `LocationMatch` must cover the `/view/` URLs. In order to protect
|
||||
*everything*, use a [Location directive](https://httpd.apache.org/docs/current/mod/core.html#location)
|
||||
that matches everything:
|
||||
|
||||
```apache
|
||||
<Location />
|
||||
AuthType Basic
|
||||
AuthName "Password Required"
|
||||
AuthUserFile /home/oddmu/.htpasswd
|
||||
Require valid-user
|
||||
</Location>
|
||||
```
|
||||
|
||||
## Customization (with recompilation)
|
||||
|
||||
The Markdown parser can be customized and
|
||||
|
||||
16
highlight.go
16
highlight.go
@@ -1,13 +1,13 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// highlight splits the query string q into terms and highlights them
|
||||
// using the bold tag. Return the highlighted string and a score.
|
||||
func highlight (q string, s string) (string, int) {
|
||||
func highlight(q string, s string) (string, int) {
|
||||
c := 0
|
||||
re, err := regexp.Compile("(?i)" + q)
|
||||
if err == nil {
|
||||
@@ -32,9 +32,15 @@ func highlight (q string, s string) (string, int) {
|
||||
// Terms matching at the beginning and
|
||||
// end of words and matching entire
|
||||
// words increase the score further.
|
||||
if len(m[1]) == 0 { c++ }
|
||||
if len(m[3]) == 0 { c++ }
|
||||
if len(m[1]) == 0 && len(m[3]) == 0 { c++ }
|
||||
if len(m[1]) == 0 {
|
||||
c++
|
||||
}
|
||||
if len(m[3]) == 0 {
|
||||
c++
|
||||
}
|
||||
if len(m[1]) == 0 && len(m[3]) == 0 {
|
||||
c++
|
||||
}
|
||||
r[m[2]] = "<b>" + m[2] + "</b>"
|
||||
}
|
||||
for old, new := range r {
|
||||
|
||||
26
page.go
26
page.go
@@ -1,14 +1,16 @@
|
||||
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"
|
||||
"strings"
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Page is a struct containing information about a single page. Title
|
||||
@@ -35,6 +37,14 @@ func (p *Page) save() error {
|
||||
s := bytes.ReplaceAll(p.Body, []byte{'\r'}, []byte{})
|
||||
p.Body = s
|
||||
p.updateIndex()
|
||||
d := filepath.Dir(filename)
|
||||
if d != "." {
|
||||
err := os.MkdirAll(d, 0700)
|
||||
if err != nil {
|
||||
fmt.Printf("Creating directory %s failed", d)
|
||||
return err
|
||||
}
|
||||
}
|
||||
return os.WriteFile(filename, s, 0600)
|
||||
}
|
||||
|
||||
@@ -55,7 +65,7 @@ func loadPage(name string) (*Page, error) {
|
||||
// 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.
|
||||
func (p* Page) handleTitle(replace bool) {
|
||||
func (p *Page) handleTitle(replace bool) {
|
||||
s := string(p.Body)
|
||||
m := titleRegexp.FindStringSubmatch(s)
|
||||
if m != nil {
|
||||
@@ -67,16 +77,16 @@ func (p* Page) handleTitle(replace bool) {
|
||||
}
|
||||
|
||||
// renderHtml renders the Page.Body to HTML and sets Page.Html.
|
||||
func (p* Page) renderHtml() {
|
||||
func (p *Page) renderHtml() {
|
||||
maybeUnsafeHTML := markdown.ToHTML(p.Body, nil, nil)
|
||||
html := bluemonday.UGCPolicy().SanitizeBytes(maybeUnsafeHTML)
|
||||
p.Html = template.HTML(html);
|
||||
p.Html = template.HTML(html)
|
||||
}
|
||||
|
||||
// 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 {
|
||||
func (p *Page) plainText() string {
|
||||
parser := parser.New()
|
||||
doc := markdown.Parse(p.Body, parser)
|
||||
text := []byte("")
|
||||
@@ -95,13 +105,13 @@ func (p* Page) plainText() string {
|
||||
}
|
||||
// Remove trailing space
|
||||
for text[len(text)-1] == ' ' {
|
||||
text = text[0: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) {
|
||||
func (p *Page) summarize(q string) {
|
||||
p.handleTitle(true)
|
||||
s, c := snippets(q, p.plainText())
|
||||
p.Score = c
|
||||
|
||||
25
page_test.go
25
page_test.go
@@ -1,11 +1,12 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestPageTitle (t *testing.T) {
|
||||
func TestPageTitle(t *testing.T) {
|
||||
p := &Page{Body: []byte(`# Ache
|
||||
My back aches for you
|
||||
I sit, stare and type for hours
|
||||
@@ -26,7 +27,7 @@ But yearn for blue sky`)}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPagePlainText (t *testing.T) {
|
||||
func TestPagePlainText(t *testing.T) {
|
||||
p := &Page{Body: []byte(`# Water
|
||||
The air will not come
|
||||
To inhale is an effort
|
||||
@@ -39,7 +40,7 @@ The summer heat kills`)}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPageHtml (t *testing.T) {
|
||||
func TestPageHtml(t *testing.T) {
|
||||
p := &Page{Body: []byte(`# Sun
|
||||
Silver leaves shine bright
|
||||
They droop, boneless, weak and sad
|
||||
@@ -57,3 +58,21 @@ A cruel sun stares down</p>
|
||||
t.Fail()
|
||||
}
|
||||
}
|
||||
|
||||
func TestPageDir(t *testing.T) {
|
||||
_ = os.RemoveAll("testdata")
|
||||
loadIndex()
|
||||
p := &Page{Name: "testdata/moon", Body: []byte(`# Moon
|
||||
From bed to bathroom
|
||||
A slow shuffle in the dark
|
||||
Moonlight floods the aisle`)}
|
||||
p.save()
|
||||
o, err := loadPage("testdata/moon")
|
||||
if err != nil || string(o.Body) != string(p.Body) {
|
||||
t.Logf("File in subdirectory not loaded: %s", p.Name)
|
||||
t.Fail()
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = os.RemoveAll("testdata")
|
||||
})
|
||||
}
|
||||
|
||||
10
search.go
10
search.go
@@ -1,12 +1,12 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
trigram "github.com/dgryski/go-trigram"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"slices"
|
||||
"io/fs"
|
||||
"fmt"
|
||||
trigram "github.com/dgryski/go-trigram"
|
||||
"io/fs"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Search is a struct containing the result of a search. Query is the
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"strings"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
var name string = "test"
|
||||
|
||||
// TestIndex relies on README.md being indexed
|
||||
func TestIndex (t *testing.T) {
|
||||
func TestIndex(t *testing.T) {
|
||||
_ = os.Remove(name + ".md")
|
||||
loadIndex()
|
||||
q := "Oddµ"
|
||||
@@ -68,4 +68,7 @@ func TestIndex (t *testing.T) {
|
||||
t.Logf("Page '%s' not found using the new content: %s", name, p.Body)
|
||||
t.Fail()
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = os.Remove(name + ".md")
|
||||
})
|
||||
}
|
||||
|
||||
12
snippets.go
12
snippets.go
@@ -1,11 +1,11 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func snippets (q string, s string) (string, int) {
|
||||
func snippets(q string, s string) (string, int) {
|
||||
// Look for Snippets
|
||||
snippetlen := 100
|
||||
maxsnippets := 4
|
||||
@@ -45,7 +45,7 @@ func snippets (q string, s string) (string, int) {
|
||||
if j > -1 {
|
||||
// get the substring containing the start of
|
||||
// the match, ending on word boundaries
|
||||
from := j - snippetlen / 2
|
||||
from := j - snippetlen/2
|
||||
if from < 0 {
|
||||
from = 0
|
||||
}
|
||||
@@ -55,7 +55,7 @@ func snippets (q string, s string) (string, int) {
|
||||
} else {
|
||||
start += from
|
||||
}
|
||||
to := j + snippetlen / 2
|
||||
to := j + snippetlen/2
|
||||
if to > len(s) {
|
||||
to = len(s)
|
||||
}
|
||||
@@ -69,8 +69,8 @@ func snippets (q string, s string) (string, int) {
|
||||
end += to
|
||||
}
|
||||
}
|
||||
t = s[start : end];
|
||||
res = res + t + " …";
|
||||
t = s[start:end]
|
||||
res = res + t + " …"
|
||||
// truncate text to avoid rematching the same string.
|
||||
s = s[end:]
|
||||
}
|
||||
|
||||
20
view.html
20
view.html
@@ -8,20 +8,28 @@
|
||||
<style>
|
||||
html { max-width: 70ch; padding: 2ch; margin: auto; color: #111; background: #ffe; }
|
||||
form { display: inline-block; padding-left: 1em; }
|
||||
footer { border-top: 1px solid #888 }
|
||||
img { max-width: 100%; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>{{.Title}}</h1>
|
||||
<div>
|
||||
<header>
|
||||
<a href="#main">Skip navigation</a>
|
||||
<a href="/view/index">Home</a>
|
||||
<a href="/edit/{{.Name}}">Edit this page</a>
|
||||
<form role="search" action="/search" method="GET">
|
||||
<input type="text" spellcheck="false" name="q" required>
|
||||
<button>Search</button>
|
||||
</form>
|
||||
</div>
|
||||
<div>
|
||||
{{.Html}}
|
||||
</div>
|
||||
</header>
|
||||
<main id="main">
|
||||
<h1>{{.Title}}</h1>
|
||||
{{.Html}}
|
||||
</main>
|
||||
<footer>
|
||||
<address>
|
||||
Comments? Send mail to Alex Schroeder <<a href="mailto:alex@alexschroeder.ch">alex@alexschroeder.ch</a>>
|
||||
</address>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
12
wiki.go
12
wiki.go
@@ -1,12 +1,12 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"html/template"
|
||||
"net/http"
|
||||
"strings"
|
||||
"regexp"
|
||||
"fmt"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Templates are parsed at startup.
|
||||
@@ -45,7 +45,7 @@ func rootHandler(w http.ResponseWriter, r *http.Request) {
|
||||
// "view.html" template is used to show the rendered HTML.
|
||||
func viewHandler(w http.ResponseWriter, r *http.Request, name string) {
|
||||
// Short cut for text files
|
||||
if (strings.HasSuffix(name, ".txt")) {
|
||||
if strings.HasSuffix(name, ".txt") {
|
||||
body, err := os.ReadFile(name)
|
||||
if err == nil {
|
||||
w.Write(body)
|
||||
@@ -94,7 +94,7 @@ func saveHandler(w http.ResponseWriter, r *http.Request, name string) {
|
||||
// /edit/foo/bar, the editHandler is called with "foo/bar" as its
|
||||
// argument. This uses the second group from the validPath regular
|
||||
// expression.
|
||||
func makeHandler(fn func (http.ResponseWriter, *http.Request, string)) http.HandlerFunc {
|
||||
func makeHandler(fn func(http.ResponseWriter, *http.Request, string)) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
m := validPath.FindStringSubmatch(r.URL.Path)
|
||||
if m != nil {
|
||||
@@ -134,5 +134,5 @@ func main() {
|
||||
loadIndex()
|
||||
port := getPort()
|
||||
fmt.Printf("Serving a wiki on port %s\n", port)
|
||||
http.ListenAndServe(":" + port, nil)
|
||||
http.ListenAndServe(":"+port, nil)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user