Compare commits

...

74 commits

Author SHA1 Message Date
cb611fbe3e xsrf fix 2026-03-13 00:02:25 +01:00
db6d76769b always set token 2025-10-15 22:51:36 +02:00
8675bc4b7a update vendor 2025-10-15 22:37:57 +02:00
824f46979b fix xsrf 2025-10-15 22:22:11 +02:00
204aec8135 updated syscall usage 2024-08-24 02:24:45 +02:00
c9aa60ca28 updated dependencies 2023-04-01 19:50:50 +02:00
cff4b58f6d updated dependencies 2023-01-15 16:24:38 +01:00
1410847c03 some updates 2021-12-04 17:53:19 +01:00
1ce33aa89a syntax fixes 2021-11-05 21:55:18 +01:00
8e748aaa90 updates dependencies 2021-09-11 15:54:31 +02:00
76360ddbc2 updated dependencies and go 1.17 2021-09-11 15:39:07 +02:00
58caf0527e cleanup 2021-07-11 23:43:10 +02:00
312005632a bump copyright 2021-02-18 22:53:49 +01:00
a3dd0183a4 bump copyright 2021-02-18 22:52:28 +01:00
82fd34b288 go1.16 and embed 2021-02-18 22:36:16 +01:00
0a55030ae5 updated dependencies and moved to new rendering 2020-11-12 22:54:50 +01:00
320dc096dd updated dependencies 2020-07-11 11:25:30 +02:00
0265d820fe bump copyright, added stats page and updated dependencies 2020-01-11 11:32:27 +01:00
b1028b3d8a added plain db 2019-11-24 11:54:39 +01:00
f6dbdcf460 go1.13 and vendored dependencies 2019-09-03 21:46:50 +02:00
d29ec19abc move index mapping to const variables 2019-08-24 18:49:16 +02:00
9ecaf33d86 better migrator and page timing 2019-08-24 18:33:13 +02:00
94ee84414c updated dependencies 2019-08-23 21:23:33 +02:00
94762c62e4 remove newline at the end of codeblocks 2019-08-04 22:00:43 +02:00
d9d089be0d some improvements 2019-07-30 18:57:16 +02:00
8143e484cd added prefix scan and fixed logout issue 2019-07-06 11:31:12 +02:00
4812a60367 better search view and admin user reset 2019-06-11 19:56:08 +02:00
c8adfd2ef9 panic bugfix 2019-05-30 16:03:51 +02:00
1b2a24a901 added features list 2019-05-16 11:01:06 +02:00
a8a4771ca4 added missing admin config option 2019-05-16 10:39:00 +02:00
0c1f6fe206 better install scripts 2019-05-16 10:32:31 +02:00
5e976064e1 fixed TOC alignment 2019-05-15 23:01:27 +02:00
98822b8e1b fixed TOC alignment 2019-05-15 23:01:08 +02:00
c4ad04fd18 typo 2019-05-15 22:53:58 +02:00
6e317eb08b bugfixes and updated go.mod 2019-05-15 22:52:27 +02:00
9424084a36 working sections version 2019-03-27 23:08:59 +01:00
16ea95bf09 added user independent sections and started db migration work 2019-03-26 23:55:55 +01:00
6a2dc8146c added initial favicon 2019-01-23 00:37:51 +01:00
799ff0fb2f added sections overview 2019-01-22 21:45:32 +01:00
72d00aeb21 bump copyright year 2019-01-21 20:06:44 +01:00
d8c4bef3a3 fix possible username enumeration 2019-01-21 19:56:39 +01:00
4e5d554105 added login referer 2019-01-21 19:46:31 +01:00
2e70a95bcd move to base64 urlencoding for xsrf 2018-12-28 19:21:14 +01:00
c28249e1ce updated jwt and added share token invalidation 2018-12-28 19:14:21 +01:00
095fb1573b fixed back-to-top anchor 2018-12-28 15:54:53 +01:00
ab454f4b99 added 'remember me' 2018-11-14 21:31:23 +01:00
61fcf2dcfe fixed page timing 2018-10-28 00:22:18 +02:00
f90e8b84b2 fixed a bug where search returned internal pages to unauthenticated users 2018-10-28 00:08:35 +02:00
35448db725 better style 2018-10-27 23:43:15 +02:00
d362159693 small bug fix 2018-10-27 21:41:23 +02:00
ddb11f0619 general improvements 2018-10-27 21:28:19 +02:00
bac08dc7cf added totp support 2018-10-27 16:05:17 +02:00
1681a2b4a0 added otp library and updated blackfriday to v1.5.2 2018-10-27 15:37:48 +02:00
375933c7bc added support for config files, openbsd, pledge and unveil 2018-10-26 17:28:50 +02:00
62543e44da updated x/sys and godrop 2018-10-26 17:27:33 +02:00
78680de687 more cleanup 2018-10-21 22:45:22 +02:00
16671b3406 some tests and major cleanup 2018-10-19 23:08:32 +02:00
8ef2a2547c minor changes/cleanup 2018-10-17 00:01:41 +02:00
de20ea3a2a fixed searching 2018-10-16 21:44:01 +02:00
8d3187b160 added dump/restore and fixed title stuff 2018-10-16 21:26:39 +02:00
d4971cda89 single db with prefixes, dump/restore, blacklisting on user deletion 2018-09-23 23:56:12 +02:00
62ff59957a updated dependencies 2018-09-20 00:18:46 +02:00
97d914bce4 minor bugfixes and cleanup 2018-09-19 00:10:16 +02:00
2a203f6f29 updated russross/blackfriday to v1.5.1 2018-09-19 00:09:42 +02:00
575a41127f fixed permissions and style 2018-09-17 23:54:55 +02:00
3fb1cfc17d upgraded bootstrap, fixed style, added share blacklisting 2018-09-14 20:49:50 +02:00
85f93bae69 first working version 2018-09-14 00:14:49 +02:00
14e0611f06 go mod and vendor/ update 2018-09-14 00:14:04 +02:00
9d332bbda4 jwt for webapp 2018-09-12 00:37:13 +02:00
7e77fd7d27 split db and bolt to store interface 2018-09-11 14:50:53 +02:00
07ff5ccffb work in progress 2018-09-04 23:15:47 +02:00
38f7c31517 mostly finished db pages 2018-02-11 19:21:31 +01:00
c5d41b0965 split into packages 2018-02-09 22:38:47 +01:00
2b56832843 major changes 2018-02-07 21:41:54 +01:00
1371 changed files with 521417 additions and 281491 deletions

5
.gitignore vendored
View file

@ -1 +1,4 @@
gowiki
/gowiki
tmp
run.sh
TODO.txt

View file

@ -1,4 +1,4 @@
Copyright (C) 2016 Marius Schellenberger
Copyright (C) 2021 Marius Schellenberger
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by

232
Godeps/Godeps.json generated
View file

@ -1,232 +0,0 @@
{
"ImportPath": "git.giftfish.de/ston1th/gowiki",
"GoVersion": "go1.7",
"GodepVersion": "v74",
"Deps": [
{
"ImportPath": "git.giftfish.de/ston1th/godrop",
"Comment": "v1.0",
"Rev": "8666e4461c9612e1432f05af6e0abfd716adca36"
},
{
"ImportPath": "github.com/blevesearch/bleve",
"Comment": "v0.5.0",
"Rev": "97393d027342f43b17da2f02c090a4e728ac929f"
},
{
"ImportPath": "github.com/blevesearch/bleve/analysis",
"Comment": "v0.5.0",
"Rev": "97393d027342f43b17da2f02c090a4e728ac929f"
},
{
"ImportPath": "github.com/blevesearch/bleve/analysis/analyzers/standard_analyzer",
"Comment": "v0.5.0",
"Rev": "97393d027342f43b17da2f02c090a4e728ac929f"
},
{
"ImportPath": "github.com/blevesearch/bleve/analysis/char_filters/html_char_filter",
"Comment": "v0.5.0",
"Rev": "97393d027342f43b17da2f02c090a4e728ac929f"
},
{
"ImportPath": "github.com/blevesearch/bleve/analysis/char_filters/regexp_char_filter",
"Comment": "v0.5.0",
"Rev": "97393d027342f43b17da2f02c090a4e728ac929f"
},
{
"ImportPath": "github.com/blevesearch/bleve/analysis/datetime_parsers/datetime_optional",
"Comment": "v0.5.0",
"Rev": "97393d027342f43b17da2f02c090a4e728ac929f"
},
{
"ImportPath": "github.com/blevesearch/bleve/analysis/datetime_parsers/flexible_go",
"Comment": "v0.5.0",
"Rev": "97393d027342f43b17da2f02c090a4e728ac929f"
},
{
"ImportPath": "github.com/blevesearch/bleve/analysis/language/en",
"Comment": "v0.5.0",
"Rev": "97393d027342f43b17da2f02c090a4e728ac929f"
},
{
"ImportPath": "github.com/blevesearch/bleve/analysis/token_filters/lower_case_filter",
"Comment": "v0.5.0",
"Rev": "97393d027342f43b17da2f02c090a4e728ac929f"
},
{
"ImportPath": "github.com/blevesearch/bleve/analysis/token_filters/porter",
"Comment": "v0.5.0",
"Rev": "97393d027342f43b17da2f02c090a4e728ac929f"
},
{
"ImportPath": "github.com/blevesearch/bleve/analysis/token_filters/stop_tokens_filter",
"Comment": "v0.5.0",
"Rev": "97393d027342f43b17da2f02c090a4e728ac929f"
},
{
"ImportPath": "github.com/blevesearch/bleve/analysis/tokenizers/unicode",
"Comment": "v0.5.0",
"Rev": "97393d027342f43b17da2f02c090a4e728ac929f"
},
{
"ImportPath": "github.com/blevesearch/bleve/document",
"Comment": "v0.5.0",
"Rev": "97393d027342f43b17da2f02c090a4e728ac929f"
},
{
"ImportPath": "github.com/blevesearch/bleve/index",
"Comment": "v0.5.0",
"Rev": "97393d027342f43b17da2f02c090a4e728ac929f"
},
{
"ImportPath": "github.com/blevesearch/bleve/index/store",
"Comment": "v0.5.0",
"Rev": "97393d027342f43b17da2f02c090a4e728ac929f"
},
{
"ImportPath": "github.com/blevesearch/bleve/index/store/boltdb",
"Comment": "v0.5.0",
"Rev": "97393d027342f43b17da2f02c090a4e728ac929f"
},
{
"ImportPath": "github.com/blevesearch/bleve/index/store/gtreap",
"Comment": "v0.5.0",
"Rev": "97393d027342f43b17da2f02c090a4e728ac929f"
},
{
"ImportPath": "github.com/blevesearch/bleve/index/upside_down",
"Comment": "v0.5.0",
"Rev": "97393d027342f43b17da2f02c090a4e728ac929f"
},
{
"ImportPath": "github.com/blevesearch/bleve/numeric_util",
"Comment": "v0.5.0",
"Rev": "97393d027342f43b17da2f02c090a4e728ac929f"
},
{
"ImportPath": "github.com/blevesearch/bleve/registry",
"Comment": "v0.5.0",
"Rev": "97393d027342f43b17da2f02c090a4e728ac929f"
},
{
"ImportPath": "github.com/blevesearch/bleve/search",
"Comment": "v0.5.0",
"Rev": "97393d027342f43b17da2f02c090a4e728ac929f"
},
{
"ImportPath": "github.com/blevesearch/bleve/search/collectors",
"Comment": "v0.5.0",
"Rev": "97393d027342f43b17da2f02c090a4e728ac929f"
},
{
"ImportPath": "github.com/blevesearch/bleve/search/facets",
"Comment": "v0.5.0",
"Rev": "97393d027342f43b17da2f02c090a4e728ac929f"
},
{
"ImportPath": "github.com/blevesearch/bleve/search/highlight",
"Comment": "v0.5.0",
"Rev": "97393d027342f43b17da2f02c090a4e728ac929f"
},
{
"ImportPath": "github.com/blevesearch/bleve/search/highlight/fragment_formatters/html",
"Comment": "v0.5.0",
"Rev": "97393d027342f43b17da2f02c090a4e728ac929f"
},
{
"ImportPath": "github.com/blevesearch/bleve/search/highlight/fragmenters/simple",
"Comment": "v0.5.0",
"Rev": "97393d027342f43b17da2f02c090a4e728ac929f"
},
{
"ImportPath": "github.com/blevesearch/bleve/search/highlight/highlighters/html",
"Comment": "v0.5.0",
"Rev": "97393d027342f43b17da2f02c090a4e728ac929f"
},
{
"ImportPath": "github.com/blevesearch/bleve/search/highlight/highlighters/simple",
"Comment": "v0.5.0",
"Rev": "97393d027342f43b17da2f02c090a4e728ac929f"
},
{
"ImportPath": "github.com/blevesearch/bleve/search/scorers",
"Comment": "v0.5.0",
"Rev": "97393d027342f43b17da2f02c090a4e728ac929f"
},
{
"ImportPath": "github.com/blevesearch/bleve/search/searchers",
"Comment": "v0.5.0",
"Rev": "97393d027342f43b17da2f02c090a4e728ac929f"
},
{
"ImportPath": "github.com/blevesearch/go-porterstemmer",
"Comment": "v1.0.1-9-g23a2c8e",
"Rev": "23a2c8e5cf1f380f27722c6d2ae8896431dc7d0e"
},
{
"ImportPath": "github.com/blevesearch/segment",
"Rev": "762005e7a34fd909a84586299f1dd457371d36ee"
},
{
"ImportPath": "github.com/boltdb/bolt",
"Comment": "v1.3.0",
"Rev": "583e8937c61f1af6513608ccc75c97b6abdf4ff9"
},
{
"ImportPath": "github.com/golang/protobuf/proto",
"Rev": "7cc19b78d562895b13596ddce7aafb59dd789318"
},
{
"ImportPath": "github.com/gorilla/context",
"Comment": "v1.1",
"Rev": "1ea25387ff6f684839d82767c1733ff4d4d15d0a"
},
{
"ImportPath": "github.com/gorilla/mux",
"Comment": "v1.1",
"Rev": "0eeaf8392f5b04950925b8a69fe70f110fa7cbfc"
},
{
"ImportPath": "github.com/gorilla/securecookie",
"Comment": "v1.1",
"Rev": "667fe4e3466a040b780561fe9b51a83a3753eefc"
},
{
"ImportPath": "github.com/gorilla/sessions",
"Rev": "f7261893ca3ea922c30eabe742c036d2c1de6e0a"
},
{
"ImportPath": "github.com/russross/blackfriday",
"Comment": "v1.4-31-g2004188",
"Rev": "2004188462c3946efefbdcd91aa83e49b4175bfb"
},
{
"ImportPath": "github.com/shurcooL/sanitized_anchor_name",
"Rev": "10ef21a441db47d8b13ebcc5fd2310f636973c77"
},
{
"ImportPath": "github.com/steveyen/gtreap",
"Rev": "0abe01ef9be25c4aedc174758ec2d917314d6d70"
},
{
"ImportPath": "golang.org/x/crypto/bcrypt",
"Rev": "9477e0b78b9ac3d0b03822fd95422e2fe07627cd"
},
{
"ImportPath": "golang.org/x/crypto/blowfish",
"Rev": "9477e0b78b9ac3d0b03822fd95422e2fe07627cd"
},
{
"ImportPath": "golang.org/x/crypto/sha3",
"Rev": "9477e0b78b9ac3d0b03822fd95422e2fe07627cd"
},
{
"ImportPath": "golang.org/x/net/context",
"Rev": "55cccaa02af1a99c69ba3213e33468628b61be4b"
},
{
"ImportPath": "golang.org/x/sys/unix",
"Rev": "a646d33e2ee3172a661fc09bca23bb4889a41bc8"
}
]
}

5
Godeps/Readme generated
View file

@ -1,5 +0,0 @@
This directory tree is generated automatically by godep.
Please do not edit.
See https://github.com/tools/godep for more information.

View file

@ -1,32 +1,59 @@
CC=go
BUILD=build -v
LDFLAGS=--ldflags "-s -w"
BUILD=build -v -trimpath
VERSION=$(shell cat VERSION)
GCFLAGS=-gcflags '-e'
LDFLAGS=-ldflags '-buildid=$(VERSION) -X main.version=$(VERSION) -s -w'
PROGRAM=gowiki
CMD=-o $(PROGRAM) cmd/gowiki/main.go
ENV=GOGC=0 CGO_ENABLED=0 GO111MODULE=on
all: $(PROGRAM)
setup:
$(CC) get github.com/tools/godep
$(CC) get github.com/client9/misspell
$(CC) install github.com/client9/misspell/cmd/misspell@latest
$(CC) install honnef.co/go/tools/cmd/staticcheck@latest
$(PROGRAM): clean codeqa
CGO_ENABLED=0 $(CC) $(BUILD) $(LDFLAGS)
release: clean gofmt codeqa
$(ENV) $(CC) $(BUILD) $(GCFLAGS) $(LDFLAGS) $(CMD)
release-vendor: clean gofmt codeqa
$(ENV) $(CC) $(BUILD) -mod=vendor $(GCFLAGS) $(LDFLAGS) $(CMD)
vendor: clean gofmt
$(ENV) $(CC) $(BUILD) -mod=vendor $(GCFLAGS) $(LDFLAGS) $(CMD)
$(PROGRAM): clean gofmt
$(ENV) $(CC) $(BUILD) $(GCFLAGS) $(LDFLAGS) $(CMD)
clean:
$(CC) clean -x
codeqa: templates gofmt misspell golint
gofmt:
gofmt -w .
$(CC) fmt ./...
golint:
$(GOPATH)/bin/golint .
codeqa: govet misspell staticcheck test
govet:
$(CC) vet ./...
update:
$(ENV) $(CC) get -u ./...
misspell:
$(GOPATH)/bin/misspell **/*
$(GOPATH)/bin/misspell cmd/* pkg/* Makefile README.md sri.sh
templates:
$(CC) generate
staticcheck:
$(GOPATH)/bin/staticcheck ./...
.PHONY: setup build clean templates gofmt misspell golint
test:
ifeq ($(shell go env GOARCH), $(shell go env GOHOSTARCH))
ifeq ($(shell go env GOOS), $(shell go env GOHOSTOS))
$(CC) test ./...
else
$(info skipping tests of other platforms)
endif
else
$(info skipping tests of other platforms)
endif
.PHONY: setup release release-vendor vendor build clean codeqa gofmt govet misspell staticcheck test

View file

@ -1 +1,61 @@
# GoWiki - a simple wiki engine written in Go
## Building
**Requirements:**
* Go (golang) >= 1.16
Linux:
```
make
or offline
make vendor
```
OpenBSD:
```
GOOS=openbsd make
or offline
GOOS=openbsd make vendor
```
## Install
See the `scripts/` directory on how to install gowiki on Linux or OpenBSD.
## Config
```
{
"data_dir": "/var/lib/gowiki", // running directory. data will be stored here
"user": "wiki", // drop privileges to user
"group": "wiki", // drop privileges to group
"listen_addr": "127.0.0.1:8080", // listening <address>:<port>
"log_file": "gowiki.log", // log file (use - for stdout, this only works in combination with 'foreground: true')
"secret": "", // static hmac secret (at least 64 chars, used for JWT signing)
"admin": true, // set to false to disable the default admin user
"foreground": false, // do not fork into the background
"secure_cookie": false, // enable secure cookie flag
"debug": false // enable debugging
}
```
## Features
* Fulltext indexing of wiki pages
* Password hashing using BCrypt
* 2FA using TOTP
* Content Security Policy
* No Javascript (yet)
* Private wiki pages
### GoWiki on OpenBSD
GoWiki makes use of the OpenBSD native security features [pledge(2)](https://man.openbsd.org/pledge) and [unveil(2)](https://man.openbsd.org/unveil).

1
VERSION Normal file
View file

@ -0,0 +1 @@
v2.0

11
cmd/gowiki/main.go Normal file
View file

@ -0,0 +1,11 @@
// Copyright (C) 2021 Marius Schellenberger
package main
import "git.giftfish.de/ston1th/gowiki/pkg/cmd"
var version string
func main() {
cmd.Run(version)
}

View file

@ -1,250 +0,0 @@
// Copyright (C) 2017 Marius Schellenberger
package main
import (
"crypto/rand"
"crypto/subtle"
"encoding/base64"
"errors"
"github.com/gorilla/mux"
"github.com/gorilla/sessions"
"html/template"
"io"
"net/http"
"time"
)
const (
keyLen = 32
authLen = keyLen * 2
cookieName = "gosession"
)
var store *sessions.CookieStore
func cookieStore() {
options := &sessions.Options{
Path: "/",
MaxAge: 3600 * 12,
Secure: secureCookie,
HttpOnly: true,
}
authKey := genKey(authLen)
encKey := genKey(keyLen)
store = sessions.NewCookieStore(authKey, encKey)
store.Options = options
go func() {
for {
time.Sleep(time.Hour * 12)
newAuthKey := genKey(authLen)
newEncKey := genKey(keyLen)
newStore := sessions.NewCookieStore(newAuthKey, newEncKey, authKey, encKey)
newStore.Options = options
store = newStore
authKey = newAuthKey
encKey = newEncKey
}
}()
}
func genKey(length int) (bytes []byte) {
bytes = make([]byte, length)
if _, err := io.ReadFull(rand.Reader, bytes); err != nil {
log.Println("genKey:", err)
}
return
}
func newContext(w http.ResponseWriter, r *http.Request) *Context {
log.Printf("%s %s %s\n", r.RemoteAddr, r.Method, r.URL.Path)
h := w.Header()
h.Set("X-Frame-Options", "DENY")
h.Set("X-Content-Type-Options", "nosniff")
h.Set("X-XSS-Protection", "1; mode=block")
h.Set("Content-Security-Policy", "default-src 'none';style-src 'self' 'unsafe-inline';frame-ancestors 'none'")
return &Context{
Request: r,
Response: w,
}
}
type Context struct {
Request *http.Request
Response http.ResponseWriter
T *template.Template
Data webData
}
type webData struct {
Title string
BodyTitle string
Admin bool
Login bool
User string
Token string
Msg string
Search string
Data interface{}
}
func (c *Context) Exec() {
if c.T == nil {
log.Println("Exec:", errors.New("template is nil"))
return
}
c.Data.Admin = c.GetAdmin()
c.Data.User = c.GetUser()
c.Data.Login = c.LoggedOn()
err := c.T.Execute(c.Response, c.Data)
if err != nil {
log.Println("Exec:", err)
}
}
func (c *Context) Template(name string) {
c.T = templ[name]
}
func (c *Context) Write(buf []byte) (err error) {
_, err = c.Response.Write(buf)
return
}
func (c *Context) SetHeader(name, value string) {
c.Response.Header().Set(name, value)
}
func (c *Context) Redirect(uri string, code int) {
http.Redirect(c.Response, c.Request, uri, code)
}
func (c *Context) Method() string {
return c.Request.Method
}
func (c *Context) Path() string {
return c.Request.URL.Path
}
func (c *Context) FormSlice(name string) []string {
// does nothing if called twice
c.Request.ParseForm()
return c.Request.PostForm[name]
}
func (c *Context) Form(name string) (ret string) {
ret = c.Request.PostFormValue(name)
return
}
func (c *Context) Var(name string) (ret string) {
ret, _ = mux.Vars(c.Request)[name]
return
}
func (c *Context) InitToken() {
token := c.getToken()
if token == nil || len(token) != keyLen {
s, _ := store.Get(c.Request, cookieName)
s.Values["token"] = genKey(keyLen)
s.Options = store.Options
err := s.Save(c.Request, c.Response)
if err != nil {
log.Println("InitToken: ", err)
}
}
}
// NewToken sets and returns the xsrf token
func (c *Context) NewToken() {
rnd := genKey(keyLen)
c.Data.Token = base64.StdEncoding.EncodeToString(append(rnd, xor(rnd, c.getToken())...))
}
// CheckToken validates the xsrf token
func (c *Context) CheckToken() bool {
token, err := base64.StdEncoding.DecodeString(c.Form("token"))
if err != nil {
log.Println("CheckToken: ", err)
return false
}
if len(token) != authLen {
return false
}
return subtle.ConstantTimeCompare(c.getToken(), xor(token[keyLen:], token[:keyLen])) == 1
}
func (c *Context) LoggedOn() (ok bool) {
s, _ := store.Get(c.Request, cookieName)
_, ok = s.Values["user"]
return
}
func (c *Context) SetUdata(user string, admin bool) {
s, _ := store.Get(c.Request, cookieName)
s.Values["user"] = user
s.Values["admin"] = admin
s.Options = store.Options
if err := s.Save(c.Request, c.Response); err != nil {
log.Println(err)
}
}
func (c *Context) UnsetUdata() {
s, _ := store.Get(c.Request, cookieName)
s.Values = make(map[interface{}]interface{})
s.Options = store.Options
if err := s.Save(c.Request, c.Response); err != nil {
log.Println(err)
}
}
func (c *Context) GetUser() string {
s, _ := store.Get(c.Request, cookieName)
if ret, ok := s.Values["user"].(string); ok {
return ret
}
return ""
}
func (c *Context) GetAdmin() bool {
s, _ := store.Get(c.Request, cookieName)
if ret, ok := s.Values["admin"].(bool); ok {
return ret
}
return false
}
func (c *Context) getToken() []byte {
s, _ := store.Get(c.Request, cookieName)
t, ok := s.Values["token"]
if !ok {
return nil
}
b, ok := t.([]byte)
if !ok {
return nil
}
return b
}
func xor(a, b []byte) (c []byte) {
n := len(a)
if len(b) != n {
return
}
c = make([]byte, n)
for i := 0; i < n; i++ {
c[i] = a[i] ^ b[i]
}
return
}
type ctxHandler func(*Context)

86
dump.go
View file

@ -1,86 +0,0 @@
// Copyright (C) 2017 Marius Schellenberger
package main
import (
"encoding/json"
"fmt"
"os"
"github.com/blevesearch/bleve"
)
type DumpArticle struct {
Title string `json:"title"`
LinkTitle string `json:"link_title"`
MD string `json:"md"`
Created string `json:"created"`
Updated string `json:"updated"`
Public bool `json:"public"`
}
func verboseLog(prefix, title string) {
if verbose {
fmt.Fprintln(os.Stderr, prefix, title)
}
}
func dumpWiki() error {
index, err := bleve.OpenUsing(bleveStore, map[string]interface{}{"read_only": true})
if err != nil {
return err
}
engine = NewEngine(index)
art, err := engine.Get(false, indexName)
if err != nil {
return err
}
defer engine.Close()
var arts []DumpArticle
titles, err := engine.Dump()
if err != nil {
return err
}
for _, title := range titles {
art, err = engine.Get(false, title)
if err != nil {
return err
}
verboseLog("dump:", art.Title)
arts = append(arts, DumpArticle{
Title: art.Title,
LinkTitle: art.LinkTitle,
MD: art.MD,
Created: art.Created,
Updated: art.Updated,
Public: art.Public,
})
}
return json.NewEncoder(os.Stdout).Encode(arts)
}
func restoreWiki() (err error) {
index, err := bleve.Open(bleveStore)
if err != nil {
mapping := bleve.NewIndexMapping()
index, err = bleve.New(bleveStore, mapping)
if err != nil {
return
}
}
engine = NewEngine(index)
defer engine.Close()
var arts []DumpArticle
err = json.NewDecoder(os.Stdin).Decode(&arts)
if err != nil {
return
}
for _, art := range arts {
verboseLog("restore:", art.Title)
err = engine.Restore(art)
if err != nil {
return
}
}
return
}

270
engine.go
View file

@ -1,270 +0,0 @@
// Copyright (C) 2017 Marius Schellenberger
package main
import (
"errors"
"html/template"
"regexp"
"time"
"github.com/blevesearch/bleve"
"github.com/blevesearch/bleve/document"
)
const (
timeFmt = "2006-01-02 15:04:05"
maxAllResult = 1000000
maxSearchResult = 101
)
var titleRe = regexp.MustCompile("[^a-zA-Z0-9+ ]+")
func now() string {
return time.Now().Format(timeFmt)
}
type Article struct {
Title string `json:"title"`
LinkTitle string
Search string `json:"search"`
Index template.HTML `json:"index"`
Text template.HTML `json:"text"`
MD string `json:"md"`
Created string `json:"created"`
Updated string `json:"updated"`
Public bool `json:"public"`
}
type Engine struct {
index bleve.Index
}
func NewEngine(index bleve.Index) *Engine {
return &Engine{index}
}
func (e *Engine) Close() error {
return e.index.Close()
}
func (e *Engine) Restore(dumpArt DumpArticle) error {
art := render(dumpArt.MD)
art.Title = dumpArt.Title
art.Created = dumpArt.Created
art.Updated = dumpArt.Updated
art.Public = dumpArt.Public
return e.index.Index(dumpArt.LinkTitle, art)
}
func (e *Engine) Index(title, text, user string, public bool) (string, error) {
if user == "" {
return "", errors.New("engine: user cannot be empty")
}
title = titleRe.ReplaceAllString(title, "")
newTitle := makeLinkTitle(title)
if err := e.Exists(false, newTitle); err == nil {
return "", errors.New("engine: article '" + title + "' already exists")
}
art := render(text)
art.Title = title
art.Created = user + " " + now()
art.Public = public
err := e.index.Index(newTitle, art)
return newTitle, err
}
func (e *Engine) Exists(public bool, title string) error {
_, err := e.Get(public, title)
if err != nil {
return err
}
return nil
}
func (e *Engine) Delete(title string) error {
if title == indexName {
return errors.New("engine: page '" + indexName + "' cannot be deleted")
}
if err := e.Exists(false, title); err != nil {
return err
}
return e.index.Delete(title)
}
func (e *Engine) Update(title, text, user string, public bool) error {
if user == "" {
return errors.New("engine: user cannot be empty")
}
if title == indexName && !public {
return errors.New("engine: page '" + indexName + "' must stay public")
}
if err := e.Exists(false, title); err != nil {
return err
}
art, err := e.Get(false, title)
if err != nil {
return err
}
rend := render(text)
art.Updated = user + " " + now()
art.Search = rend.Search
art.Index = rend.Index
art.Text = rend.Text
art.MD = rend.MD
art.Public = public
return e.index.Index(title, art)
}
func (e *Engine) Get(public bool, title string) (Article, error) {
var art Article
doc, err := e.index.Document(title)
if err != nil {
return art, err
}
if doc == nil || doc.Fields == nil {
return art, errors.New("engine: article '" + title + "' does not esist")
}
for _, field := range doc.Fields {
switch f := field.(type) {
case *document.TextField:
switch field.Name() {
case "title":
art.Title = string(f.Value())
case "index":
art.Index = template.HTML(string(f.Value()))
case "text":
art.Text = template.HTML(string(f.Value()))
case "md":
art.MD = string(f.Value())
case "created":
art.Created = string(f.Value())
case "updated":
art.Updated = string(f.Value())
}
case *document.BooleanField:
if field.Name() == "public" {
p, err := f.Boolean()
if err != nil {
return art, err
}
art.Public = p
}
}
}
art.LinkTitle = title
if public && !art.Public {
err = errors.New("engine: article '" + title + "' does not esist")
}
return art, err
}
func (e *Engine) Search(public bool, search string) (string, error) {
var (
res *bleve.SearchResult
req *bleve.SearchRequest
err error
)
if public {
req = bleve.NewSearchRequest(bleve.NewConjunctionQuery([]bleve.Query{
bleve.NewBoolFieldQuery(true).SetField("public"),
bleve.NewQueryStringQuery("title:" + search + " search:" + search),
}))
} else {
req = bleve.NewSearchRequest(bleve.NewQueryStringQuery("title:" + search + " search:" + search))
}
req.Highlight = bleve.NewHighlightWithStyle("html")
req.Size = maxSearchResult
res, err = e.index.Search(req)
if err != nil {
return "", err
}
if res.Total == 0 {
return "<b>No search results</b>", nil
} else if res.Total == maxSearchResult {
return "", errors.New("engine: too many search results")
}
html := ""
for _, hit := range res.Hits {
if hit == nil {
continue
}
art, err := e.Get(false, hit.ID)
if err != nil {
continue
}
html += `<a href="/wiki/` + hit.ID + `"><b>` + art.Title + "</b></a>"
for fragField, frags := range hit.Fragments {
if fragField == "title" || fragField == "search" {
html += `<pre style="white-space: pre-wrap">`
for _, f := range frags {
html += f
}
html += "</pre>"
break
}
}
html += "<br>"
}
return html, nil
}
type Title struct {
Title string
LinkTitle string
}
func (e *Engine) GetAll(public bool) (t []Title, err error) {
var (
res *bleve.SearchResult
req *bleve.SearchRequest
)
if public {
req = bleve.NewSearchRequest(bleve.NewBoolFieldQuery(true).SetField("public"))
} else {
req = bleve.NewSearchRequest(bleve.NewMatchAllQuery())
}
req.Size = maxAllResult
res, err = e.index.Search(req)
if err != nil {
return
}
for _, v := range res.Hits {
if v == nil {
continue
}
if v.ID == indexName {
continue
}
doc, err := e.index.Document(v.ID)
if err != nil || doc == nil || doc.Fields == nil {
continue
}
for _, field := range doc.Fields {
if field.Name() == "title" {
f, ok := field.(*document.TextField)
if ok {
t = append(t, Title{string(f.Value()), v.ID})
break
}
}
}
}
return
}
func (e *Engine) Dump() (titles []string, err error) {
req := bleve.NewSearchRequest(bleve.NewMatchAllQuery())
req.Size = maxAllResult
res, err := e.index.Search(req)
if err != nil {
return
}
for _, v := range res.Hits {
if v == nil {
continue
}
titles = append(titles, v.ID)
}
return
}

40
go.mod Normal file
View file

@ -0,0 +1,40 @@
module git.giftfish.de/ston1th/gowiki
go 1.24.0
require (
git.giftfish.de/ston1th/godrop/v2 v2.2.0
git.giftfish.de/ston1th/jwt/v3 v3.3.0
github.com/blevesearch/bleve v1.0.14
github.com/gorilla/mux v1.8.1
github.com/pquerna/otp v1.5.0
github.com/russross/blackfriday v1.6.0
github.com/urfave/cli v1.22.17
go.etcd.io/bbolt v1.4.3
golang.org/x/crypto v0.43.0
)
require (
github.com/RoaringBitmap/roaring v1.9.4 // indirect
github.com/bits-and-blooms/bitset v1.24.1 // indirect
github.com/blevesearch/go-porterstemmer v1.0.3 // indirect
github.com/blevesearch/mmap-go v1.0.4 // indirect
github.com/blevesearch/segment v0.9.1 // indirect
github.com/blevesearch/snowballstem v0.9.0 // indirect
github.com/blevesearch/zap/v11 v11.0.14 // indirect
github.com/blevesearch/zap/v12 v12.0.14 // indirect
github.com/blevesearch/zap/v13 v13.0.6 // indirect
github.com/blevesearch/zap/v14 v14.0.5 // indirect
github.com/blevesearch/zap/v15 v15.0.3 // indirect
github.com/boombuler/barcode v1.1.0 // indirect
github.com/couchbase/vellum v1.0.2 // indirect
github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect
github.com/golang/protobuf v1.5.4 // indirect
github.com/golang/snappy v1.0.0 // indirect
github.com/mschoch/smat v0.2.0 // indirect
github.com/russross/blackfriday/v2 v2.1.0 // indirect
github.com/steveyen/gtreap v0.1.0 // indirect
github.com/willf/bitset v1.1.11 // indirect
golang.org/x/sys v0.37.0 // indirect
google.golang.org/protobuf v1.36.10 // indirect
)

171
go.sum Normal file
View file

@ -0,0 +1,171 @@
git.giftfish.de/ston1th/godrop/v2 v2.2.0 h1:jdcPJeotiuug3tC4zXvwnauen1Y7kfF9CbFKQbGttYU=
git.giftfish.de/ston1th/godrop/v2 v2.2.0/go.mod h1:gDAD0cWURkN0cMAjjSKB6a6jqIXvOrY8Th25HdoJAyg=
git.giftfish.de/ston1th/jwt/v3 v3.3.0 h1:lSBVbk1D+KmLHBlxhS7snK33+U975cNbx/Z4Md6S+0A=
git.giftfish.de/ston1th/jwt/v3 v3.3.0/go.mod h1:jDALrW7OKeIaqYuMxs3GWyPRnQZmk/R+Lu9p/Em3Ziw=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/RoaringBitmap/roaring v0.4.23/go.mod h1:D0gp8kJQgE1A4LQ5wFLggQEyvDi06Mq5mKs52e1TwOo=
github.com/RoaringBitmap/roaring v1.9.4 h1:yhEIoH4YezLYT04s1nHehNO64EKFTop/wBhxv2QzDdQ=
github.com/RoaringBitmap/roaring v1.9.4/go.mod h1:6AXUsoIEzDTFFQCe1RbGA6uFONMhvejWj5rqITANK90=
github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8=
github.com/bits-and-blooms/bitset v1.12.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8=
github.com/bits-and-blooms/bitset v1.24.1 h1:hqnfFbjjk3pxGa5E9Ho3hjoU7odtUuNmJ9Ao+Bo8s1c=
github.com/bits-and-blooms/bitset v1.24.1/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8=
github.com/blevesearch/bleve v1.0.14 h1:Q8r+fHTt35jtGXJUM0ULwM3Tzg+MRfyai4ZkWDy2xO4=
github.com/blevesearch/bleve v1.0.14/go.mod h1:e/LJTr+E7EaoVdkQZTfoz7dt4KoDNvDbLb8MSKuNTLQ=
github.com/blevesearch/blevex v1.0.0 h1:pnilj2Qi3YSEGdWgLj1Pn9Io7ukfXPoQcpAI1Bv8n/o=
github.com/blevesearch/blevex v1.0.0/go.mod h1:2rNVqoG2BZI8t1/P1awgTKnGlx5MP9ZbtEciQaNhswc=
github.com/blevesearch/cld2 v0.0.0-20200327141045-8b5f551d37f5/go.mod h1:PN0QNTLs9+j1bKy3d/GB/59wsNBFC4sWLWG3k69lWbc=
github.com/blevesearch/go-porterstemmer v1.0.3 h1:GtmsqID0aZdCSNiY8SkuPJ12pD4jI+DdXTAn4YRcHCo=
github.com/blevesearch/go-porterstemmer v1.0.3/go.mod h1:angGc5Ht+k2xhJdZi511LtmxuEf0OVpvUUNrwmM1P7M=
github.com/blevesearch/mmap-go v1.0.2/go.mod h1:ol2qBqYaOUsGdm7aRMRrYGgPvnwLe6Y+7LMvAB5IbSA=
github.com/blevesearch/mmap-go v1.0.4 h1:OVhDhT5B/M1HNPpYPBKIEJaD0F3Si+CrEKULGCDPWmc=
github.com/blevesearch/mmap-go v1.0.4/go.mod h1:EWmEAOmdAS9z/pi/+Toxu99DnsbhG1TIxUoRmJw/pSs=
github.com/blevesearch/segment v0.9.0/go.mod h1:9PfHYUdQCgHktBgvtUOF4x+pc4/l8rdH0u5spnW85UQ=
github.com/blevesearch/segment v0.9.1 h1:+dThDy+Lvgj5JMxhmOVlgFfkUtZV2kw49xax4+jTfSU=
github.com/blevesearch/segment v0.9.1/go.mod h1:zN21iLm7+GnBHWTao9I+Au/7MBiL8pPFtJBJTsk6kQw=
github.com/blevesearch/snowballstem v0.9.0 h1:lMQ189YspGP6sXvZQ4WZ+MLawfV8wOmPoD/iWeNXm8s=
github.com/blevesearch/snowballstem v0.9.0/go.mod h1:PivSj3JMc8WuaFkTSRDW2SlrulNWPl4ABg1tC/hlgLs=
github.com/blevesearch/zap/v11 v11.0.14 h1:IrDAvtlzDylh6H2QCmS0OGcN9Hpf6mISJlfKjcwJs7k=
github.com/blevesearch/zap/v11 v11.0.14/go.mod h1:MUEZh6VHGXv1PKx3WnCbdP404LGG2IZVa/L66pyFwnY=
github.com/blevesearch/zap/v12 v12.0.14 h1:2o9iRtl1xaRjsJ1xcqTyLX414qPAwykHNV7wNVmbp3w=
github.com/blevesearch/zap/v12 v12.0.14/go.mod h1:rOnuZOiMKPQj18AEKEHJxuI14236tTQ1ZJz4PAnWlUg=
github.com/blevesearch/zap/v13 v13.0.6 h1:r+VNSVImi9cBhTNNR+Kfl5uiGy8kIbb0JMz/h8r6+O4=
github.com/blevesearch/zap/v13 v13.0.6/go.mod h1:L89gsjdRKGyGrRN6nCpIScCvvkyxvmeDCwZRcjjPCrw=
github.com/blevesearch/zap/v14 v14.0.5 h1:NdcT+81Nvmp2zL+NhwSvGSLh7xNgGL8QRVZ67njR0NU=
github.com/blevesearch/zap/v14 v14.0.5/go.mod h1:bWe8S7tRrSBTIaZ6cLRbgNH4TUDaC9LZSpRGs85AsGY=
github.com/blevesearch/zap/v15 v15.0.3 h1:Ylj8Oe+mo0P25tr9iLPp33lN6d4qcztGjaIsP51UxaY=
github.com/blevesearch/zap/v15 v15.0.3/go.mod h1:iuwQrImsh1WjWJ0Ue2kBqY83a0rFtJTqfa9fp1rbVVU=
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
github.com/boombuler/barcode v1.1.0 h1:ChaYjBR63fr4LFyGn8E8nt7dBSt3MiU3zMOZqFvVkHo=
github.com/boombuler/barcode v1.1.0/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
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/couchbase/ghistogram v0.1.0/go.mod h1:s1Jhy76zqfEecpNWJfWUiKZookAFaiGOEoyzgHt9i7k=
github.com/couchbase/moss v0.1.0/go.mod h1:9MaHIaRuy9pvLPUJxB8sh8OrLfyDczECVL37grCIubs=
github.com/couchbase/vellum v1.0.2 h1:BrbP0NKiyDdndMPec8Jjhy0U47CZ0Lgx3xUC2r9rZqw=
github.com/couchbase/vellum v1.0.2/go.mod h1:FcwrEivFpNi24R3jLOs3n+fs5RnuQnQqCLBJ1uAg1W4=
github.com/cpuguy83/go-md2man v1.0.10/go.mod h1:SmD6nW6nTyfqj6ABTjUi3V3JVMnlJmwcJI5acqYI6dE=
github.com/cpuguy83/go-md2man/v2 v2.0.7 h1:zbFlGlXEAKlwXpmvle3d8Oe3YnkKIK4xSRTd3sHPnBo=
github.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/cznic/b v0.0.0-20181122101859-a26611c4d92d h1:SwD98825d6bdB+pEuTxWOXiSjBrHdOl/UVp75eI7JT8=
github.com/cznic/b v0.0.0-20181122101859-a26611c4d92d/go.mod h1:URriBxXwVq5ijiJ12C7iIZqlA69nTlI+LgI6/pwftG8=
github.com/cznic/mathutil v0.0.0-20181122101859-297441e03548/go.mod h1:e6NPNENfs9mPDVNRekM7lKScauxd5kXTr1Mfyig6TDM=
github.com/cznic/strutil v0.0.0-20181122101858-275e90344537/go.mod h1:AHHPPPXTw0h6pVabbcbyGRK1DckRn7r/STdZEeIDzZc=
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/facebookgo/ensure v0.0.0-20200202191622-63f1cf65ac4c/go.mod h1:Yg+htXGokKKdzcwhuNDwVvN+uBxDGXJ7G/VN1d8fa64=
github.com/facebookgo/stack v0.0.0-20160209184415-751773369052/go.mod h1:UbMTZqLaRiH3MsBH8va0n7s1pQYcu3uTb8G4tygF4Zg=
github.com/facebookgo/subset v0.0.0-20200203212716-c811ad88dec4/go.mod h1:5tD+neXqOorC30/tWg0LCSkrqj/AR6gu8yY8/fpw1q0=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/glycerine/go-unsnap-stream v0.0.0-20181221182339-f9677308dec2/go.mod h1:/20jfyN9Y5QPEAprSgKAUr+glWDY39ZiUEAYOEv5dsE=
github.com/glycerine/goconvey v0.0.0-20190410193231-58a59202ab31/go.mod h1:Ogl1Tioa0aV7gstGFO7KhffUsb9M4ydbEbbxpcEDc24=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/golang/snappy v0.0.0-20180518054509-2e65f85255db/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs=
github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/gopherjs/gopherjs v0.0.0-20190910122728-9d188e94fb99/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
github.com/ikawaha/kagome.ipadic v1.1.2/go.mod h1:DPSBbU0czaJhAb/5uKQZHMc9MTVRpDugJfX+HddPHHg=
github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=
github.com/jmhodges/levigo v1.0.0 h1:q5EC36kV79HWeTBWsod3mG11EgStG3qArTKcvlksN1U=
github.com/jmhodges/levigo v1.0.0/go.mod h1:Q6Qx+uH3RAqyK4rFQroq9RL7mdkABMcfhEI+nNuzMJQ=
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
github.com/kljensen/snowball v0.6.0/go.mod h1:27N7E8fVU5H68RlUmnWwZCfxgt4POBJfENGMvNRhldw=
github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ=
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/mschoch/smat v0.0.0-20160514031455-90eadee771ae/go.mod h1:qAyveg+e4CE+eKJXWVjKXM4ck2QobLqTDytGJbLLhJg=
github.com/mschoch/smat v0.2.0 h1:8imxQsjDm8yFEAVBe7azKmKSgzSkZXDuKkSq9374khM=
github.com/mschoch/smat v0.2.0/go.mod h1:kc9mz7DoBKqDyiRL7VZN8KvXQMWeTaVnttLRXOlotKw=
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic=
github.com/philhofer/fwd v1.0.0/go.mod h1:gk3iGcWd9+svBvR0sR+KPcfE+RNWozjowpeBVG3ZVNU=
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/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs=
github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
github.com/rcrowley/go-metrics v0.0.0-20190826022208-cac0b30c2563/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4=
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g=
github.com/russross/blackfriday v1.6.0 h1:KqfZb0pUVN2lYqZUYRddxF4OR8ZMURnJIG5Y3VRLtww=
github.com/russross/blackfriday v1.6.0/go.mod h1:ti0ldHuxg49ri4ksnFxlkCfN+hvslNlmVHqNRXXJNAY=
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
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/steveyen/gtreap v0.1.0 h1:CjhzTa274PyJLJuMZwIzCO1PfC00oRa8d1Kc78bFXJM=
github.com/steveyen/gtreap v0.1.0/go.mod h1:kl/5J7XbrOmlIbYIXdRHDDE5QxHqpk0cmkT7Z4dM9/Y=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/syndtr/goleveldb v1.0.0 h1:fBdIW9lB4Iz0n9khmH8w27SJ3QEJ7+IgjPEwGSZiFdE=
github.com/syndtr/goleveldb v1.0.0/go.mod h1:ZVVdQEZoIme9iO1Ch2Jdy24qqXrMMOU6lpPAyBWyWuQ=
github.com/tebeka/snowball v0.4.2/go.mod h1:4IfL14h1lvwZcp1sfXuuc7/7yCsvVffTWxWxCLfFpYg=
github.com/tecbot/gorocksdb v0.0.0-20191217155057-f0fad39f321c h1:g+WoO5jjkqGAzHWCjJB1zZfXPIAaDpzXIEJ0eS6B5Ok=
github.com/tecbot/gorocksdb v0.0.0-20191217155057-f0fad39f321c/go.mod h1:ahpPrc7HpcfEWDQRZEmnXMzHY03mLDYMCxeDzy46i+8=
github.com/tinylib/msgp v1.1.0/go.mod h1:+d+yLhGm8mzTaHzB+wgMYrodPfmZrzkirds8fDWklFE=
github.com/ugorji/go/codec v0.0.0-20181204163529-d75b2dcb6bc8/go.mod h1:VFNgLljTbGfSG7qAOspJ7OScBnGdDN/yBr0sguwnwf0=
github.com/urfave/cli v1.22.17 h1:SYzXoiPfQjHBbkYxbew5prZHS1TOLT3ierW8SYLqtVQ=
github.com/urfave/cli v1.22.17/go.mod h1:b0ht0aqgH/6pBYzzxURyrM4xXNgsoT/n2ZzwQiEhNVo=
github.com/willf/bitset v1.1.10/go.mod h1:RjeCKbqT1RxIR/KWY6phxZiaY1IyutSBfGjNPySAYV4=
github.com/willf/bitset v1.1.11 h1:N7Z7E9UvjW+sGsEl7k/SJrvY2reP1A07MrGuCjIOjRE=
github.com/willf/bitset v1.1.11/go.mod h1:83CECat5yLh5zVOf4P1ErAgKA5UDvKtgyUABdr3+MjI=
github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q=
go.etcd.io/bbolt v1.3.5/go.mod h1:G5EMThwa9y8QZGBClrRx5EY+Yw9kAhnjy3bSjsnlVTQ=
go.etcd.io/bbolt v1.4.3 h1:dEadXpI6G79deX5prL3QRNP6JB8UxVkqo4UPnHaNXJo=
go.etcd.io/bbolt v1.4.3/go.mod h1:tKQlpPaYCVFctUIgFKFnAlvbmB3tpy1vkTnDWohtc0E=
golang.org/x/crypto v0.0.0-20181203042331-505ab145d0a9/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04=
golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0=
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20181205085412-a5c9d58dba9a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20181221143128-b4a75ba826a6/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190813064441-fde4db37ae7a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
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.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View file

@ -1,515 +0,0 @@
// Copyright (C) 2017 Marius Schellenberger
package main
import (
"html/template"
"net"
"net/http"
"regexp"
"time"
)
func webserver(l net.Listener) {
cookieStore()
loadTemplates()
srv := &http.Server{
Handler: buildRoutes(),
}
log.Fatal("webserver: ", srv.Serve(l))
}
type notFoundHandler struct{}
func (notFoundHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
f := func(ctx *Context) {
ctx.Template("notFoundHandler")
ctx.Data = webData{
Title: "404",
}
ctx.Exec()
}
w.WriteHeader(http.StatusNotFound)
f(newContext(w, r))
}
func contextWrapper(h ctxHandler) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
h(newContext(w, r))
}
}
func adminAuthHandler(h ctxHandler) ctxHandler {
return func(ctx *Context) {
if ctx.GetAdmin() {
h(ctx)
return
}
ctx.Redirect("/404", 302)
}
}
func userAuthHandler(h ctxHandler) ctxHandler {
return func(ctx *Context) {
if ctx.GetAdmin() {
h(ctx)
return
}
if ctx.GetUser() == ctx.Var("user") {
h(ctx)
return
}
ctx.Redirect("/404", 302)
}
}
func staticHandler(ctx *Context) {
var err error
ctx.SetHeader("Content-Type", "text/css; charset=utf-8")
ctx.SetHeader("Expires", time.Now().Add(time.Hour*12).Format(http.TimeFormat))
switch ctx.Path() {
case "/bootstrap.css":
err = ctx.Write(res["bootstrap.css"])
case "/custom.css":
err = ctx.Write(res["custom.css"])
}
if err != nil {
log.Println("static:", err)
}
}
func indexHandler(ctx *Context) {
ctx.InitToken()
if !ctx.LoggedOn() {
ctx.UnsetUdata()
}
ctx.Redirect("/wiki/Index", 302)
}
func loginHandler(ctx *Context) {
ctx.InitToken()
if ctx.LoggedOn() {
ctx.Redirect("/wiki/Index", 302)
return
}
ctx.Template("loginHandler")
ctx.Data = webData{
Title: "Login",
BodyTitle: "Login",
}
switch ctx.Method() {
case "GET":
ctx.NewToken()
ctx.Exec()
case "POST":
user := ctx.Form("user")
password := ctx.Form("password")
submit := ctx.Form("submit")
if !ctx.CheckToken() {
ctx.NewToken()
ctx.Data.Msg = "wrong csrf token"
ctx.Exec()
return
}
if user == "" || password == "" || submit != "Login" {
ctx.NewToken()
ctx.Data.Msg = "wrong inputs"
ctx.Exec()
return
}
err := db.Login(user, password)
if err != nil {
ctx.NewToken()
ctx.Data.Msg = err.Error()
ctx.Exec()
return
}
ctx.SetUdata(user, db.UserIsAdmin(user))
ctx.Redirect("/wiki/Index", 302)
}
}
func searchHandler(ctx *Context) {
ctx.InitToken()
ctx.Template("searchHandler")
ctx.Data = webData{
Title: "Search",
BodyTitle: "Search",
}
switch ctx.Method() {
case "GET":
ctx.NewToken()
ctx.Exec()
case "POST":
search := ctx.Form("search")
submit := ctx.Form("submit")
if !ctx.CheckToken() {
ctx.NewToken()
ctx.Data.Msg = "wrong csrf token"
ctx.Exec()
return
}
if search == "" || submit != "Search" {
ctx.NewToken()
ctx.Data.Msg = "wrong inputs"
ctx.Exec()
return
}
res, err := engine.Search(!ctx.LoggedOn(), search)
if err != nil {
ctx.NewToken()
ctx.Data.Msg = err.Error()
ctx.Exec()
return
}
ctx.Data.Search = search
ctx.Data.Data = template.HTML(res)
ctx.NewToken()
ctx.Exec()
}
}
func allHandler(ctx *Context) {
ctx.Template("allHandler")
ctx.Data = webData{
Title: "All Articles",
BodyTitle: "All Articles",
Admin: ctx.GetAdmin(),
}
all, err := engine.GetAll(!ctx.LoggedOn())
if err != nil {
ctx.Data.Msg = err.Error()
}
ctx.Data.Data = all
ctx.Exec()
}
func wikiNewHandler(ctx *Context) {
ctx.Template("wikiNewHandler")
ctx.Data = webData{
Title: "New Article",
BodyTitle: "New Article",
Admin: ctx.GetAdmin(),
}
switch ctx.Method() {
case "GET":
ctx.NewToken()
ctx.Exec()
case "POST":
title := ctx.Form("title")
text := ctx.Form("text")
public := ctx.Form("public")
pub := false
if public == "0" {
pub = true
}
submit := ctx.Form("submit")
art := Article{
Title: title,
MD: text,
}
ctx.Data.Data = art
if !ctx.CheckToken() {
ctx.NewToken()
ctx.Data.Msg = "wrong csrf token"
ctx.Exec()
return
}
ctx.NewToken()
m, err := regexp.MatchString("[^a-zA-Z0-9 _-]", title)
if err != nil {
ctx.Data.Msg = err.Error()
ctx.Exec()
return
}
if m {
ctx.Data.Msg = "title contains forbidden characters, allowed are [a-zA-Z0-9 _-]"
ctx.Exec()
return
}
err = engine.Exists(!ctx.LoggedOn(), title)
if err == nil {
ctx.Data.Msg = "article alerady exsist"
ctx.Exec()
return
}
if submit != "Submit" {
ctx.Data.Msg = "wrong inputs"
ctx.Exec()
return
}
newTitle, err := engine.Index(title, text, ctx.GetUser(), pub)
if err != nil {
ctx.Data.Msg = err.Error()
ctx.Exec()
return
}
ctx.Redirect("/wiki/"+newTitle, 302)
}
}
func wikiHandler(ctx *Context) {
if ctx.T == nil {
ctx.Template("wikiHandler")
}
ctx.Data = webData{
Admin: ctx.GetAdmin(),
}
ctx.NewToken()
title := ctx.Var("title")
art, err := engine.Get(!ctx.LoggedOn(), title)
if err != nil {
ctx.Redirect("/404", 302)
return
}
art.LinkTitle = title
ctx.Data.Data = art
ctx.Data.Title = art.Title
ctx.Data.BodyTitle = art.Title
ctx.Exec()
}
func wikiMDHandler(f ctxHandler) ctxHandler {
return func(ctx *Context) {
ctx.Template("wikiMDHandler")
f(ctx)
}
}
func wikiEditHandler(ctx *Context) {
ctx.Template("wikiEditHandler")
ctx.Data = webData{}
title := ctx.Var("title")
switch ctx.Method() {
case "GET":
ctx.NewToken()
art, err := engine.Get(!ctx.LoggedOn(), title)
if err != nil {
ctx.Redirect("/404", 302)
return
}
art.LinkTitle = title
ctx.Data.Data = art
ctx.Data.Title = art.Title
ctx.Data.BodyTitle = art.Title
ctx.Exec()
case "POST":
ftitle := ctx.Form("title")
text := ctx.Form("text")
public := ctx.Form("public")
pub := false
if public == "0" {
pub = true
}
submit := ctx.Form("submit")
ctx.Data.Data = Article{
Title: ftitle,
LinkTitle: title,
MD: text,
Public: pub,
}
ctx.Data.Title = ftitle
ctx.Data.BodyTitle = ftitle
if !ctx.CheckToken() {
ctx.NewToken()
ctx.Data.Msg = "wrong csrf token"
ctx.Exec()
return
}
ctx.NewToken()
if submit != "Submit" {
ctx.Data.Msg = "wrong inputs"
ctx.Exec()
return
}
err := engine.Update(title, text, ctx.GetUser(), pub)
if err != nil {
ctx.Data.Msg = err.Error()
ctx.Exec()
return
}
ctx.Redirect("/wiki/"+title, 302)
}
}
func wikiDelHandler(ctx *Context) {
title := ctx.Var("title")
submit := ctx.Form("submit")
if !ctx.CheckToken() {
ctx.Redirect("/wiki/"+title, 302)
return
}
if submit != "Delete" {
ctx.Redirect("/wiki/"+title, 302)
return
}
if err := engine.Delete(title); err != nil {
log.Println("delete:", err)
ctx.Redirect("/wiki/"+title, 302)
}
ctx.Redirect("/wiki/Index", 302)
}
func usersHandler(ctx *Context) {
if !ctx.LoggedOn() {
ctx.Redirect("/404", 302)
return
}
ctx.Template("usersHandler")
ctx.Data = webData{
Title: "Users",
BodyTitle: "Users",
User: ctx.GetUser(),
}
ctx.NewToken()
us, err := db.GetUsers()
if err != nil {
ctx.Data.Msg = err.Error()
}
ctx.Data.Data = us
ctx.Exec()
}
func userNewHandler(ctx *Context) {
ctx.Template("userNewHandler")
ctx.Data = webData{
Title: "New User",
BodyTitle: "New User",
}
switch ctx.Method() {
case "GET":
ctx.NewToken()
ctx.Exec()
case "POST":
user := ctx.Form("user")
password := ctx.Form("password")
repeat := ctx.Form("repeat")
admin := ctx.Form("admin")
adm := false
if admin == "0" {
adm = true
}
submit := ctx.Form("submit")
if !ctx.CheckToken() {
ctx.NewToken()
ctx.Data.Msg = "wrong csrf token"
ctx.Exec()
return
}
if user == "" || password == "" || password != repeat || submit != "Submit" {
ctx.NewToken()
ctx.Data.Msg = "wrong inputs"
ctx.Exec()
return
}
log.Println(user, password, adm)
if err := db.CreateUser(user, password, adm); err != nil {
ctx.NewToken()
ctx.Data.Msg = err.Error()
ctx.Exec()
return
}
ctx.Redirect("/users", 302)
}
}
func userEditHandler(ctx *Context) {
ctx.Template("userEditHandler")
ctx.Data = webData{
Title: "Edit User",
BodyTitle: "Edit User",
}
switch ctx.Method() {
case "GET":
u, err := db.GetUserWithoutPassword(ctx.Var("user"))
if err != nil {
ctx.Data.Msg = err.Error()
ctx.Exec()
return
}
ctx.Data.Data = u
ctx.NewToken()
ctx.Exec()
case "POST":
user := ctx.Var("user")
password := ctx.Form("password")
repeat := ctx.Form("repeat")
admin := ctx.Form("admin")
adm := false
if admin == "0" {
adm = true
}
submit := ctx.Form("submit")
if !ctx.CheckToken() {
ctx.NewToken()
ctx.Data.Msg = "wrong csrf token"
ctx.Exec()
return
}
if user == "" || password != repeat || submit != "Submit" {
ctx.NewToken()
ctx.Data.Msg = "wrong inputs"
ctx.Exec()
return
}
if ctx.GetAdmin() {
if err := db.AdminUpdateUser(user, password, adm); err != nil {
ctx.NewToken()
ctx.Data.Msg = err.Error()
ctx.Exec()
return
}
} else {
if user == ctx.GetUser() {
if err := db.UpdateUserPassword(user, password); err != nil {
ctx.NewToken()
ctx.Data.Msg = err.Error()
ctx.Exec()
return
}
ctx.Redirect("/wiki/Index", 302)
return
}
}
ctx.Redirect("/users", 302)
}
}
func userUnlockHandler(ctx *Context) {
user := ctx.Var("user")
if err := db.UnlockUser(user); err != nil {
log.Println("unlock:", err)
}
ctx.Redirect("/users", 302)
}
func userDelHandler(ctx *Context) {
ctx.Template("userDelHandler")
ctx.Data = webData{
Title: "Delete User",
BodyTitle: "Delete User",
}
switch ctx.Method() {
case "GET":
user, err := db.GetUserWithoutPassword(ctx.Var("user"))
if err != nil {
ctx.Data.Msg = err.Error()
ctx.Exec()
return
}
ctx.Data.Data = user
ctx.NewToken()
ctx.Exec()
case "POST":
if err := db.DeleteUser(ctx.Var("user")); err != nil {
log.Println("delete:", err)
}
ctx.Redirect("/users", 302)
}
}
func logoutHandler(ctx *Context) {
ctx.UnsetUdata()
ctx.Redirect("/wiki/Index", 302)
}

View file

@ -1,23 +0,0 @@
// Copyright (C) 2017 Marius Schellenberger
package main
import (
logger "log"
"os"
)
var log *logger.Logger
func newLogger(file string) {
if file == "" {
log = logger.New(os.Stdout, "", logger.LstdFlags)
return
}
f, err := os.OpenFile(file, os.O_RDWR|os.O_CREATE|os.O_APPEND, 0644)
if err != nil {
logger.Fatal(err)
}
logger.SetOutput(f)
log = logger.New(f, "", logger.LstdFlags)
}

157
main.go
View file

@ -1,157 +0,0 @@
// Copyright (C) 2017 Marius Schellenberger
//go:generate ./templates.sh
package main
import (
"errors"
"flag"
logger "log"
"net"
"os"
"os/signal"
"syscall"
"time"
"git.giftfish.de/ston1th/godrop"
"github.com/blevesearch/bleve"
)
const (
bleveStore = "."
defDataDir = "/var/lib/gowiki"
defListen = "127.0.0.1:8090"
defLog = "gowiki.log"
defUser = "admin"
defPassword = "gowiki"
defRunUser = "wiki"
defRunGroup = "wiki"
welcome = `# Welcome to GoWiki
This is the [Index](/wiki/Index) page.
You can customize it how you like.`
indexName = "Index"
)
var (
datadir string
listen string
secureCookie bool
user string
group string
logfile string
engine *Engine
db *BoltStore
dump bool
restore bool
verbose bool
)
func main() {
flag.StringVar(&datadir, "d", defDataDir, "data directory")
flag.StringVar(&user, "u", defRunUser, "drop privileges to user")
flag.StringVar(&group, "g", defRunGroup, "drop privileges to group")
flag.StringVar(&listen, "l", defListen, "listening <address>:<port>")
flag.StringVar(&logfile, "log", defLog, "log file")
flag.BoolVar(&secureCookie, "s", false, "enable secure cookie")
flag.BoolVar(&dump, "dump", false, "create a json dump of all articles in the data directory (dump is written to stdout)")
flag.BoolVar(&restore, "restore", false, "restore a json dump to the data directory (dump is read from stdin)")
flag.BoolVar(&verbose, "v", false, "enable verbose dump/restore mode")
flag.Parse()
err := os.Chdir(datadir)
if err != nil {
logger.Fatal("main: ", err)
}
if dump {
err = dumpWiki()
if err != nil {
logger.Fatal("dump: ", err)
}
return
}
if restore {
err = restoreWiki()
if err != nil {
logger.Fatal("restore: ", err)
}
return
}
cfg := godrop.Config{
User: user,
Group: group,
}
err = godrop.Drop(cfg, func() (net.Listener, error) {
return net.Listen("tcp", listen)
})
if err != nil {
logger.Fatal("main: ", err)
}
newLogger(logfile)
l, err := godrop.GetListener()
if err != nil {
log.Fatal("main: ", err)
}
db, err = NewBoltStore("users")
if err != nil {
log.Fatal("main: ", err)
}
if !db.UserExists(defUser) {
err = db.CreateUser(defUser, defPassword, true)
if err != nil {
log.Fatal("main: ", err)
}
}
if _, err := os.Stat(bleveStore); os.IsNotExist(err) {
mapping := bleve.NewIndexMapping()
index, err := bleve.New(bleveStore, mapping)
if err != nil {
log.Fatal("main: ", err)
}
engine = NewEngine(index)
_, err = engine.Index(indexName, welcome, defUser, true)
if err != nil {
log.Fatal("main: ", err)
}
} else {
index, err := bleve.Open(bleveStore)
if err != nil {
log.Fatal("main: ", err)
}
engine = NewEngine(index)
}
go webserver(l)
log.Println("gowiki started")
sigs := make(chan os.Signal)
signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM)
sig := <-sigs
log.Println("caught " + sig.String())
go func() {
time.Sleep(time.Second * 20)
log.Fatal(errors.New("stop timed out: killing"))
}()
err = l.Close()
if err != nil {
log.Println(err)
}
err = db.Close()
if err != nil {
log.Println(err)
}
err = engine.Close()
if err != nil {
log.Println(err)
}
}

54
pkg/cache/cache.go vendored Normal file
View file

@ -0,0 +1,54 @@
// Copyright (C) 2021 Marius Schellenberger
package cache
import (
"git.giftfish.de/ston1th/gowiki/pkg/core"
"sync"
"time"
)
type CachedPage struct {
Page *core.Page
CacheTime time.Time
}
type Cache struct {
mu sync.RWMutex
Cache map[string]*CachedPage
}
func NewCache() *Cache {
return &Cache{Cache: make(map[string]*CachedPage)}
}
func (c *Cache) Add(title string, p *core.Page) {
now := time.Now()
c.mu.Lock()
defer c.mu.Unlock()
cp, ok := c.Cache[title]
if ok && now.After(cp.CacheTime) {
cp.Page = p
cp.CacheTime = now
c.Cache[title] = cp
return
}
c.Cache[title] = &CachedPage{p, now}
}
func (c *Cache) Get(title string) (p *core.Page) {
c.mu.RLock()
defer c.mu.RUnlock()
if cp, ok := c.Cache[title]; ok {
p = new(core.Page)
*p = *cp.Page
return
}
return
}
func (c *Cache) Delete(title string) {
c.mu.Lock()
delete(c.Cache, title)
c.mu.Unlock()
}

50
pkg/cache/cache_test.go vendored Normal file
View file

@ -0,0 +1,50 @@
// Copyright (C) 2021 Marius Schellenberger
package cache
import (
"git.giftfish.de/ston1th/gowiki/pkg/core"
"testing"
)
func TestCache(t *testing.T) {
title := "test/page"
pages := []*core.Page{
&core.Page{
Title: title,
Markdown: "page v1",
},
&core.Page{
Title: title,
Markdown: "page v2",
},
}
c := NewCache()
t.Run("Add", func(t *testing.T) {
c.Add(title, pages[0])
})
t.Run("Get", func(t *testing.T) {
p := c.Get(title)
if p.Markdown != pages[0].Markdown {
t.Fail()
}
})
t.Run("Add", func(t *testing.T) {
c.Add(title, pages[1])
})
t.Run("Get", func(t *testing.T) {
p := c.Get(title)
if p.Markdown == pages[0].Markdown {
t.Fail()
}
})
t.Run("Delete", func(t *testing.T) {
c.Delete(title)
})
t.Run("Get", func(t *testing.T) {
p := c.Get(title)
if p != nil {
t.Fail()
}
})
}

281
pkg/cmd/cmd.go Normal file
View file

@ -0,0 +1,281 @@
// Copyright (C) 2021 Marius Schellenberger
package cmd
import (
"encoding/json"
stdlog "log"
"net"
"os"
"os/signal"
"syscall"
"time"
"git.giftfish.de/ston1th/godrop/v2"
"git.giftfish.de/ston1th/gowiki/pkg/core"
"git.giftfish.de/ston1th/gowiki/pkg/db"
"git.giftfish.de/ston1th/gowiki/pkg/log"
"git.giftfish.de/ston1th/gowiki/pkg/server"
"github.com/urfave/cli"
)
const (
defDataDir = "/var/lib/gowiki"
defListen = "127.0.0.1:8080"
defLogFile = "gowiki.log"
defRunUser = "_wiki"
defRunGroup = "_wiki"
)
var (
config core.Config
dumpFile string
configFile string
)
func initServer(cfg *core.Config) (err error) {
if err = godrop.PledgePromises("stdio rpath wpath cpath inet fattr flock unveil"); err != nil {
return
}
l, err := net.Listen("tcp", cfg.ListenAddr)
if err != nil {
return
}
log.InitLogger(cfg)
srv := server.NewHTTPServer(cfg, l)
err = srv.Start()
if err != nil {
return
}
log.Println("gowiki " + cfg.Version + " started")
sigs := make(chan os.Signal, 1)
signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM)
sig := <-sigs
log.Println("signal: " + sig.String())
go func() {
time.Sleep(time.Second * 20)
log.Fatal("stop timed out: killing")
}()
err = srv.Stop()
if err != nil {
log.Println(err)
}
log.Println("gowiki stopped")
return
}
func loadConfig() error {
if configFile == "" {
return nil
}
file, err := os.Open(configFile)
if err != nil {
return err
}
err = json.NewDecoder(file).Decode(&config)
if err != nil {
return err
}
return file.Close()
}
func Run(version string) {
app := cli.NewApp()
app.Name = "gowiki"
app.Usage = "a simple wiki engine"
app.Version = version
app.Commands = []cli.Command{
{
Name: "server",
Usage: "start gowiki server",
Flags: defaultFlags(serverFlags()),
Action: func(c *cli.Context) error {
if err := loadConfig(); err != nil {
stdlog.Fatal("server: ", err)
}
config.Version = app.Version
if err := initServer(&config); err != nil {
stdlog.Fatal("server: ", err)
}
return nil
},
},
{
Name: "dump",
Usage: "create a json dump of all articles in the data directory (gowiki needs to be stopped)",
Flags: defaultFlags(dumpRestoreFlags()),
Action: func(c *cli.Context) error {
var (
file = os.Stdout
err error
)
if err = loadConfig(); err != nil {
stdlog.Fatal("dump: ", err)
}
log.Stderr()
if dumpFile != "-" {
file, err = os.OpenFile(dumpFile, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o600)
if err != nil {
stdlog.Fatal("dump: ", err)
}
}
DB, err := db.NewPlain(&config)
if err != nil {
stdlog.Fatal("dump: ", err)
}
if err = DB.Dump(file); err != nil {
stdlog.Fatal("dump: ", err)
}
file.Close()
err = DB.Close()
if err != nil {
stdlog.Fatal("dump: ", err)
}
return nil
},
},
{
Name: "restore",
Usage: "restore a json dump to the data directory (gowiki needs to be stopped)",
Flags: defaultFlags(dumpRestoreFlags()),
Action: func(c *cli.Context) error {
var (
file = os.Stdin
err error
)
if err = loadConfig(); err != nil {
stdlog.Fatal("restore: ", err)
}
if dumpFile != "-" {
file, err = os.Open(dumpFile)
if err != nil {
stdlog.Fatal("restore: ", err)
}
}
DB, err := db.NewPlain(&config)
if err != nil {
stdlog.Fatal("restore: ", err)
}
if err = DB.Restore(file); err != nil {
stdlog.Fatal("restore: ", err)
}
file.Close()
err = DB.Close()
if err != nil {
stdlog.Fatal("restore: ", err)
}
return nil
},
},
{
Name: "resetadmin",
Usage: "reset the admin users password to the default (gowiki needs to be stopped)",
Flags: defaultFlags(nil),
Action: func(c *cli.Context) error {
var err error
if err = loadConfig(); err != nil {
stdlog.Fatal("resetadmin: ", err)
}
DB, err := db.NewPlain(&config)
if err != nil {
stdlog.Fatal("resetadmin: ", err)
}
err = DB.ResetAdmin()
if err != nil {
stdlog.Fatal("resetadmin: ", err)
}
err = DB.Close()
if err != nil {
stdlog.Fatal("resetadmin: ", err)
}
return nil
},
},
}
app.Run(os.Args)
}
func serverFlags() []cli.Flag {
return []cli.Flag{
cli.StringFlag{
Name: "user, u",
Value: defRunUser,
Usage: "drop privileges to user",
Destination: &config.User,
},
cli.StringFlag{
Name: "group, g",
Value: defRunGroup,
Usage: "drop privileges to group",
Destination: &config.Group,
},
cli.StringFlag{
Name: "listen, l",
Value: defListen,
Usage: "listening <address>:<port>",
Destination: &config.ListenAddr,
},
cli.StringFlag{
Name: "log",
Value: defLogFile,
Usage: "log file (use -log=- for stdout, this only works in combination with -f)",
Destination: &config.LogFile,
},
cli.StringFlag{
Name: "secret",
Usage: "static hmac secret (at least 64 chars, used for JWT signing)",
Destination: &config.Secret,
},
cli.BoolTFlag{
Name: "admin, a",
Usage: "use -a=false to disable the admin user",
Destination: &config.Admin,
},
cli.BoolFlag{
Name: "foreground, f",
Usage: "do not fork into the background",
Destination: &config.Foreground,
},
cli.BoolFlag{
Name: "secure, s",
Usage: "enable secure cookie",
Destination: &config.SecureCookie,
},
cli.BoolFlag{
Name: "debug",
Usage: "enable debugging",
Destination: &config.Debug,
},
}
}
func dumpRestoreFlags() []cli.Flag {
return []cli.Flag{
cli.StringFlag{
Name: "file, f",
Value: "-",
Usage: "file to read/write dump (default stdin/stdout)",
Destination: &dumpFile,
},
}
}
func defaultFlags(f []cli.Flag) []cli.Flag {
return append([]cli.Flag{
cli.StringFlag{
Name: "config, c",
Usage: "config file",
Destination: &configFile,
},
cli.StringFlag{
Name: "data, d",
Value: defDataDir,
Usage: "data directory",
Destination: &config.DataDir,
},
}, f...)
}

26
pkg/core/const.go Normal file
View file

@ -0,0 +1,26 @@
// Copyright (C) 2021 Marius Schellenberger
package core
const (
IndexPage = "Index"
WikiSection = "wiki"
IndexURI = WikiSection + "/" + IndexPage
RootURI = "/"
WikiURI = "/wiki"
LoginURI = "/login"
LogoutURI = "/logout"
TotpURI = "/totp"
SectionsURI = "/sections"
SectionURI = "/section"
SearchURI = "/search"
StatsURI = "/stats"
NewURI = "/new"
BlacklistURI = "/blacklist"
UsersURI = "/users"
UserURI = "/user"
ViewURI = "/view"
StaticURI = "/static"
)

13
pkg/core/helper.go Normal file
View file

@ -0,0 +1,13 @@
// Copyright (C) 2021 Marius Schellenberger
package core
// Contains checks if slice a contains string str
func Contains(str string, a []string) (int, bool) {
for i, s := range a {
if s == str {
return i, true
}
}
return -1, false
}

41
pkg/core/permission.go Normal file
View file

@ -0,0 +1,41 @@
// Copyright (C) 2021 Marius Schellenberger
package core
type Permission int
const (
Invalid Permission = iota
Public
Private
)
func ParsePermString(p string) Permission {
switch p {
case "1":
return Public
case "2":
return Private
}
return Invalid
}
func ReadPerm(username string, p Permission, s Section) bool {
switch p {
case Public:
return true
case Private:
_, c := Contains(username, s.Members)
return c
}
return false
}
func WritePerm(username string, p Permission, s Section) bool {
switch p {
case Public, Private:
_, c := Contains(username, s.Members)
return c
}
return false
}

View file

@ -0,0 +1,77 @@
// Copyright (C) 2021 Marius Schellenberger
package core
import (
"fmt"
"testing"
)
func TestParsePermString(t *testing.T) {
data := map[string]Permission{
"": Invalid,
"1": Public,
"2": Private,
"123": Invalid,
"test": Invalid,
}
for k, v := range data {
t.Run(k, func(t *testing.T) {
if ParsePermString(k) != v {
t.Fail()
}
})
}
}
func TestReadPerm(t *testing.T) {
data := []struct {
Username string
Perm Permission
Section Section
OK bool
}{
{"", Invalid, Section{Members: []string{"admin"}}, false},
{"", Public, Section{Members: []string{"admin"}}, true},
{"user", Public, Section{Members: []string{"admin"}}, true},
{"", Private, Section{Members: []string{"admin"}}, false},
{"user", Private, Section{Members: []string{"admin"}}, false},
{"", Private, Section{Members: []string{"user"}}, false},
{"user", Private, Section{Members: []string{"user"}}, true},
{"user", Private, Section{Members: []string{"user", "admin"}}, true},
{"admin", Private, Section{Members: []string{"user"}}, false},
}
for i, v := range data {
t.Run(fmt.Sprintf("Read:%d", i), func(t *testing.T) {
if ReadPerm(v.Username, v.Perm, v.Section) != v.OK {
t.Fail()
}
})
}
}
func TestWritePerm(t *testing.T) {
data := []struct {
Username string
Perm Permission
Section Section
OK bool
}{
{"", Invalid, Section{Members: []string{"user"}}, false},
{"", Public, Section{Members: []string{"user"}}, false},
{"user", Public, Section{Members: []string{"user"}}, true},
{"user", Public, Section{Members: []string{"admin"}}, false},
{"user", Private, Section{Members: []string{"user"}}, true},
{"", Private, Section{Members: []string{"user"}}, false},
{"", Private, Section{Members: []string{"user", "admin"}}, false},
{"user", Private, Section{Members: []string{"user", "admin"}}, true},
{"admin", Private, Section{Members: []string{"user", "admin"}}, true},
}
for i, v := range data {
t.Run(fmt.Sprintf("Write:%d", i), func(t *testing.T) {
if WritePerm(v.Username, v.Perm, v.Section) != v.OK {
t.Fail()
}
})
}
}

94
pkg/core/types.go Normal file
View file

@ -0,0 +1,94 @@
// Copyright (C) 2021 Marius Schellenberger
package core
import "html/template"
type Page struct {
Title string `json:"title"`
StoreTitle string `json:"store_title"`
Markdown string `json:"markdown"`
HTML template.HTML `json:"html"`
Created string `json:"created"`
Updated string `json:"updated"`
Perm Permission `json:"perm"`
}
type Pages []Page
func (p Pages) Len() int { return len(p) }
func (p Pages) Swap(i, j int) { p[i], p[j] = p[j], p[i] }
func (p Pages) Less(i, j int) bool { return p[i].StoreTitle < p[j].StoreTitle }
type UpdatedPages []Page
func (p UpdatedPages) Len() int { return len(p) }
func (p UpdatedPages) Swap(i, j int) { p[i], p[j] = p[j], p[i] }
func (p UpdatedPages) Less(i, j int) bool { return p[i].Updated > p[j].Updated }
type Section struct {
Name string
User bool
Members []string
}
type Sections []Section
func (s Sections) Len() int { return len(s) }
func (s Sections) Swap(i, j int) { s[i], s[j] = s[j], s[i] }
func (s Sections) Less(i, j int) bool { return s[i].Name < s[j].Name }
type SectionPage struct {
Section string
Pages UpdatedPages
}
type SectionPages []SectionPage
func (s SectionPages) Len() int { return len(s) }
func (s SectionPages) Swap(i, j int) { s[i], s[j] = s[j], s[i] }
func (s SectionPages) Less(i, j int) bool { return s[i].Section < s[j].Section }
type User struct {
Username string
Password string
Created string
Secret string
Admin bool
Locked int
}
type Users []*User
func (u Users) Len() int { return len(u) }
func (u Users) Swap(i, j int) { u[i], u[j] = u[j], u[i] }
func (u Users) Less(i, j int) bool { return u[i].Username < u[j].Username }
type Result struct {
Title string
Section string
StoreTitle string
HTML template.HTML
}
type Stats struct {
Goroutines int
Alloc string
Sys string
Docs uint64
GoVersion string
}
type Config struct {
DataDir string `json:"data_dir,omitempty"`
User string `json:"user,omitempty"`
Group string `json:"group,omitempty"`
ListenAddr string `json:"listen_addr,omitempty"`
LogFile string `json:"log_file,omitempty"`
Version string `json:"-"`
Secret string `json:"secret,omitempty"`
Admin bool `json:"admin,omitempty"`
Foreground bool `json:"foreground,omitempty"`
SecureCookie bool `json:"secure_cookie,omitempty"`
Debug bool `json:"debug,omitempty"`
}

33
pkg/db/blacklist.go Normal file
View file

@ -0,0 +1,33 @@
// Copyright (C) 2021 Marius Schellenberger
package db
import "git.giftfish.de/ston1th/jwt/v3"
const blacklistPrefix = "blacklist/"
func (db *DB) Add(sig string, exp int64) error {
return db.store.Set(blacklistPrefix+sig, exp)
}
func (db *DB) Remove(sig string) error {
return db.store.Delete(blacklistPrefix + sig)
}
func (db *DB) Check(sig string) (ok bool) {
return db.store.Get(blacklistPrefix+sig, nil) == nil
}
func (db *DB) Map() (list jwt.BlacklistMap, err error) {
list = make(jwt.BlacklistMap)
err = db.store.ForEachPrefix(blacklistPrefix, func(k string, v []byte) error {
var exp int64
err := db.store.Unmarshal(v, &exp)
if err != nil {
return err
}
list[k] = exp
return nil
})
return
}

122
pkg/db/db.go Normal file
View file

@ -0,0 +1,122 @@
// Copyright (C) 2021 Marius Schellenberger
package db
import (
"git.giftfish.de/ston1th/godrop/v2"
"git.giftfish.de/ston1th/gowiki/pkg/cache"
"git.giftfish.de/ston1th/gowiki/pkg/core"
"git.giftfish.de/ston1th/gowiki/pkg/index"
"git.giftfish.de/ston1th/gowiki/pkg/render"
"git.giftfish.de/ston1th/gowiki/pkg/store"
"io"
"os"
"path/filepath"
)
const (
defUser = "admin"
defPassword = "gowiki"
storeFile = "store.db"
bcryptCost = 13
welcome = `# Welcome to GoWiki
This is the [Index](/wiki/Index) page.
You can customize it how you like.`
)
const blevePath = "bleve"
type DB struct {
admin bool
store store.Store
cache *cache.Cache
Index *index.Index
indexPath string
}
func NewPlain(cfg *core.Config) (db *DB, err error) {
db = &DB{admin: cfg.Admin, cache: cache.NewCache()}
dbFile := filepath.Join(cfg.DataDir, storeFile)
err = godrop.Unveil(dbFile, "rwc")
if err != nil {
return
}
db.store, err = store.NewBoltStore(dbFile, nil)
return
}
func New(cfg *core.Config) (db *DB, err error) {
db, err = NewPlain(cfg)
if err != nil {
return
}
db.indexPath = filepath.Join(cfg.DataDir, blevePath)
db.Index, err = index.New(db.indexPath)
if err != nil {
return
}
if db.UserExists(defUser) != nil {
err = db.CreateUser(defUser, defPassword, true)
if err != nil {
return
}
}
err = db.UnlockUser(defUser)
if err != nil {
return
}
if db.SectionExists(core.WikiSection) != nil {
err = db.CreateSection(core.WikiSection, []string{defUser}, false)
if err != nil {
return
}
}
_, err = db.CreatePage(core.IndexPage, core.WikiSection, welcome, defUser, core.Public)
if err != errPageExists && err != nil {
return
}
err = db.RunMigrations()
return
}
func (db *DB) Dump(w io.Writer) error {
return db.store.Dump(w)
}
func (db *DB) Restore(r io.Reader) (err error) {
err = db.Index.Close()
if err != nil {
return
}
err = os.RemoveAll(db.indexPath)
if err != nil {
return
}
db.Index, err = index.New(db.indexPath)
if err != nil {
return
}
err = db.store.Restore(r)
if err != nil {
return
}
err = db.store.ForEachPrefix(pagePrefix, func(k string, _ []byte) error {
p := new(core.Page)
db.store.Get(pagePrefix+k, p)
db.Index.Add(p.Title, p.StoreTitle, render.Render(p))
return nil
})
return
}
func (db *DB) Close() (err error) {
err = db.store.Close()
if err != nil {
return
}
if db.Index != nil {
return db.Index.Close()
}
return nil
}

55
pkg/db/helper.go Normal file
View file

@ -0,0 +1,55 @@
// Copyright (C) 2021 Marius Schellenberger
package db
import (
"crypto/sha256"
"git.giftfish.de/ston1th/gowiki/pkg/core"
"time"
)
func init() {
reservedNames = make([]string, len(reservedURIs))
for i, v := range reservedURIs {
reservedNames[i] = v[1:]
}
}
var reservedURIs = []string{
core.LoginURI,
core.LogoutURI,
core.TotpURI,
core.SectionsURI,
core.SectionURI,
core.SearchURI,
core.StatsURI,
core.NewURI,
core.BlacklistURI,
core.UsersURI,
core.UserURI,
core.ViewURI,
core.StaticURI,
}
var reservedNames []string
func validatePassword(pw string) (b []byte) {
b = []byte(pw)
if len(pw) <= 56 {
return
}
hash := sha256.New()
hash.Write(b)
b = hash.Sum(nil)
return
}
const timeFmt = "2006-01-02 15:04:05"
func now() string {
return time.Now().Format(timeFmt)
}
func created(username string) string {
return username + " " + now()
}

13
pkg/db/lockout.go Normal file
View file

@ -0,0 +1,13 @@
// Copyright (C) 2021 Marius Schellenberger
package db
const lockoutPrefix = "lockout/"
func (db *DB) lockout(username, created string) error {
return db.store.Set(lockoutPrefix+username+created, nil)
}
func (db *DB) LockedOut(username, created string) bool {
return db.store.Get(lockoutPrefix+username+created, nil) == nil
}

115
pkg/db/migration.go Normal file
View file

@ -0,0 +1,115 @@
// Copyright (C) 2021 Marius Schellenberger
package db
import (
"git.giftfish.de/ston1th/gowiki/pkg/core"
"git.giftfish.de/ston1th/gowiki/pkg/log"
"git.giftfish.de/ston1th/gowiki/pkg/render"
"git.giftfish.de/ston1th/gowiki/pkg/store"
)
const (
versionKey = "version/version"
currentVersion = 2
)
type migrator func(*DB, int) error
var migrators = []migrator{
func(_ *DB, _ int) error {
return nil
},
func(db *DB, version int) (err error) {
// recreate all pages with new format
var pages []string
db.store.ForEachPrefix(pagePrefix, func(k string, _ []byte) error {
pages = append(pages, k)
return nil
})
var p core.Page
for _, v := range pages {
k := pagePrefix + v
err := db.store.Get(k, &p)
if err != nil {
log.Printf("db: migrator[%d]: pageGet %s: %s", version, k, err)
continue
}
if p.Perm == 3 {
p.Perm = core.Private
}
err = db.store.Set(k, &p)
if err != nil {
log.Printf("db: migrator[%d]: pageSet %s: %s", version, k, err)
continue
}
}
// create user sections
users, err := db.GetUsers()
if err != nil {
return
}
for _, u := range users {
err = db.CreateSection(u.Username, []string{u.Username}, true)
if err != nil {
log.Printf("db: migrator[%d]: userSection %s: %s", version, u.Username, err)
}
}
return
},
func(db *DB, version int) (err error) {
// re-render all pages
var pages []string
db.store.ForEachPrefix(pagePrefix, func(k string, _ []byte) error {
pages = append(pages, k)
return nil
})
var p core.Page
for _, v := range pages {
k := pagePrefix + v
err := db.store.Get(k, &p)
if err != nil {
log.Printf("db: migrator[%d]: pageGet %s: %s", version, k, err)
continue
}
render.Render(&p)
err = db.store.Set(k, &p)
if err != nil {
log.Printf("db: migrator[%d]: pageSet %s: %s", version, k, err)
continue
}
}
return
},
}
func (db *DB) RunMigrations() (err error) {
var version int
err = db.store.Get(versionKey, &version)
if err == store.ErrKeyNotFound {
err = db.store.Set(versionKey, currentVersion)
if err != nil {
return
}
err = db.store.Get(versionKey, &version)
if err != nil {
return
}
}
log.Printf("db: database version %d", version)
for version < currentVersion {
v := version + 1
log.Printf("db: running migration version %d", v)
err = migrators[v](db, v)
if err != nil {
return
}
err = db.store.Set(versionKey, v)
if err != nil {
return
}
version = v
log.Printf("db: database version %d", version)
}
return
}

241
pkg/db/page.go Normal file
View file

@ -0,0 +1,241 @@
// Copyright (C) 2021 Marius Schellenberger
package db
import (
"errors"
"git.giftfish.de/ston1th/gowiki/pkg/core"
"git.giftfish.de/ston1th/gowiki/pkg/render"
"git.giftfish.de/ston1th/gowiki/pkg/store"
"sort"
"strings"
)
const pagePrefix = "page/"
var (
errPageNotFound = store.ErrKeyNotFound
errPageExists = errors.New("db: page already exists")
errPermissionDenied = errors.New("db: permission denied")
errInvalidPermission = errors.New("db: invalid permission")
errPrivateWikiPage = errors.New("db: private wiki pages are not allowed")
errDeleteIndexPage = errors.New("db: index page can not be deleted")
)
func (db *DB) GetAllSections(username string, admin bool) (sections core.SectionPages) {
m := make(map[string]core.UpdatedPages)
db.store.ForEachPrefix(pagePrefix, func(k string, _ []byte) error {
if k == core.IndexURI {
return nil
}
p, err := db.getPage(k, username)
if err == nil {
i := strings.Index(k, "/")
if i == -1 {
return nil
}
s := k[:i]
if _, ok := m[s]; !ok {
m[s] = core.UpdatedPages{}
}
m[s] = append(m[s], *p)
}
return nil
})
if admin {
secs, err := db.GetSections()
if err == nil {
for _, s := range secs {
if _, ok := m[s.Name]; !ok {
m[s.Name] = core.UpdatedPages{}
}
}
}
}
for s, p := range m {
sort.Sort(p)
l := len(p)
if l > 5 {
l = 5
}
sections = append(sections, core.SectionPage{
Section: s,
Pages: p[:l],
})
}
sort.Sort(sections)
return
}
func (db *DB) GetAllSectionPages(section, username string, admin bool) (pages core.Pages, err error) {
err = db.SectionExists(section)
if err != nil {
return
}
db.store.ForEachPrefix(pagePrefix, func(k string, _ []byte) error {
if !strings.HasPrefix(k, section+"/") {
return nil
}
if k == core.IndexURI {
return nil
}
p, err := db.getPage(k, username)
if err == nil {
pages = append(pages, *p)
}
return nil
})
if len(pages) == 0 && !admin {
err = errPageNotFound
return
}
sort.Sort(pages)
return
}
func (db *DB) ValidatePage(st, username string) bool {
p := db.cache.Get(st)
if p == nil {
p, err := db.getPage(st, username)
if err != nil {
return false
}
db.cache.Add(st, p)
return true
}
return db.ReadPerm(st, username, p.Perm)
}
func (db *DB) GetPage(section, title, username string) (p *core.Page, err error) {
st := render.StoreTitle(section, title)
p = db.cache.Get(st)
if p == nil {
p, err = db.getPage(st, username)
db.cache.Add(st, p)
return
}
if !db.ReadPerm(section, username, p.Perm) {
err = errPageNotFound
}
return
}
func (db *DB) getPage(title, username string) (p *core.Page, err error) {
p = new(core.Page)
err = db.store.Get(pagePrefix+title, p)
if err != nil {
return
}
if !db.ReadPerm(title, username, p.Perm) {
err = errPageNotFound
}
return
}
func (db *DB) CreatePage(title, section, markdown, username string, p core.Permission) (page *core.Page, err error) {
if p == core.Invalid {
err = errInvalidPermission
return
}
if section == core.WikiSection && p == core.Private {
err = errPrivateWikiPage
return
}
st := render.StoreTitle(section, render.Title(title))
_, err = db.getPage(st, username)
if err != store.ErrKeyNotFound {
if err == nil {
err = errPageExists
}
return
}
s, err := db.GetSection(section)
if err != nil {
return
}
if _, ok := core.Contains(username, s.Members); !ok {
err = errPermissionDenied
return
}
page = &core.Page{
Title: title,
StoreTitle: st,
Markdown: markdown,
Created: created(username),
Perm: p,
}
search := render.Render(page)
err = db.store.Set(pagePrefix+st, page)
if err != nil {
return
}
err = db.Index.Add(title, st, search)
if err != nil {
return
}
db.cache.Add(st, page)
return
}
func (db *DB) UpdatePage(title, section, markdown, username string, p core.Permission) (err error) {
if p == core.Invalid {
err = errInvalidPermission
return
}
if section == core.WikiSection && p == core.Private {
err = errPrivateWikiPage
return
}
st := render.StoreTitle(section, title)
page, err := db.getPage(st, username)
if err != nil {
return
}
if !db.WritePerm(section, username, page.Perm) {
return errPermissionDenied
}
page.Markdown = markdown
page.Updated = created(username)
page.Perm = p
search := render.Render(page)
err = db.store.Set(pagePrefix+st, page)
if err != nil {
return
}
err = db.Index.Add(page.Title, st, search)
if err != nil {
return
}
db.cache.Add(st, page)
return
}
func (db *DB) deletePage(st string) (err error) {
if err = db.store.Delete(pagePrefix + st); err != nil {
return
}
db.cache.Delete(st)
return db.Index.Delete(st)
}
func (db *DB) DeletePage(title, section, username string) (err error) {
var page *core.Page
if section == core.WikiSection && title == core.IndexPage {
err = errDeleteIndexPage
return
}
st := render.StoreTitle(section, title)
if page, err = db.getPage(st, username); err != nil {
return
}
if !db.WritePerm(section, username, page.Perm) {
return errPermissionDenied
}
return db.deletePage(st)
}

29
pkg/db/permission.go Normal file
View file

@ -0,0 +1,29 @@
// Copyright (C) 2021 Marius Schellenberger
package db
import (
"git.giftfish.de/ston1th/gowiki/pkg/core"
"git.giftfish.de/ston1th/gowiki/pkg/log"
"git.giftfish.de/ston1th/gowiki/pkg/render"
)
func (db *DB) ReadPerm(st, username string, p core.Permission) bool {
section, _ := render.SplitStoreTitle(st)
s, err := db.GetSection(section)
if err != nil {
log.Debugf("db: read: %s", err)
return false
}
return core.ReadPerm(username, p, s)
}
func (db *DB) WritePerm(st, username string, p core.Permission) bool {
section, _ := render.SplitStoreTitle(st)
s, err := db.GetSection(section)
if err != nil {
log.Debugf("db: read: %s", err)
return false
}
return core.WritePerm(username, p, s)
}

118
pkg/db/section.go Normal file
View file

@ -0,0 +1,118 @@
// Copyright (C) 2021 Marius Schellenberger
package db
import (
"errors"
"fmt"
"git.giftfish.de/ston1th/gowiki/pkg/core"
"git.giftfish.de/ston1th/gowiki/pkg/log"
"sort"
"strings"
)
const sectionPrefix = "section/"
var (
errSectionExists = errors.New("db: section already exist")
)
func (db *DB) GetSections() (secs core.Sections, err error) {
err = db.store.ForEachPrefix(sectionPrefix, func(k string, _ []byte) error {
s, err := db.GetSection(k)
if err == nil {
secs = append(secs, s)
}
return nil
})
sort.Sort(secs)
return
}
func (db *DB) GetUserSections(username string) (sections map[string]bool, err error) {
sections = make(map[string]bool)
secs, err := db.GetSections()
if err != nil {
return
}
for _, s := range secs {
if _, ok := core.Contains(username, s.Members); ok {
sections[s.Name] = false
}
}
return
}
func (db *DB) GetSection(section string) (s core.Section, err error) {
err = db.store.Get(sectionPrefix+section, &s)
return
}
func (db *DB) SectionExists(section string) error {
return db.store.Get(sectionPrefix+section, nil)
}
func (db *DB) CreateSection(section string, members []string, user bool) error {
section = userRe.ReplaceAllString(section, "")
if _, ok := core.Contains(section, reservedNames); ok {
return fmt.Errorf("%s: %s", errNameReserved, section)
}
if err := db.SectionExists(section); err == nil {
return errSectionExists
}
return db.UpdateSection(section, members, user)
}
func (db *DB) UpdateSection(section string, members []string, user bool) error {
section = userRe.ReplaceAllString(section, "")
if _, ok := core.Contains(section, reservedNames); ok {
return fmt.Errorf("%s: %s", errNameReserved, section)
}
var m []string
if user {
m = append(m, section)
} else {
for _, v := range members {
if v == "" {
continue
}
if err := db.UserExists(v); err == nil {
m = append(m, v)
}
}
}
return db.store.Set(sectionPrefix+section, &core.Section{
Name: section,
User: user,
Members: m,
})
}
func (db *DB) DeleteSection(section string) error {
if section == core.WikiSection {
return errors.New("section '" + core.WikiSection + "' cannot be deleted")
}
err := db.SectionExists(section)
if err != nil {
return err
}
err = db.store.Delete(sectionPrefix + section)
if err != nil {
return err
}
section += "/"
var pages []string
db.store.ForEachPrefix(pagePrefix, func(k string, _ []byte) error {
if strings.HasPrefix(k, section) {
pages = append(pages, k)
}
return nil
})
for _, p := range pages {
err := db.deletePage(p)
if err != nil {
log.Debugf("DeleteSection %s, %s", section, err)
}
}
return nil
}

230
pkg/db/user.go Normal file
View file

@ -0,0 +1,230 @@
// Copyright (C) 2021 Marius Schellenberger
package db
import (
"errors"
"fmt"
"git.giftfish.de/ston1th/gowiki/pkg/core"
"git.giftfish.de/ston1th/gowiki/pkg/otp"
"git.giftfish.de/ston1th/jwt/v3"
"golang.org/x/crypto/bcrypt"
"regexp"
"sort"
"strconv"
)
const userPrefix = "user/"
var (
errUserLocked = errors.New("db: user locked")
errUserLogin = errors.New("db: wrong login")
errUserPin = errors.New("db: wrong pin")
errUserExists = errors.New("db: user already exist")
errNameReserved = errors.New("db: name is reserved")
userRe = regexp.MustCompile("[^a-zA-Z0-9]+")
)
func (db *DB) GetUsers() (users core.Users, err error) {
err = db.store.ForEachPrefix(userPrefix, func(k string, _ []byte) error {
u, err := db.GetUser(k)
u.Password = ""
if err == nil {
users = append(users, u)
}
return nil
})
sort.Sort(users)
return
}
func (db *DB) GetUserWithoutPassword(username string) (u *core.User, err error) {
u, err = db.GetUser(username)
u.Password = ""
return
}
func (db *DB) GetUserSecret(username string) (secret string, err error) {
var user *core.User
user, err = db.GetUser(username)
secret = user.Secret
return
}
func (db *DB) GetUser(username string) (u *core.User, err error) {
u = new(core.User)
err = db.store.Get(userPrefix+username, u)
return
}
func (db *DB) UserExists(username string) error {
return db.store.Get(userPrefix+username, nil)
}
func (db *DB) CreateUser(username, password string, admin bool) error {
username = userRe.ReplaceAllString(username, "")
if _, ok := core.Contains(username, reservedNames); ok {
return fmt.Errorf("%s: %s", errNameReserved, username)
}
if err := db.UserExists(username); err == nil {
return errUserExists
}
hash, err := bcrypt.GenerateFromPassword(validatePassword(password), bcryptCost)
if err != nil {
return err
}
err = db.CreateSection(username, nil, true)
if err != nil {
return err
}
return db.store.Set(userPrefix+username, &core.User{
Username: username,
Password: string(hash),
Created: strconv.Itoa(int(jwt.Now())),
Admin: admin,
})
}
func (db *DB) Login(username, password string) (u *core.User, err error) {
if username == defUser && !db.admin {
err = errors.New("db: user disabled")
return
}
u, err = db.GetUser(username)
if err != nil {
return
}
if u.Locked == 3 {
err = errUserLocked
return
}
if err = bcrypt.CompareHashAndPassword([]byte(u.Password), validatePassword(password)); err != nil {
u.Locked++
err = db.store.Set(userPrefix+username, u)
if err != nil {
return
}
err = errUserLogin
return
}
if u.Locked > 0 {
u.Locked = 0
err = db.store.Set(userPrefix+username, u)
if err != nil {
return
}
}
return
}
func (db *DB) Totp(username, pin string) (u *core.User, err error) {
u, err = db.GetUser(username)
if err != nil {
return
}
if u.Locked == 3 {
err = errUserLocked
return
}
if !otp.Validate(pin, u.Secret) {
u.Locked++
err = db.store.Set(userPrefix+username, u)
if err != nil {
return
}
err = errUserPin
return
}
if u.Locked > 0 {
u.Locked = 0
err = db.store.Set(userPrefix+username, u)
if err != nil {
return
}
}
return
}
func (db *DB) UnlockUser(username string) (err error) {
var u *core.User
u, err = db.GetUser(username)
if err != nil {
return
}
if u.Locked > 0 {
u.Locked = 0
err = db.store.Set(userPrefix+username, u)
if err != nil {
return
}
}
return
}
func (db *DB) UpdateUserSecret(username, secret string) error {
u, err := db.GetUser(username)
if err != nil {
return err
}
u.Secret = secret
return db.store.Set(userPrefix+username, u)
}
func (db *DB) UpdateUserPassword(username, password string) error {
if password == "" {
return errors.New("empty password")
}
u, err := db.GetUser(username)
if err != nil {
return err
}
hash, err := bcrypt.GenerateFromPassword(validatePassword(password), bcryptCost)
if err != nil {
return err
}
u.Password = string(hash)
return db.store.Set(userPrefix+username, u)
}
func (db *DB) ResetAdmin() error {
return db.UpdateUserPassword(defUser, defPassword)
}
func (db *DB) AdminUpdateUser(username, password string, admin bool) error {
if username == defUser && !admin {
return errors.New("user '" + defUser + "' cannot lose admin privileges")
}
u, err := db.GetUser(username)
if err != nil {
return err
}
u.Admin = admin
if password != "" {
hash, err := bcrypt.GenerateFromPassword(validatePassword(password), bcryptCost)
if err != nil {
return err
}
u.Password = string(hash)
}
return db.store.Set(userPrefix+username, u)
}
func (db *DB) DeleteUser(username string) error {
if username == defUser {
return errors.New("user '" + defUser + "' cannot be deleted")
}
u, err := db.GetUser(username)
if err != nil {
return err
}
err = db.store.Delete(userPrefix + username)
if err != nil {
return err
}
err = db.lockout(username, u.Created)
if err != nil {
return err
}
return db.DeleteSection(username)
}

130
pkg/index/index.go Normal file
View file

@ -0,0 +1,130 @@
// Copyright (C) 2021 Marius Schellenberger
package index
import (
"errors"
"git.giftfish.de/ston1th/godrop/v2"
"git.giftfish.de/ston1th/gowiki/pkg/core"
"git.giftfish.de/ston1th/gowiki/pkg/render"
"github.com/blevesearch/bleve"
"github.com/blevesearch/bleve/mapping"
"html/template"
"os"
)
const (
maxSearchResult = 101
docType = "page"
titleField = "title"
storeTitleField = "store_title"
searchField = "search"
)
type indexPage struct {
Title string `json:"title"`
StoreTitle string `json:"store_title"`
Search string `json:"search"`
}
func (*indexPage) Type() string {
return docType
}
type Index struct {
i bleve.Index
}
func New(path string) (i *Index, err error) {
var bi bleve.Index
err = godrop.Unveil(path, "rwc")
if err != nil {
return
}
if _, err = os.Stat(path); os.IsNotExist(err) {
err = os.Mkdir(path, 0o700)
if err != nil {
err = errors.New("index: " + err.Error())
return
}
err = godrop.Unveil(path, "rwc")
if err != nil {
return
}
var m *mapping.IndexMappingImpl
m, err = createMapping()
if err != nil {
err = errors.New("mapping: " + err.Error())
return
}
bi, err = bleve.New(path, m)
if err != nil {
err = errors.New("index: " + err.Error())
return
}
} else {
bi, err = bleve.Open(path)
if err != nil {
err = errors.New("index: " + err.Error())
return
}
}
i = &Index{i: bi}
return
}
func (i *Index) DocCount() (c uint64) {
c, _ = i.i.DocCount()
return
}
func (i *Index) Close() error {
return i.i.Close()
}
func (i *Index) Add(title, storeTitle, search string) error {
doc, _ := i.i.Document(storeTitle)
if doc != nil {
err := i.Delete(storeTitle)
if err != nil {
return err
}
}
return i.i.Index(storeTitle, &indexPage{title, storeTitle, search})
}
func (i *Index) Delete(storeTitle string) error {
return i.i.Delete(storeTitle)
}
func (i *Index) Search(search string) (results []core.Result, err error) {
req := bleve.NewSearchRequest(bleve.NewQueryStringQuery(search))
req.Highlight = bleve.NewHighlightWithStyle("html")
req.Size = maxSearchResult
res, err := i.i.Search(req)
if err != nil {
return
}
for _, hit := range res.Hits {
if hit == nil {
continue
}
r := core.Result{StoreTitle: hit.ID}
r.Section, r.Title = render.SplitStoreTitle(hit.ID)
r.Title = render.Untitle(r.Title)
d, err := i.i.Document(hit.ID)
if d == nil || err != nil {
continue
}
for fragField, frags := range hit.Fragments {
if fragField == titleField || fragField == searchField {
for _, f := range frags {
r.HTML += template.HTML(f)
}
break
}
}
results = append(results, r)
}
return
}

56
pkg/index/mapping.go Normal file
View file

@ -0,0 +1,56 @@
// Copyright (C) 2021 Marius Schellenberger
package index
import (
"github.com/blevesearch/bleve"
"github.com/blevesearch/bleve/analysis/analyzer/custom"
"github.com/blevesearch/bleve/analysis/token/edgengram"
"github.com/blevesearch/bleve/analysis/token/lowercase"
"github.com/blevesearch/bleve/analysis/tokenizer/unicode"
"github.com/blevesearch/bleve/mapping"
)
const (
bigram = "bigram_tokenfilter"
ngram = "fulltext_ngram"
)
func createMapping() (m *mapping.IndexMappingImpl, err error) {
m = bleve.NewIndexMapping()
err = m.AddCustomTokenFilter(bigram, map[string]interface{}{
"type": edgengram.Name,
"side": edgengram.FRONT,
"min": 3.0,
"max": 25.0,
})
if err != nil {
return
}
err = m.AddCustomAnalyzer(ngram, map[string]interface{}{
"type": custom.Name,
"tokenizer": unicode.Name,
"token_filters": []string{
lowercase.Name,
bigram,
},
})
if err != nil {
return
}
doc := bleve.NewDocumentMapping()
ti := bleve.NewTextFieldMapping()
ti.Analyzer = ngram
doc.AddFieldMappingsAt(titleField, ti)
st := bleve.NewTextFieldMapping()
st.Analyzer = ngram
doc.AddFieldMappingsAt(storeTitleField, st)
s := bleve.NewTextFieldMapping()
s.Analyzer = ngram
doc.AddFieldMappingsAt(searchField, s)
m.AddDocumentMapping(docType, doc)
return
}

63
pkg/log/log.go Normal file
View file

@ -0,0 +1,63 @@
// Copyright (C) 2021 Marius Schellenberger
package log
import (
"git.giftfish.de/ston1th/godrop/v2"
"git.giftfish.de/ston1th/gowiki/pkg/core"
stdlog "log"
"os"
"path/filepath"
)
var (
log = stdlog.New(os.Stdout, "", stdlog.LstdFlags)
debug = false
)
func Stderr() {
log = stdlog.New(os.Stderr, "", stdlog.LstdFlags)
}
func InitLogger(cfg *core.Config) {
debug = cfg.Debug
if cfg.LogFile == "-" {
return
}
logfile := filepath.Join(cfg.DataDir, cfg.LogFile)
err := godrop.Unveil(logfile, "rwc")
if err != nil {
stdlog.Fatal(err)
return
}
f, err := os.OpenFile(logfile, os.O_RDWR|os.O_CREATE|os.O_APPEND, 0o600)
if err != nil {
stdlog.Fatal(err)
}
stdlog.SetOutput(f)
log = stdlog.New(f, "", stdlog.LstdFlags)
}
func Println(v ...interface{}) {
log.Println(v...)
}
func Printf(fmt string, v ...interface{}) {
log.Printf(fmt, v...)
}
func Fatal(v ...interface{}) {
log.Fatal(v...)
}
func Debug(v ...interface{}) {
if debug {
log.Println(append([]interface{}{"debug:"}, v...)...)
}
}
func Debugf(fmt string, v ...interface{}) {
if debug {
log.Printf("debug: "+fmt, v...)
}
}

76
pkg/otp/otp.go Normal file
View file

@ -0,0 +1,76 @@
// Copyright (C) 2021 Marius Schellenberger
package otp
import (
"bytes"
"encoding/base64"
"errors"
"github.com/pquerna/otp"
"github.com/pquerna/otp/totp"
"html/template"
"image/png"
)
const size = 200
func base64Image(key *otp.Key) (data template.URL, err error) {
var buf bytes.Buffer
img, err := key.Image(size, size)
if err != nil {
err = errors.New("otp: " + err.Error())
return
}
err = png.Encode(&buf, img)
if err != nil {
err = errors.New("otp: " + err.Error())
return
}
data = template.URL("data:image/png;base64," + base64.StdEncoding.EncodeToString(buf.Bytes()))
return
}
type Request struct {
Username string
Image template.URL
Secret string
URL string
}
func New(username string) (req Request, err error) {
key, err := totp.Generate(totp.GenerateOpts{
Issuer: "gowiki",
AccountName: username,
})
if err != nil {
return
}
data, err := base64Image(key)
if err != nil {
return
}
return Request{
Username: username,
Image: data,
Secret: key.Secret(),
URL: key.URL(),
}, nil
}
func Validate(pin, secret string) bool {
if secret == "" {
return true
}
return totp.Validate(pin, secret)
}
func ImageSecretFromURL(url string) (data template.URL, secret string, err error) {
key, err := otp.NewKeyFromURL(url)
if err != nil {
err = errors.New("otp: " + err.Error())
return
}
secret = key.Secret()
data, err = base64Image(key)
return
}

25
pkg/otp/otp_test.go Normal file
View file

@ -0,0 +1,25 @@
// Copyright (C) 2021 Marius Schellenberger
package otp
import (
"testing"
)
func TestNew(t *testing.T) {
_, err := New("admin")
if err != nil {
t.Error(err)
}
}
func TestImageSecretFromURL(t *testing.T) {
r, _ := New("admin")
_, secret, err := ImageSecretFromURL(r.URL)
if err != nil {
t.Error(err)
}
if secret != r.Secret {
t.Error("secret is not equal")
}
}

87
pkg/render/render.go Normal file
View file

@ -0,0 +1,87 @@
// Copyright (C) 2021 Marius Schellenberger
package render
import (
"git.giftfish.de/ston1th/gowiki/pkg/core"
"github.com/blevesearch/bleve/analysis"
htmlchar "github.com/blevesearch/bleve/analysis/char/html"
"github.com/russross/blackfriday"
"html/template"
"regexp"
"strings"
)
const delim = "+"
var filter = makeFilter()
func makeFilter() analysis.CharFilter {
f, _ := htmlchar.CharFilterConstructor(nil, nil)
return f
}
var (
titleRe = regexp.MustCompile("[^a-zA-Z0-9 -]+")
whiteSpace = regexp.MustCompile(`\s+`)
)
const (
commonHtmlFlags = 0 |
blackfriday.HTML_USE_XHTML |
blackfriday.HTML_USE_SMARTYPANTS |
blackfriday.HTML_SMARTYPANTS_FRACTIONS |
blackfriday.HTML_SMARTYPANTS_DASHES |
blackfriday.HTML_SMARTYPANTS_LATEX_DASHES |
blackfriday.HTML_TOC |
blackfriday.HTML_HREF_TARGET_BLANK |
blackfriday.HTML_NOREFERRER_LINKS |
blackfriday.HTML_NOFOLLOW_LINKS |
blackfriday.HTML_NOOPENER_LINKS
commonExtensions = 0 |
blackfriday.EXTENSION_NO_INTRA_EMPHASIS |
blackfriday.EXTENSION_TABLES |
blackfriday.EXTENSION_FENCED_CODE |
blackfriday.EXTENSION_AUTOLINK |
blackfriday.EXTENSION_STRIKETHROUGH |
blackfriday.EXTENSION_SPACE_HEADERS |
blackfriday.EXTENSION_HEADER_IDS |
blackfriday.EXTENSION_BACKSLASH_LINE_BREAK |
blackfriday.EXTENSION_DEFINITION_LISTS |
blackfriday.EXTENSION_AUTO_HEADER_IDS
)
func Render(p *core.Page) string {
renderer := blackfriday.HtmlRenderer(commonHtmlFlags, "", "")
rend := blackfriday.MarkdownOptions([]byte(p.Markdown), renderer,
blackfriday.Options{Extensions: commonExtensions},
)
html := string(rend)
html = strings.ReplaceAll(html, "<table>", `<table class="table">`)
p.HTML = template.HTML(html)
return whiteSpace.ReplaceAllString(string(filter.Filter(rend)), " ")
}
func Title(title string) string {
return whiteSpace.ReplaceAllString(titleRe.ReplaceAllString(title, ""), delim)
}
func Untitle(title string) string {
return strings.ReplaceAll(title, delim, " ")
}
func StoreTitle(section, title string) string {
return section + "/" + title
}
func SplitStoreTitle(st string) (section, title string) {
a := strings.Split(st, "/")
switch len(a) {
case 1:
return a[0], ""
case 2:
return a[0], a[1]
}
return
}

35
pkg/render/render_test.go Normal file
View file

@ -0,0 +1,35 @@
// Copyright (C) 2021 Marius Schellenberger
package render
import (
"testing"
)
func TestTitleFunctions(t *testing.T) {
section := "section"
title := "title"
combined := "section/title"
t.Run("StoreTitle", func(t *testing.T) {
if StoreTitle(section, title) != combined {
t.Fail()
}
})
t.Run("SplitStoreTitle", func(t *testing.T) {
se, ti := SplitStoreTitle(combined)
if se != section || ti != title {
t.Fail()
}
})
t.Run("SplitStoreTitleSingle", func(t *testing.T) {
se, ti := SplitStoreTitle(section)
if se != section || ti != "" {
t.Fail()
}
})
t.Run("Title", func(t *testing.T) {
if Title("9ßw$%4h4t-8v74 287(G /SV&3[45v}=94#+?)") != "9w4h4t-8v74+287G+SV345v94" {
t.Fail()
}
})
}

305
pkg/server/context.go Normal file
View file

@ -0,0 +1,305 @@
// Copyright (C) 2021 Marius Schellenberger
package server
import (
"errors"
"git.giftfish.de/ston1th/gowiki/pkg/core"
"git.giftfish.de/ston1th/gowiki/pkg/log"
"git.giftfish.de/ston1th/jwt/v3"
"github.com/gorilla/mux"
"html/template"
"net/http"
"net/url"
"strconv"
"time"
)
const (
userClaim = "user"
adminClaim = "admin"
userCreatedClaim = "user_created"
pageCreatedClaim = "page_created"
sharedClaim = "shared"
sectionClaim = "section"
titleClaim = "title"
totpClaim = "totp"
rememberClaim = "remember"
refererClaim = "referer"
empty = "(empty)"
)
func newContext(w http.ResponseWriter, r *http.Request, s *HTTPServer) *Context {
h := w.Header()
h.Set("X-Frame-Options", "DENY")
h.Set("X-Content-Type-Options", "nosniff")
h.Set("X-XSS-Protection", "1; mode=block")
h.Set("Content-Security-Policy", "default-src 'none';style-src 'self';img-src 'self' https: data:;connect-src 'self';frame-ancestors 'none'")
h.Set("Referrer-Policy", "same-origin")
return &Context{
Request: r,
Response: w,
Srv: s,
Time: time.Now(),
}
}
type Context struct {
Request *http.Request
Response http.ResponseWriter
Srv *HTTPServer
T *template.Template
Status int
Err error
Time time.Time
Token jwt.Token
Data webData
}
type webData struct {
Version string
Time string
Title string
BodyTitle string
Admin bool
Login bool
Key string
User string
Token string
Msg string
Search string
Data interface{}
}
type loginData struct {
User string
Referer string
}
type sectionData struct {
Name string
Members map[string]bool
}
type pageData struct {
Page core.Page
Sections map[string]bool
}
func (c *Context) Exec() {
defer c.log()
if c.T == nil {
c.Status = http.StatusInternalServerError
c.Err = errors.New("template is nil")
return
}
c.Data.Token = newXsrf(c.Token.RawSig())
c.Data.Version = c.Srv.Config.Version
if c.Data.BodyTitle == "" {
c.Data.BodyTitle = c.Data.Title
}
c.Data.Admin = c.Admin()
c.Data.User = c.User()
c.Data.Login = c.LoggedOn()
t := strconv.Itoa(int(time.Since(c.Time).Nanoseconds()/1e6)) + "ms"
if t == "0ms" {
t = strconv.Itoa(int(time.Since(c.Time).Nanoseconds()/1e5)) + "µs"
}
c.Data.Time = t
if c.Data.Msg != "" {
c.Status = http.StatusBadRequest
}
c.Err = c.T.Execute(c.Response, c.Data)
if c.Err != nil {
c.Status = http.StatusInternalServerError
}
if c.Status == 0 {
c.Status = http.StatusOK
}
}
func (c *Context) log() {
if c.Err != nil {
log.Printf("%s %s %s %d error: %s\n", c.Request.RemoteAddr, c.Request.Method, c.Request.URL, c.Status, c.Err)
return
}
log.Printf("%s %s %s %d\n", c.Request.RemoteAddr, c.Request.Method, c.Request.URL, c.Status)
}
func (c *Context) Error(i interface{}) {
switch v := i.(type) {
case string:
c.Data.Msg = v
case error:
c.Data.Msg = v.Error()
}
c.Exec()
}
func (c *Context) NotFound() {
c.Template("notFoundHandler")
c.Data = webData{Title: "404"}
c.Status = http.StatusNotFound
c.Response.WriteHeader(http.StatusNotFound)
c.Exec()
}
func (c *Context) Forbidden() {
c.Template("forbiddenHandler")
c.Data = webData{Title: "403"}
c.Status = http.StatusForbidden
c.Response.WriteHeader(http.StatusForbidden)
c.Exec()
}
func (c *Context) Template(name string) {
c.T = c.Srv.templ[name]
}
func (c *Context) Write(buf []byte) (err error) {
_, err = c.Response.Write(buf)
return
}
func (c *Context) SetHeader(name, value string) {
c.Response.Header().Set(name, value)
}
func (c *Context) Redirect(uri string, code int) {
if len(uri) > 0 && uri[0] != '/' {
uri = "/" + uri
}
log.Printf("%s %s %s %d %s\n", c.Request.RemoteAddr, c.Request.Method, c.Request.URL, code, uri)
http.Redirect(c.Response, c.Request, uri, code)
}
func (c *Context) Method() string {
return c.Request.Method
}
func (c *Context) Path() string {
return c.Request.URL.Path
}
func (c *Context) RefererURI() string {
u, err := url.Parse(c.Request.Header.Get("Referer"))
if err != nil {
return ""
}
return u.RequestURI()
}
func (c *Context) FormSlice(name string) []string {
// does nothing if called twice
c.Request.ParseForm()
return c.Request.PostForm[name]
}
func (c *Context) Form(name string) string {
return c.Request.PostFormValue(name)
}
func (c *Context) Var(name string) (ret string) {
ret = mux.Vars(c.Request)[name]
return
}
func (c *Context) Forwarded() (ret string) {
ret = c.Request.Header.Get("X-Forwarded-For")
if ret == "" {
ret = empty
}
return
}
// CheckXsrf validates the xsrf token
func (c *Context) CheckXsrf() (ok bool) {
ok = checkXsrf(c.Form("token"), c.Token.RawSig())
if !ok {
c.Error("wrong csrf token")
}
return
}
func (c *Context) LoggedOn() (ok bool) {
_, ok = c.Token.Claims.Get(userClaim)
return
}
func (c *Context) LogSetCookie(msg string, err error) {
log.Printf("ctx: %s %s\n", msg, err)
c.SetCookie(nil, 0)
}
func (c *Context) Login(u *core.User, remember string) {
var d time.Duration
if remember != "" {
d = day * 7
}
c.SetCookie(jwt.Claims{userClaim: u.Username, userCreatedClaim: u.Created, adminClaim: u.Admin}, d)
}
func (c *Context) SetCookie(claims jwt.Claims, d time.Duration) {
c.setCookie(jwt.NewToken(claims, nil), d)
}
func (c *Context) setCookie(t *jwt.Token, d time.Duration) {
if d == 0 {
d = jwt.DefaultExpiry
}
t.Claims[jwt.ExpClaim] = jwt.NewExp(d)
if err := c.Srv.JWT.Sign(t); err != nil {
log.Println(err)
return
}
c.Token = *t
cookie := &http.Cookie{
Name: cookieName,
Value: c.Token.String(),
Path: "/",
MaxAge: int(d.Seconds()),
Secure: c.Srv.Config.SecureCookie,
HttpOnly: true,
SameSite: http.SameSiteStrictMode,
}
if cookie.MaxAge > 0 {
cookie.Expires = time.Now().Add(d)
} else if cookie.MaxAge < 0 {
cookie.Expires = time.Unix(1, 0)
}
http.SetCookie(c.Response, cookie)
}
func (c *Context) User() string {
return c.Token.Claims.GetString(userClaim)
}
func (c *Context) Totp() string {
return c.Token.Claims.GetString(totpClaim)
}
func (c *Context) Remember() string {
return c.Token.Claims.GetString(rememberClaim)
}
func (c *Context) Referer() string {
return c.Token.Claims.GetString(refererClaim)
}
func (c *Context) Admin() bool {
return c.Token.Claims.GetBool(adminClaim)
}
type ctxHandler func(*Context)

890
pkg/server/handler.go Normal file
View file

@ -0,0 +1,890 @@
// Copyright (C) 2021 Marius Schellenberger
package server
import (
"git.giftfish.de/ston1th/gowiki/pkg/core"
"git.giftfish.de/ston1th/gowiki/pkg/log"
"git.giftfish.de/ston1th/gowiki/pkg/otp"
"git.giftfish.de/ston1th/gowiki/pkg/render"
"git.giftfish.de/ston1th/jwt/v3"
"net/http"
"time"
)
type notFoundHandler struct {
s *HTTPServer
}
func (nf *notFoundHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
newContext(w, r, nf.s).NotFound()
}
func jwtHandler(h ctxHandler) ctxHandler {
return func(ctx *Context) {
if c, err := ctx.Request.Cookie(cookieName); err == nil {
t, err := jwt.DecodeToken(c.Value)
if err != nil {
ctx.LogSetCookie("DecodeToken:", err)
ctx.Redirect(core.IndexURI, http.StatusFound)
return
}
if err = ctx.Srv.JWT.Verify(t); err != nil {
ctx.LogSetCookie("VerifyToken:", err)
ctx.Redirect(core.IndexURI, http.StatusFound)
return
}
if t.Claims.GetString(totpClaim) != "" {
path := ctx.Path()
if path == core.TotpURI || path == core.LoginURI || path == core.LogoutURI {
ctx.Token = *t
h(ctx)
return
}
ctx.Redirect(core.TotpURI, http.StatusFound)
return
}
if !ctx.Srv.DB.LockedOut(t.Claims.GetString(userClaim), t.Claims.GetString(userCreatedClaim)) {
ctx.Token = *t
h(ctx)
return
}
if err = ctx.Srv.JWT.Invalidate(t); err != nil {
log.Println("Invalidate:", err)
}
}
ctx.SetCookie(nil, 0)
h(ctx)
}
}
func totpAuthHandler(h ctxHandler) ctxHandler {
return func(ctx *Context) {
if ctx.Totp() != "" {
h(ctx)
return
}
ctx.Redirect(core.IndexURI, http.StatusFound)
}
}
func adminAuthHandler(h ctxHandler) ctxHandler {
return func(ctx *Context) {
if ctx.Admin() {
h(ctx)
return
}
ctx.Forbidden()
}
}
func userAuthHandler(h ctxHandler) ctxHandler {
return func(ctx *Context) {
if ctx.Admin() {
h(ctx)
return
}
if ctx.User() == ctx.Var("user") {
h(ctx)
return
}
ctx.Forbidden()
}
}
func authHandler(h ctxHandler) ctxHandler {
return func(ctx *Context) {
if ctx.LoggedOn() {
h(ctx)
return
}
ctx.Forbidden()
}
}
func indexHandler(ctx *Context) {
ctx.Redirect(core.IndexURI, http.StatusFound)
}
func loginHandler(ctx *Context) {
if ctx.LoggedOn() {
ctx.Redirect(core.IndexURI, http.StatusFound)
return
}
ctx.Template("loginHandler")
ctx.Data = webData{
Title: "Login",
BodyTitle: "Login",
}
var data loginData
switch ctx.Method() {
case "GET":
data.Referer = ctx.RefererURI()
ctx.Data.Data = data
ctx.Exec()
case "POST":
if !ctx.CheckXsrf() {
return
}
data.User = ctx.Form("user")
data.Referer = ctx.Form("referer")
ctx.Data.Data = data
if data.Referer == "" {
data.Referer = core.IndexURI
}
password := ctx.Form("password")
if data.User == "" || password == "" {
ctx.Error("empty username or password")
return
}
u, err := ctx.Srv.DB.Login(data.User, password)
if err != nil {
log.Printf("login: %s from: %s %s", data.User, ctx.Forwarded(), err)
ctx.Error("bad username or password")
return
}
if u.Secret == "" {
log.Println("login:", u.Username)
ctx.Login(u, ctx.Form("remember"))
ctx.Redirect(data.Referer, http.StatusFound)
return
}
ctx.SetCookie(jwt.Claims{
totpClaim: u.Username,
rememberClaim: ctx.Form("remember"),
refererClaim: data.Referer,
}, time.Minute)
ctx.Redirect(core.TotpURI, http.StatusFound)
}
}
func loginTotpHandler(ctx *Context) {
if ctx.LoggedOn() {
ctx.Redirect(core.IndexURI, http.StatusFound)
return
}
ctx.Template("loginTotpHandler")
ctx.Data = webData{
Title: "TOTP Verification",
BodyTitle: "TOTP Veriftcation",
}
switch ctx.Method() {
case "GET":
ctx.Exec()
case "POST":
if !ctx.CheckXsrf() {
return
}
pin := ctx.Form("pin")
if pin == "" {
ctx.Error("empty pin")
return
}
user := ctx.Totp()
u, err := ctx.Srv.DB.Totp(user, pin)
if err != nil {
log.Printf("totp: %s from: %s %s", user, ctx.Forwarded(), err)
ctx.Error(err)
return
}
ref := ctx.Referer()
log.Println("login:", u.Username)
ctx.Login(u, ctx.Remember())
ctx.Redirect(ref, http.StatusFound)
}
}
func searchHandler(ctx *Context) {
ctx.Template("searchHandler")
ctx.Data = webData{
Title: "Search",
BodyTitle: "Search",
}
switch ctx.Method() {
case "GET":
ctx.Exec()
case "POST":
if !ctx.CheckXsrf() {
return
}
search := ctx.Form("search")
res, err := ctx.Srv.DB.Index.Search(search)
if err != nil {
ctx.Error(err)
return
}
user := ctx.User()
r := make([]core.Result, len(res))
i := 0
for _, v := range res {
if ctx.Srv.DB.ValidatePage(v.StoreTitle, user) {
r[i] = v
i++
}
}
ctx.Data.Data = r[:i]
ctx.Data.Search = search
ctx.Exec()
}
}
func sectionsHandler(ctx *Context) {
ctx.Template("sectionsHandler")
ctx.Data = webData{
Title: "All Sections",
BodyTitle: "All Sections",
Admin: ctx.Admin(),
}
ctx.Data.Data = ctx.Srv.DB.GetAllSections(ctx.User(), ctx.Admin())
ctx.Exec()
}
func sectionHandler(ctx *Context) {
ctx.Template("sectionHandler")
section := ctx.Var("section")
ctx.Data = webData{
Title: section,
BodyTitle: section,
Admin: ctx.Admin(),
}
var err error
ctx.Data.Data, err = ctx.Srv.DB.GetAllSectionPages(section, ctx.User(), ctx.Admin())
if err != nil {
ctx.NotFound()
return
}
ctx.Exec()
}
func sectionNewHandler(ctx *Context) {
ctx.Template("sectionNewHandler")
ctx.Data = webData{
Title: "New Section",
BodyTitle: "New Section",
}
users, err := ctx.Srv.DB.GetUsers()
if err != nil {
ctx.Error(err)
return
}
sd := sectionData{Members: make(map[string]bool)}
for _, u := range users {
sd.Members[u.Username] = false
}
switch ctx.Method() {
case "GET":
ctx.Data.Data = sd
ctx.Exec()
case "POST":
if !ctx.CheckXsrf() {
return
}
section := ctx.Form("section")
members := ctx.FormSlice("members")
for _, m := range members {
sd.Members[m] = true
}
ctx.Data.Data = sd
if section == "" {
ctx.Error("empty section")
return
}
err = ctx.Srv.DB.CreateSection(section, members, false)
if err != nil {
ctx.Error(err)
return
}
log.Printf("create: section %s created by %s\n", section, ctx.User())
ctx.Redirect(core.SectionsURI, http.StatusFound)
}
}
func sectionEditHandler(ctx *Context) {
ctx.Template("sectionEditHandler")
ctx.Data = webData{
Title: "Edit Section",
BodyTitle: "Edit Section",
}
users, err := ctx.Srv.DB.GetUsers()
if err != nil {
ctx.Error(err)
return
}
sd := sectionData{Members: make(map[string]bool)}
for _, u := range users {
sd.Members[u.Username] = false
}
s, err := ctx.Srv.DB.GetSection(ctx.Var("section"))
if err != nil {
ctx.Error(err)
return
}
sd.Name = s.Name
switch ctx.Method() {
case "GET":
for _, m := range s.Members {
sd.Members[m] = true
}
ctx.Data.Data = sd
ctx.Exec()
case "POST":
if !ctx.CheckXsrf() {
return
}
members := ctx.FormSlice("members")
for _, m := range members {
sd.Members[m] = true
}
ctx.Data.Data = sd
err = ctx.Srv.DB.UpdateSection(s.Name, members, s.User)
if err != nil {
ctx.Error(err)
return
}
log.Printf("update: section %s updated by %s\n", s.Name, ctx.User())
ctx.Redirect(core.SectionsURI, http.StatusFound)
}
}
func sectionDelHandler(ctx *Context) {
ctx.Template("sectionDelHandler")
ctx.Data = webData{
Title: "Delete Section",
BodyTitle: "Delete Section",
}
s, err := ctx.Srv.DB.GetSection(ctx.Var("section"))
if err != nil {
ctx.Error(err)
return
}
if s.User {
ctx.Error("user section can not be deleted")
return
}
switch ctx.Method() {
case "GET":
ctx.Data.Data = s.Name
ctx.Exec()
case "POST":
if !ctx.CheckXsrf() {
return
}
err := ctx.Srv.DB.DeleteSection(s.Name)
if err != nil {
ctx.Error(err)
return
}
log.Printf("delete: section %s deleted by %s\n", s.Name, ctx.User())
ctx.Redirect(core.SectionsURI, http.StatusFound)
}
}
func pageNewHandler(ctx *Context) {
ctx.Template("pageNewHandler")
ctx.Data = webData{Title: "New Page"}
user := ctx.User()
secs, err := ctx.Srv.DB.GetUserSections(user)
if err != nil {
ctx.Error(err)
return
}
switch ctx.Method() {
case "GET":
secs[user] = true
ctx.Data.Data = pageData{core.Page{Perm: core.Public}, secs}
ctx.Exec()
case "POST":
if !ctx.CheckXsrf() {
return
}
section := ctx.Form("section")
title := ctx.Form("title")
markdown := ctx.Form("markdown")
perm := core.ParsePermString(ctx.Form("perm"))
p := core.Page{
Title: title,
Markdown: markdown,
Perm: perm,
}
secs[section] = true
ctx.Data.Data = pageData{p, secs}
if title == "" {
ctx.Error("empty title")
return
}
if perm == core.Invalid {
ctx.Error("invalid permission")
return
}
page, err := ctx.Srv.DB.CreatePage(title, section, markdown, user, perm)
if err != nil {
ctx.Error(err)
return
}
ctx.Redirect(page.StoreTitle, http.StatusFound)
}
}
func pageHandler(ctx *Context) {
var pageCreated string
ctx.Data = webData{
Admin: ctx.Admin(),
}
section := ctx.Var("section")
title := ctx.Var("title")
user := ctx.User()
key := ctx.Var("key")
if key != "" {
t, err := jwt.DecodeToken(key)
if err != nil {
log.Println("share: DecodeToken:", err)
ctx.NotFound()
return
}
if err = ctx.Srv.JWT.Verify(t); err != nil {
log.Println("share: VerifyToken:", err)
ctx.NotFound()
return
}
user = t.Claims.GetString(sharedClaim)
if ctx.Srv.DB.LockedOut(user, t.Claims.GetString(userCreatedClaim)) {
if err = ctx.Srv.JWT.Invalidate(t); err != nil {
log.Println("share: Invalidate:", err)
}
ctx.NotFound()
return
}
section = t.Claims.GetString(sectionClaim)
title = t.Claims.GetString(titleClaim)
pageCreated = t.Claims.GetString(pageCreatedClaim)
ctx.Data.Key = key
if ctx.T == nil {
ctx.Template("pageViewHandler")
}
} else if ctx.T == nil {
ctx.Template("pageHandler")
}
page, err := ctx.Srv.DB.GetPage(section, title, user)
if err != nil {
log.Println(err)
ctx.NotFound()
return
}
if key != "" && page.Created != pageCreated {
ctx.NotFound()
return
}
ctx.Data.Data = page
ctx.Data.Title = section + " | " + page.Title
ctx.Data.BodyTitle = section
ctx.Exec()
}
func pageMDHandler(f ctxHandler) ctxHandler {
return func(ctx *Context) {
ctx.Template("pageMDHandler")
f(ctx)
}
}
func pageShareHandler(ctx *Context) {
ctx.Template("pageShareHandler")
ctx.Data = webData{}
section := ctx.Var("section")
title := ctx.Var("title")
user := ctx.User()
page, err := ctx.Srv.DB.GetPage(section, title, user)
if err != nil {
ctx.NotFound()
return
}
if page.Perm != core.Private {
ctx.Error("only private pages can be shared")
return
}
ctx.Data.Data = page.StoreTitle
ctx.Data.Title = page.Title
ctx.Data.BodyTitle = page.Title
switch ctx.Method() {
case "GET":
ctx.Exec()
case "POST":
if !ctx.CheckXsrf() {
return
}
d, err := duration(ctx.Form("duration"), ctx.Form("mode"))
if err != nil {
ctx.Error(err)
return
}
t := jwt.NewToken(map[string]interface{}{
sharedClaim: user,
userCreatedClaim: ctx.Token.Claims.GetString(userCreatedClaim),
pageCreatedClaim: page.Created,
sectionClaim: section,
titleClaim: title,
jwt.ExpClaim: jwt.NewExp(d),
}, nil)
if err = ctx.Srv.JWT.Sign(t); err != nil {
log.Println("share: SignToken:", err)
ctx.NotFound()
return
}
ctx.Data.Key = t.String()
ctx.Exec()
}
}
func pageBlacklistHandler(ctx *Context) {
ctx.Template("pageBlacklistHandler")
ctx.Data = webData{
Title: "Blacklist",
BodyTitle: "Blacklist",
}
switch ctx.Method() {
case "GET":
ctx.Exec()
case "POST":
if !ctx.CheckXsrf() {
return
}
t, err := jwt.DecodeToken(ctx.Form("share"))
if err != nil {
ctx.Error(err)
return
}
err = ctx.Srv.JWT.Verify(t)
if err != nil {
ctx.Error(err)
return
}
user := t.Claims.GetString(sharedClaim)
if user == "" {
ctx.Error("blacklisting denied: no share token")
return
}
if ctx.User() != user {
ctx.Error("blacklisting denied: token was issued by " + user)
return
}
err = ctx.Srv.JWT.Invalidate(t)
if err != nil {
ctx.Error(err)
return
}
ctx.Exec()
}
}
func pageEditHandler(ctx *Context) {
ctx.Template("pageEditHandler")
ctx.Data = webData{}
section := ctx.Var("section")
title := ctx.Var("title")
page, err := ctx.Srv.DB.GetPage(section, title, ctx.User())
if err != nil {
ctx.NotFound()
return
}
ctx.Data.Title = page.Title
ctx.Data.BodyTitle = page.Title
switch ctx.Method() {
case "GET":
ctx.Data.Data = page
ctx.Exec()
case "POST":
if !ctx.CheckXsrf() {
return
}
page.Markdown = ctx.Form("markdown")
page.Perm = core.ParsePermString(ctx.Form("perm"))
ctx.Data.Data = page
err = ctx.Srv.DB.UpdatePage(title, section, page.Markdown, ctx.User(), page.Perm)
if err != nil {
ctx.Error(err)
return
}
ctx.Redirect(page.StoreTitle, http.StatusFound)
}
}
func pageDelHandler(ctx *Context) {
section := ctx.Var("section")
title := ctx.Var("title")
ctx.Template("pageDelHandler")
ctx.Data.Data = render.StoreTitle(section, title)
ctx.Data.Title = title
ctx.Data.BodyTitle = title
switch ctx.Method() {
case "GET":
ctx.Exec()
case "POST":
if !ctx.CheckXsrf() {
return
}
if err := ctx.Srv.DB.DeletePage(title, section, ctx.User()); err != nil {
ctx.Error(err)
return
}
ctx.Redirect(core.IndexURI, http.StatusFound)
}
}
func usersHandler(ctx *Context) {
if !ctx.LoggedOn() {
ctx.NotFound()
return
}
ctx.Template("usersHandler")
ctx.Data = webData{
Title: "Users",
BodyTitle: "Users",
User: ctx.User(),
}
us, err := ctx.Srv.DB.GetUsers()
if err != nil {
ctx.Data.Msg = err.Error()
}
ctx.Data.Data = us
ctx.Exec()
}
func userNewHandler(ctx *Context) {
ctx.Template("userNewHandler")
ctx.Data = webData{
Title: "New User",
BodyTitle: "New User",
}
switch ctx.Method() {
case "GET":
ctx.Exec()
case "POST":
if !ctx.CheckXsrf() {
return
}
user := ctx.Form("user")
password := ctx.Form("password")
repeat := ctx.Form("repeat")
admin := ctx.Form("admin")
adm := false
if admin == "0" {
adm = true
}
if user == "" || password == "" {
ctx.Error("empty user or password")
return
}
if password != repeat {
ctx.Error("passwords do not match")
return
}
if err := ctx.Srv.DB.CreateUser(user, password, adm); err != nil {
ctx.Error(err)
return
}
log.Printf("create: user %s created by %s\n", user, ctx.User())
ctx.Redirect(core.UsersURI, http.StatusFound)
}
}
func userEditHandler(ctx *Context) {
ctx.Template("userEditHandler")
ctx.Data = webData{
Title: "Edit User",
BodyTitle: "Edit User",
}
u, err := ctx.Srv.DB.GetUserWithoutPassword(ctx.Var("user"))
if err != nil {
ctx.Error(err)
return
}
ctx.Data.Data = u
switch ctx.Method() {
case "GET":
ctx.Exec()
case "POST":
if !ctx.CheckXsrf() {
return
}
user := ctx.Var("user")
password := ctx.Form("password")
repeat := ctx.Form("repeat")
admin := ctx.Form("admin")
adm := false
if admin == "0" {
adm = true
}
if password != repeat {
ctx.Error("passwords do not match")
return
}
if ctx.Admin() {
if err := ctx.Srv.DB.AdminUpdateUser(user, password, adm); err != nil {
ctx.Error(err)
return
}
log.Printf("update: user %s updated by %s\n", user, ctx.User())
ctx.Redirect(core.UsersURI, http.StatusFound)
return
}
if err := ctx.Srv.DB.UpdateUserPassword(user, password); err != nil {
ctx.Error(err)
return
}
log.Printf("update: %s updated by %s\n", user, ctx.User())
ctx.Redirect(core.IndexURI, http.StatusFound)
}
}
func userTotpHandler(ctx *Context) {
ctx.Template("userTotpHandler")
ctx.Data = webData{
Title: "TOTP",
BodyTitle: "TOTP",
}
req := otp.Request{Username: ctx.Var("user")}
u, err := ctx.Srv.DB.GetUser(req.Username)
if err != nil {
ctx.Error(err)
return
}
if u.Secret == "" {
switch ctx.Method() {
case "GET":
req, err = otp.New(req.Username)
if err != nil {
ctx.Error(err)
return
}
case "POST":
req.URL = ctx.Form("url")
req.Image, req.Secret, err = otp.ImageSecretFromURL(req.URL)
if err != nil {
ctx.Error(err)
return
}
}
}
switch ctx.Method() {
case "GET":
ctx.Data.Data = req
ctx.Exec()
case "POST":
if !ctx.CheckXsrf() {
return
}
pin := ctx.Form("pin")
if pin == "" {
ctx.Error("empty pin")
return
}
ctx.Data.Data = req
secret := req.Secret
if u.Secret != "" {
secret = u.Secret
}
valid := otp.Validate(pin, secret)
user := ctx.User()
if user != req.Username && ctx.Admin() {
valid = true
}
if !valid {
ctx.Error("validation failed")
return
}
if err := ctx.Srv.DB.UpdateUserSecret(req.Username, req.Secret); err != nil {
ctx.Error(err)
return
}
if req.Secret == "" {
log.Printf("totp: disabled by %s for %s\n", user, req.Username)
} else {
log.Printf("totp: enabled by %s for %s\n", user, req.Username)
}
ctx.Redirect(core.UserURI+"/edit/"+req.Username, http.StatusFound)
}
}
func userUnlockHandler(ctx *Context) {
user := ctx.Var("user")
if err := ctx.Srv.DB.UnlockUser(user); err != nil {
log.Println("unlock:", err)
}
log.Printf("unlock: %s unlocked by %s\n", user, ctx.User())
ctx.Redirect(core.UsersURI, http.StatusFound)
}
func userDelHandler(ctx *Context) {
ctx.Template("userDelHandler")
ctx.Data = webData{
Title: "Delete User",
BodyTitle: "Delete User",
}
switch ctx.Method() {
case "GET":
user, err := ctx.Srv.DB.GetUserWithoutPassword(ctx.Var("user"))
if err != nil {
ctx.Error(err)
return
}
ctx.Data.Data = user
ctx.Exec()
case "POST":
if !ctx.CheckXsrf() {
return
}
self := ctx.User()
user := ctx.Var("user")
if err := ctx.Srv.DB.DeleteUser(user); err != nil {
log.Println("delete:", err)
}
log.Printf("delete: user %s deleted by %s\n", user, self)
if user == self {
ctx.Redirect(core.LogoutURI, http.StatusFound)
return
}
ctx.Redirect(core.UsersURI, http.StatusFound)
}
}
func statsHandler(ctx *Context) {
ctx.Template("statsHandler")
ctx.Data = webData{
Title: "Stats",
BodyTitle: "Stats",
}
if ctx.Method() == "GET" {
ctx.Data.Data = getStats(ctx.Srv.DB.Index)
ctx.Exec()
}
}
func logoutHandler(ctx *Context) {
var totp string
user := ctx.User()
if user == "" {
user = ctx.Totp()
if user != "" {
totp = " (totp)"
}
}
if user != "" {
ctx.Srv.JWT.Invalidate(&ctx.Token)
} else {
user = empty
}
log.Printf("logout: %s%s", user, totp)
ctx.SetCookie(nil, 0)
ctx.Redirect(core.IndexURI, http.StatusFound)
}

66
pkg/server/helper.go Normal file
View file

@ -0,0 +1,66 @@
// Copyright (C) 2021 Marius Schellenberger
package server
import (
"errors"
"git.giftfish.de/ston1th/gowiki/pkg/core"
bleve "git.giftfish.de/ston1th/gowiki/pkg/index"
"runtime"
"strconv"
"time"
)
const (
day = time.Hour * 24
min = time.Second * 30
max = day * 90
div = 1048576
)
func duration(duration, mode string) (d time.Duration, err error) {
switch mode {
case "1":
d, err = time.ParseDuration(duration)
if err != nil {
err = errors.New("could not parse duration")
return
}
case "2":
var t int
t, err = strconv.Atoi(duration)
if err != nil {
err = errors.New("could not parse duration")
return
}
d = time.Duration(int(day) * t)
default:
err = errors.New("invalid mode")
return
}
if d < min {
err = errors.New("minimum duration is 30 seconds")
}
if d > max {
err = errors.New("maximum duration is 90 days")
}
return
}
func fmtMem(i uint64) string {
return strconv.FormatFloat(float64(i)/div, 'f', 2, 64)
}
func getStats(i *bleve.Index) *core.Stats {
mem := new(runtime.MemStats)
runtime.ReadMemStats(mem)
return &core.Stats{
Goroutines: runtime.NumGoroutine(),
Alloc: fmtMem(mem.Alloc),
Sys: fmtMem(mem.Sys),
Docs: i.DocCount(),
GoVersion: runtime.Version(),
}
}

187
pkg/server/routes.go Normal file
View file

@ -0,0 +1,187 @@
// Copyright (C) 2021 Marius Schellenberger
package server
import "git.giftfish.de/ston1th/gowiki/pkg/core"
type route struct {
Path string
Handler ctxHandler
Methods []string
}
var routes = []route{
{
core.RootURI,
jwtHandler(
indexHandler),
[]string{"GET"},
},
{
core.SectionsURI,
jwtHandler(
sectionsHandler),
[]string{"GET"},
},
{
core.SectionURI + "/new",
jwtHandler(
adminAuthHandler(
sectionNewHandler)),
[]string{"GET", "POST"},
},
{
core.SectionURI + "/edit/{section:[a-zA-Z0-9]+$}",
jwtHandler(
adminAuthHandler(
sectionEditHandler)),
[]string{"GET", "POST"},
},
{
core.SectionURI + "/del/{section:[a-zA-Z0-9]+$}",
jwtHandler(
adminAuthHandler(
sectionDelHandler)),
[]string{"GET", "POST"},
},
{
core.SearchURI,
jwtHandler(
searchHandler),
[]string{"GET", "POST"},
},
{
core.NewURI,
jwtHandler(
authHandler(
pageNewHandler)),
[]string{"GET", "POST"},
},
{
core.LoginURI,
jwtHandler(
loginHandler),
[]string{"GET", "POST"},
},
{
core.TotpURI,
jwtHandler(
totpAuthHandler(
loginTotpHandler)),
[]string{"GET", "POST"},
},
{
core.BlacklistURI,
jwtHandler(
authHandler(
pageBlacklistHandler)),
[]string{"GET", "POST"},
},
{
core.UsersURI,
jwtHandler(
adminAuthHandler(
usersHandler)),
[]string{"GET"},
},
{
core.UserURI + "/new",
jwtHandler(
adminAuthHandler(
userNewHandler)),
[]string{"GET", "POST"},
},
{
core.UserURI + "/edit/{user:[a-zA-Z0-9]+$}",
jwtHandler(
userAuthHandler(
userEditHandler)),
[]string{"GET", "POST"},
},
{
core.UserURI + "/del/{user:[a-zA-Z0-9]+$}",
jwtHandler(
userAuthHandler(
userDelHandler)),
[]string{"GET", "POST"},
},
{
core.UserURI + "/totp/{user:[a-zA-Z0-9]+$}",
jwtHandler(
userAuthHandler(
userTotpHandler)),
[]string{"GET", "POST"},
},
{
core.UserURI + "/unlock/{user:[a-zA-Z0-9]+$}",
jwtHandler(
adminAuthHandler(
userUnlockHandler)),
[]string{"POST"},
},
{
core.StatsURI,
jwtHandler(
adminAuthHandler(
statsHandler)),
[]string{"GET"},
},
{
core.LogoutURI,
jwtHandler(
logoutHandler),
[]string{"GET", "POST"},
},
{
core.ViewURI + "/{key}",
jwtHandler(
pageHandler),
[]string{"GET"},
},
{
core.ViewURI + "/{key}/md",
jwtHandler(
pageMDHandler(
pageHandler)),
[]string{"GET"},
},
{
"/{section:[a-zA-Z0-9]+}",
jwtHandler(
sectionHandler),
[]string{"GET"},
},
{
"/{section:[a-zA-Z0-9]+}/{title:[a-zA-Z0-9+-]+}",
jwtHandler(
pageHandler),
[]string{"GET"},
},
{
"/{section:[a-zA-Z0-9]+}/{title:[a-zA-Z0-9+-]+}/md",
jwtHandler(
pageMDHandler(
pageHandler)),
[]string{"GET"},
},
{
"/{section:[a-zA-Z0-9]+}/{title:[a-zA-Z0-9+-]+}/share",
jwtHandler(
pageShareHandler),
[]string{"GET", "POST"},
},
{
"/{section:[a-zA-Z0-9]+}/{title:[a-zA-Z0-9+-]+}/edit",
jwtHandler(
authHandler(
pageEditHandler)),
[]string{"GET", "POST"},
},
{
"/{section:[a-zA-Z0-9]+}/{title:[a-zA-Z0-9+-]+}/del",
jwtHandler(
authHandler(
pageDelHandler)),
[]string{"GET", "POST"},
},
}

99
pkg/server/server.go Normal file
View file

@ -0,0 +1,99 @@
// Copyright (C) 2021 Marius Schellenberger
package server
import (
"bytes"
"context"
"embed"
"errors"
"git.giftfish.de/ston1th/godrop/v2"
"git.giftfish.de/ston1th/gowiki/pkg/core"
"git.giftfish.de/ston1th/gowiki/pkg/db"
"git.giftfish.de/ston1th/gowiki/pkg/log"
"git.giftfish.de/ston1th/jwt/v3"
"github.com/gorilla/mux"
"html/template"
"net"
"net/http"
"time"
)
const (
cookieName = "gowiki_session"
)
type HTTPServer struct {
Config *core.Config
listener net.Listener
srv *http.Server
DB *db.DB
JWT *jwt.JWT
templ map[string]*template.Template
res map[string][]byte
}
func NewHTTPServer(cfg *core.Config, l net.Listener) (srv *HTTPServer) {
srv = &HTTPServer{
Config: cfg,
listener: l,
}
srv.loadTemplates()
srv.srv = &http.Server{
Handler: srv.buildRoutes(),
}
return
}
func (s *HTTPServer) Start() (err error) {
s.DB, err = db.New(s.Config)
if err != nil {
return errors.New("db: " + err.Error())
}
err = godrop.UnveilBlock()
if err != nil {
return
}
opts := []jwt.JWTOption{jwt.WithBlacklist(s.DB), jwt.WithNonce()}
if s.Config.Secret != "" {
opts = append(opts, jwt.WithSecret(bytes.NewBufferString(s.Config.Secret)))
}
s.JWT, err = jwt.New(opts...)
if err != nil {
return errors.New("server: " + err.Error())
}
go func() {
log.Println(s.srv.Serve(s.listener))
}()
return
}
func (s *HTTPServer) Stop() error {
s.DB.Close()
s.JWT.Stop()
ctx, cancel := context.WithTimeout(context.Background(), time.Second*5)
defer cancel()
return s.srv.Shutdown(ctx)
}
//go:embed static/*
var static embed.FS
func (s *HTTPServer) buildRoutes() http.Handler {
m := mux.NewRouter()
m.NotFoundHandler = &notFoundHandler{s}
m.Handle("/static/{file}", http.StripPrefix("/", http.FileServer(http.FS(static))))
for _, v := range routes {
m.HandleFunc(v.Path, s.contextWrapper(v.Handler)).Methods(v.Methods...)
}
return m
}
func (s *HTTPServer) contextWrapper(h ctxHandler) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
h(newContext(w, r, s))
}
}

12
pkg/server/static/bootstrap.css vendored Normal file

File diff suppressed because one or more lines are too long

View file

@ -22,7 +22,12 @@
float: left !important;
}
}
body {
padding-top: 60px;
}
.msg {
margin-top: 30px;
}
a.dark {
color: #222222;
}
@ -30,6 +35,16 @@ a.para {
text-decoration: none;
font-size: 18px;
}
nav {
padding-top: 20px;
}
*[id]:before {
display: block;
content: " ";
margin-top: -75px;
height: 75px;
visibility: hidden;
}
.alert-primary {
background-color: #375a7f;
border-color: #375a7f;
@ -37,8 +52,6 @@ a.para {
}
h1,h2,h3,h4,h5,h6 {
font-weight: bold;
padding-top: 60px !important;
margin-top: -50px !important;
-webkit-background-clip: content-box !important;
background-clip: content-box !important;
}
@ -54,10 +67,7 @@ h3 {
h4 {
font-size: 1.25em;
}
h5 {
font-size: 1em;
}
h6 {
h5,h6 {
font-size: 1em;
}
.md-text {
@ -77,14 +87,64 @@ pre {
border: 1px solid #444444;
border-radius: 2px;
}
pre.wrap {
white-space: pre-wrap;
}
code {
padding: 3px 3px 1px 3px;
padding: 3px 3px 1px 0px;
font-size: 90%;
color: #0cdba6;
background-color: #444444;
border-radius: 2px;
}
textarea,input {
textarea,input,select {
color: #e2e2e2 !important;
background-color: #444444 !important;
}
select.sections {
height: 120px !important;
}
input.menu {
color: #e2e2e2 !important;
background-color: #222222 !important;
}
.menu-search-bar {
padding-top: 4px;
}
.menu-search-input {
background-color: #2f2f2f !important;
}
html,body,.container,.content {
height: 100% !important;
}
.container,.content {
position: relative;
}
.proper-content {
padding-top: 40px;
}
.section {
margin-bottom: 20px;
width: 250px;
}
.wrapper {
min-height: 100%;
height: auto !important;
height: 100%;
margin: 0 auto -60px;
}
.push {
height: 60px;
}
.full-width {
width: 100%;
}
.relative {
position: relative;
}
.btn-breadcrumb {
position: absolute;
right: 1rem;
top: 38%;
transform: translateY(-50%);
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

67
pkg/server/templates.go Normal file
View file

@ -0,0 +1,67 @@
// Copyright (C) 2021 Marius Schellenberger
package server
import (
"embed"
"errors"
"git.giftfish.de/ston1th/gowiki/pkg/log"
"html/template"
"io/fs"
)
//go:embed templates/*
var templates embed.FS
func (s *HTTPServer) loadTemplates() {
var fatal bool
s.templ = make(map[string]*template.Template)
s.res = make(map[string][]byte)
tfs, err := fs.Sub(templates, "templates")
if err != nil {
log.Fatal("parse: ", err)
}
parse := func(html ...string) (temp *template.Template) {
var err error
temp, err = template.ParseFS(tfs, html...)
if err != nil {
log.Println("parse:", err)
fatal = true
}
return
}
// pages
s.templ["notFoundHandler"] = parse("index.html", "menu.html", "notFound.html")
s.templ["forbiddenHandler"] = parse("index.html", "menu.html", "forbidden.html")
s.templ["loginHandler"] = parse("index.html", "menu.html", "login.html")
s.templ["loginTotpHandler"] = parse("index.html", "menu.html", "loginTotp.html")
s.templ["searchHandler"] = parse("index.html", "menu.html", "search.html")
s.templ["pageHandler"] = parse("index.html", "menu.html", "page.html")
s.templ["pageNewHandler"] = parse("index.html", "menu.html", "pageNew.html")
s.templ["pageEditHandler"] = parse("index.html", "menu.html", "pageEdit.html")
s.templ["pageViewHandler"] = parse("index.html", "menu.html", "pageView.html")
s.templ["pageMDHandler"] = parse("index.html", "menu.html", "pageMD.html")
s.templ["pageShareHandler"] = parse("index.html", "menu.html", "pageShare.html")
s.templ["pageBlacklistHandler"] = parse("index.html", "menu.html", "pageBlacklist.html")
s.templ["pageDelHandler"] = parse("index.html", "menu.html", "pageDel.html")
// sections
s.templ["sectionHandler"] = parse("index.html", "menu.html", "section.html")
s.templ["sectionsHandler"] = parse("index.html", "menu.html", "sections.html")
s.templ["sectionNewHandler"] = parse("index.html", "menu.html", "sectionNew.html")
s.templ["sectionEditHandler"] = parse("index.html", "menu.html", "sectionEdit.html")
s.templ["sectionDelHandler"] = parse("index.html", "menu.html", "sectionDel.html")
// users
s.templ["usersHandler"] = parse("index.html", "menu.html", "users.html")
s.templ["userNewHandler"] = parse("index.html", "menu.html", "userNew.html")
s.templ["userEditHandler"] = parse("index.html", "menu.html", "userEdit.html")
s.templ["userDelHandler"] = parse("index.html", "menu.html", "userDel.html")
// totp
s.templ["userTotpHandler"] = parse("index.html", "menu.html", "userTotp.html")
// stats
s.templ["statsHandler"] = parse("index.html", "menu.html", "stats.html")
if fatal {
log.Fatal("parse: ", errors.New("parsing templates failed"))
}
}

View file

@ -0,0 +1,17 @@
{{define "body"}}
<div class="page-header">
<div class="row">
<div class="col-xs-4 offset-4">
<div class="card border-danger mx-auto">
<div class="card-header">
<strong>403 - Forbidden</strong>
</div>
<div class="card-body">
<h5>Access to this page is restricted</h5>
<a href="/" class="btn btn-sm btn-primary">Back</a>
</div>
</div>
</div>
</div>
</div>
{{end}}

View file

@ -0,0 +1,46 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>GoWiki | {{.Title}}</title>
<link rel="shortcut icon" type="image/x-icon" href="/static/favicon.ico" integrity="sha256-bgotk/IPq4Yvt6s8AQGPTM5ZjPjQ6rrBRa2EyxpnFSc="></link>
<link rel="stylesheet" type="text/css" href="/static/bootstrap.css" media="screen" integrity="sha256-3YeveuySvBgp2GItUS8eV1R/9T1zcPMyvw22SyVf8qo="></link>
<link rel="stylesheet" type="text/css" href="/static/custom.css" media="screen" integrity="sha256-IwnADqysqqVpTpQ4sbG3Bz7v8FM4lqMiozQPozsFBUQ="></link>
<meta name="theme-color" content="#375a7f">
<meta name="description" content="{{.Title}}">
</head>
<body id="top">
<div class="navbar navbar-expand fixed-top navbar-dark bg-primary">
<div class="container">
<a href="/" class="navbar-brand">GoWiki</a>
<div class="collapse navbar-collapse">
{{template "menu" .}}
</div>
</div>
</div>
<div class="container">
<div class="proper-content">
{{if .Msg}}
<div class="alert alert-danger msg">
<h4>Error!</h4>
<p>{{.Msg}}</p>
</div>
{{end}}
{{template "body" .}}
</div>
<div class="push"></div>
<footer>
<div class="row">
<div class="col">
<ul class="list-unstyled">
<li class="float-md-right"><a href="#top">Back to top</a></li>
{{if .Login}}
<li><a href="/blacklist">Blacklist</a></li>
{{end}}
</ul>
<p>© GoWiki {{.Version}} Request: <strong>{{.Time}}</strong></p>
</div>
</div>
</footer>
</div>
</body>
</html>

View file

@ -0,0 +1,34 @@
{{define "body"}}
<div class="page-header">
<div class="row">
<div class="col-xs-4 offset-4">
<div class="card border-primary mx-auto">
<div class="card-header">
<strong>{{.BodyTitle}}</strong>
</div>
<div class="card-body">
<form class="form-horizontal" action="/login" method="post">
<input type="hidden" name="token" value="{{.Token}}">
<input type="hidden" name="referer" value="{{.Data.Referer}}">
<div class="form-group">
<label class="col-form-label" for="user">Username</label>
<input class="form-control input-sm" type="text" id="user" name="user" value="{{.Data.User}}" autofocus required>
</div>
<div class="form-group">
<label class="col-form-label" for="password">Password</label>
<input class="form-control input-sm" type="password" id="password" name="password" required>
</div>
<div class="form-group">
<div class="custom-control custom-checkbox">
<input type="checkbox" class="custom-control-input" id="remember" name="remember">
<label class="custom-control-label" for="remember">Remember Me</label>
</div>
</div>
<button class="btn btn-sm btn-primary" type="submit">Login</button>
</form>
</div>
</div>
</div>
</div>
</div>
{{end}}

View file

@ -0,0 +1,24 @@
{{define "body"}}
<div class="page-header">
<div class="row">
<div class="col-xs-4 offset-4">
<div class="card border-primary mx-auto">
<div class="card-header">
<strong>{{.BodyTitle}}</strong>
</div>
<div class="card-body">
<form class="form-horizontal" action="/totp" method="post">
<input type="hidden" name="token" value="{{.Token}}">
<div class="form-group">
<label class="col-form-label" for="pin">PIN</label>
<input class="form-control input-sm" type="text" id="pin" name="pin" autocomplete="off" autofocus required>
</div>
<button class="btn btn-sm btn-primary" type="submit">Verify</button>
<a href="/logout" class="btn btn-sm btn-primary">Cancel</a>
</form>
</div>
</div>
</div>
</div>
</div>
{{end}}

View file

@ -0,0 +1,27 @@
{{define "menu"}}
<ul class="navbar-nav">
<li class="nav-item"><a class="nav-link" href="/wiki/Index">Index</a></li>
<li class="nav-item"><a class="nav-link" href="/sections">Sections</a></li>
{{if .Login}}
<li class="nav-item"><a class="nav-link" href="/new">New Page</a></li>
{{end}}
</ul>
<form class="form-inline" method="post" action="/search">
<input type="hidden" name="token" value="{{.Token}}">
<input type="text" class="form-control mr-sm-2 menu" placeholder="Search" name="search" value="{{.Search}}" autocomplete="off">
<button class="btn btn-sm btn-info" type="submit">Search</button>
</form>
<ul class="nav navbar-nav ml-auto">
{{if .Login}}
{{if .Admin}}
<li class="nav-item"><a class="nav-link" href="/users">Users</a></li>
<li class="nav-item"><a class="nav-link" href="/stats">Stats</a></li>
{{else}}
<li class="nav-item"><a class="nav-link" href="/user/edit/{{.User}}">Profile</a></li>
{{end}}
<li class="nav-item"><a class="nav-link" href="/logout">Logout</a></li>
{{else}}
<li class="nav-item"><a class="nav-link" href="/login">Login</a></li>
{{end}}
</ul>
{{end}}

View file

@ -0,0 +1,17 @@
{{define "body"}}
<div class="page-header">
<div class="row">
<div class="col-xs-4 offset-4">
<div class="card border-danger mx-auto">
<div class="card-header">
<strong>404 - Page Not Found</strong>
</div>
<div class="card-body">
<h5>The requested page could not be found</h5>
<a href="/" class="btn btn-sm btn-primary">Back</a>
</div>
</div>
</div>
</div>
</div>
{{end}}

View file

@ -0,0 +1,40 @@
{{define "body"}}
<div class="row">
<div class="nav-container full-width relative">
<ol class="breadcrumb full-width">
<li class="breadcrumb-item"></li>
<li class="breadcrumb-item"><a href="/{{.BodyTitle}}">{{.BodyTitle}}</a></li>
<li class="breadcrumb-item active">{{.Data.Title}}</li>
</ol>
<div class="btn-group btn-breadcrumb">
<a href="/{{.Data.StoreTitle}}/md" class="btn btn-sm btn-primary">Markdown</a>
{{if .Login}}
<a href="/{{.Data.StoreTitle}}/edit" class="btn btn-sm btn-primary">Edit</a>
{{if eq .Data.Perm 2}}
<a href="/{{.Data.StoreTitle}}/share" class="btn btn-sm btn-primary">Share</a>
{{end}}
{{if ne .Data.StoreTitle "wiki/Index"}}
<a href="/{{.Data.StoreTitle}}/del" class="btn btn-sm btn-danger">Delete</a>
{{end}}
{{end}}
</div>
</div>
</div>
<div class="row mt-3">
<div class="col">
<strong>Created by:</strong> {{.Data.Created}}<br>
{{if .Data.Updated}}
<strong>Updated by:</strong> {{.Data.Updated}}<br>
{{end}}
<strong>Visibility:</strong>
{{if eq .Data.Perm 1}}
<span class="badge badge-primary">Public</span>
{{end}}
{{if eq .Data.Perm 2}}
<span class="badge badge-danger">Private</span>
{{end}}
<br>
</div>
</div>
{{.Data.HTML}}
{{end}}

View file

@ -0,0 +1,23 @@
{{define "body"}}
<div class="page-header">
<div class="row">
<div class="col-xs-4 offset-4">
<div class="card border-danger mx-auto">
<div class="card-header">
<strong>{{.BodyTitle}}</strong>
</div>
<div class="card-body">
<form class="form-horizontal" action="/blacklist" method="post">
<input type="hidden" name="token" value="{{.Token}}">
<div class="form-group">
<label class="col-form-label" for="token">Token</label>
<input class="form-control input-sm" type="text" id="token" name="share" autocomplete="off" autofocus required>
</div>
<button class="btn btn-sm btn-danger" type="submit">Blacklist</button>
</form>
</div>
</div>
</div>
</div>
</div>
{{end}}

View file

@ -0,0 +1,20 @@
{{define "body"}}
<div class="page-header">
<div class="row">
<div class="col-xs-4 offset-4">
<div class="card border-danger mx-auto">
<div class="card-header">
<strong>Delete {{.Data}}</strong>
</div>
<div class="card-body">
<form class="form-horizontal" action="/{{.Data}}/del" method="post">
<input type="hidden" name="token" value="{{.Token}}">
<button class="btn btn-sm btn-danger" type="submit">Delete</button>
<a href="/{{.Data}}" class="btn btn-sm btn-primary">Back</a>
</form>
</div>
</div>
</div>
</div>
</div>
{{end}}

View file

@ -0,0 +1,30 @@
{{define "body"}}
<div class="page-header">
<div class="row">
<h2>{{.BodyTitle}}</h2>
</div>
</div>
<form class="form-horizontal" action="/{{.Data.StoreTitle}}/edit" method="post">
<input type="hidden" name="token" value="{{.Token}}">
<div class="form-group">
<label class="col-form-label" for="title">Title</label>
<input class="form-control input-sm" type="text" id="title" name="title" value="{{.Data.Title}}" spellcheck="false" disabled>
</div>
<div class="form-group">
<label class="col-form-label" for="markdown">Markdown</label>
<textarea class="form-control input-sm md-text" id="markdown" name="markdown" rows="30" spellcheck="false">{{.Data.Markdown}}</textarea>
</div>
<div class="form-group">
<div class="custom-control custom-radio">
<input type="radio" id="perm1" name="perm" value="1" class="custom-control-input" {{if eq .Data.Perm 1}}checked=""{{end}}>
<label class="custom-control-label" for="perm1">Public</label>
</div>
<div class="custom-control custom-radio">
<input type="radio" id="perm2" name="perm" value="2" class="custom-control-input" {{if eq .Data.Perm 2}}checked=""{{end}}>
<label class="custom-control-label" for="perm2">Private</label>
</div>
</div>
<button class="btn btn-sm btn-primary" type="submit">Update</button>
<a href="/{{.Data.StoreTitle}}" class="btn btn-sm btn-primary">Back</a>
</form>
{{end}}

View file

@ -0,0 +1,14 @@
{{define "body"}}
<div class="row">
<div class="nav-container full-width relative">
<ol class="breadcrumb full-width">
<li class="breadcrumb-item"></li>
<li class="breadcrumb-item"><a href="/{{.BodyTitle}}">{{.BodyTitle}}</a></li>
<li class="breadcrumb-item active"><a href="/{{if .Key}}view/{{.Key}}{{else}}{{.Data.StoreTitle}}{{end}}">{{.Data.Title}}</a></li>
</ol>
</div>
</div>
<div class="row mt-3">
<textarea class="form-control input-sm md-text" rows="30" spellcheck="false" autofocus>{{.Data.Markdown}}</textarea>
</div>
{{end}}

View file

@ -0,0 +1,41 @@
{{define "body"}}
<div class="page-header">
<div class="row">
<h2>{{.BodyTitle}}</h2>
</div>
</div>
<form class="form-horizontal" action="/new" method="post">
<input type="hidden" name="token" value="{{.Token}}">
<div class="form-group">
<label class="col-form-label" for="section">Section</label>
<select class="form-control input-sm" id="section" name="section" required>
{{range $section, $selected := .Data.Sections}}
{{if $selected}}
<option value="{{$section}}" selected>{{$section}}</option>
{{else}}
<option value="{{$section}}">{{$section}}</option>
{{end}}
{{end}}
</select>
</div>
<div class="form-group">
<label class="col-form-label" for="title">Title</label>
<input class="form-control input-sm" type="text" id="title" name="title" placeholder="Title" value="{{.Data.Page.Title}}" spellcheck="false" autocomplete="off" autofocus required>
</div>
<div class="form-group">
<label class="col-form-label" for="markdown">Markdown</label>
<textarea class="form-control input-sm md-text" id="markdown" name="markdown" rows="30" spellcheck="false">{{.Data.Page.Markdown}}</textarea>
</div>
<div class="form-group">
<div class="custom-control custom-radio">
<input type="radio" id="perm1" name="perm" value="1" class="custom-control-input" {{if eq .Data.Page.Perm 1}}checked=""{{end}}>
<label class="custom-control-label" for="perm1">Public</label>
</div>
<div class="custom-control custom-radio">
<input type="radio" id="perm2" name="perm" value="2" class="custom-control-input" {{if eq .Data.Page.Perm 2}}checked=""{{end}}>
<label class="custom-control-label" for="perm2">Private</label>
</div>
</div>
<button class="btn btn-sm btn-primary" type="submit">Create</button>
</form>
{{end}}

View file

@ -0,0 +1,43 @@
{{define "body"}}
<div class="page-header">
<div class="row">
<div class="col-xs-4 offset-4">
<div class="card border-primary mx-auto">
<div class="card-header">
<strong>Share {{.Data}}</strong>
</div>
<div class="card-body">
{{if .Key}}
<div class="form-group">
<label class="col-form-label" for="key">Key</label>
<input class="form-control input-sm" type="text" id="key" value="{{.Key}}" disabled>
</div>
<a href="/view/{{.Key}}" class="btn btn-sm btn-danger">Share Link</a>
<a href="/{{.Data}}/share" class="btn btn-sm btn-primary">Back</a>
{{else}}
<form class="form-horizontal" action="/{{.Data}}/share" method="post">
<input type="hidden" name="token" value="{{.Token}}">
<div class="form-group">
<label class="col-form-label" for="duration">Lifetime</label>
<input class="form-control input-sm" type="text" id="duration" name="duration" autocomplete="off" autofocus required>
</div>
<div class="form-group">
<div class="custom-control custom-radio">
<input type="radio" id="mode1" name="mode" value="1" class="custom-control-input" checked="">
<label class="custom-control-label" for="mode1">Parse (15m, 10h)</label>
</div>
<div class="custom-control custom-radio">
<input type="radio" id="mode2" name="mode" value="2" class="custom-control-input">
<label class="custom-control-label" for="mode2">Day(s) (Numbers only)</label>
</div>
</div>
<button class="btn btn-sm btn-primary" type="submit">Share</button>
<a href="/{{.Data}}" class="btn btn-sm btn-primary">Back</a>
</form>
{{end}}
</div>
</div>
</div>
</div>
</div>
{{end}}

View file

@ -0,0 +1,31 @@
{{define "body"}}
<div class="row">
<div class="nav-container full-width relative">
<ol class="breadcrumb full-width">
<li class="breadcrumb-item"></li>
<li class="breadcrumb-item"><a href="/{{.BodyTitle}}">{{.BodyTitle}}</a></li>
<li class="breadcrumb-item active">{{.Data.Title}}</li>
</ol>
<div class="btn-group btn-breadcrumb">
<a href="/view/{{.Key}}/md" class="btn btn-sm btn-primary">Markdown</a>
</div>
</div>
</div>
<div class="row mt-3">
<div class="col">
<strong>Created by:</strong> {{.Data.Created}}<br>
{{if .Data.Updated}}
<strong>Updated by:</strong> {{.Data.Updated}}<br>
{{end}}
<strong>Visibility:</strong>
{{if eq .Data.Perm 1}}
<span class="badge badge-primary">Public</span>
{{end}}
{{if eq .Data.Perm 2}}
<span class="badge badge-danger">Private</span>
{{end}}
<br>
</div>
</div>
{{.Data.HTML}}
{{end}}

View file

@ -0,0 +1,30 @@
{{define "body"}}
<div class="page-header">
<div class="row">
<h2>{{.BodyTitle}}</h2>
</div>
</div>
<div class="row mt-3">
<div class="col-xs-6">
<form class="form-horizontal" action="/search" method="post">
<input type="hidden" name="token" value="{{.Token}}">
<div class="form-group">
<div class="input-group">
<input type="text" class="form-control input-sm" placeholder="Search" name="search" value="{{.Search}}" autocomplete="off" autofocus>
<div class="input-group-append">
<button class="btn btn-sm btn-primary" type="submit">Search</button>
</div>
</div>
</div>
</form>
</div>
</div>
<div class="row mt-3">
<div class="col">
{{range $item := .Data}}
<a href="/{{$item.StoreTitle}}"><b>{{$item.Section}} / {{$item.Title}}</b></a>
<pre class="wrap">{{$item.HTML}}</pre><br>
{{end}}
</div>
</div>
{{end}}

View file

@ -0,0 +1,39 @@
{{define "body"}}
<!--<div class="page-header">
<div class="row">
<div class="col">
<h2>{{.BodyTitle}}</h2>
</div>
<div class="col">
{{if .Admin}}
<div class="btn-group float-right">
<a class="btn btn-sm btn-primary" href="/section/edit/{{.BodyTitle}}">Edit</a>
<a class="btn btn-sm btn-danger" href="/section/del/{{.BodyTitle}}">Delete</a>
</div>
{{end}}
</div>
</div>-->
<div class="row">
<div class="nav-container full-width relative">
<ol class="breadcrumb full-width">
<li class="breadcrumb-item"></li>
<li class="breadcrumb-item">{{.BodyTitle}}</li>
</ol>
{{if .Admin}}
<div class="btn-group btn-breadcrumb">
<a class="btn btn-sm btn-primary" href="/section/edit/{{.BodyTitle}}">Edit</a>
<a class="btn btn-sm btn-danger" href="/section/del/{{.BodyTitle}}">Delete</a>
</div>
{{end}}
</div>
</div>
{{if .Data}}
<div class="row">
<ul type="circle">
{{range $item := .Data}}
<li><a href="/{{$item.StoreTitle}}"><b>{{$item.Title}}</b></a></li>
{{end}}
</ul>
</div>
{{end}}
{{end}}

View file

@ -0,0 +1,23 @@
{{define "body"}}
{{if .Data}}
<div class="page-header">
<div class="row">
<div class="col-xs-4 offset-4">
<div class="card border-danger mx-auto">
<div class="card-header">
<strong>Delete {{.Data}}?</strong>
</div>
<div class="card-body">
<p>You are about to delete section <a href="/{{.Data}}">{{.Data}}</a>.<br>This will also remove all pages of this section.</p>
<form class="form-horizontal" action="/section/del/{{.Data}}" method="post">
<input type="hidden" name="token" value="{{.Token}}">
<button class="btn btn-sm btn-danger" type="submit">Delete</button>
<a href="/{{.Data}}" class="btn btn-sm btn-primary">Back</a>
</form>
</div>
</div>
</div>
</div>
</div>
{{end}}
{{end}}

View file

@ -0,0 +1,34 @@
{{define "body"}}
<div class="row">
<div class="col-xs-4 offset-4">
<div class="card border-primary mx-auto">
<div class="card-header">
<strong>{{.BodyTitle}}</strong>
</div>
<div class="card-body">
<form class="form-horizontal" action="/section/edit/{{.Data.Name}}" method="post">
<input type="hidden" name="token" value="{{.Token}}">
<div class="form-group">
<label class="col-form-label" for="section">Section</label>
<input class="form-control input-sm" type="text" id="section" name="section" value="{{.Data.Name}}" disabled>
</div>
<div class="form-group">
<label class="col-form-label" for="members">Members</label>
<select class="form-control input-sm sections" id="members" name="members" multiple required>
{{range $user, $member := .Data.Members}}
{{if $member}}
<option value="{{$user}}" selected>{{$user}}</option>
{{else}}
<option value="{{$user}}">{{$user}}</option>
{{end}}
{{end}}
</select>
</div>
<button class="btn btn-sm btn-primary" type="submit">Update</button>
<a href="/{{.Data.Name}}" class="btn btn-sm btn-primary">Back</a>
</form>
</div>
</div>
</div>
</div>
{{end}}

View file

@ -0,0 +1,34 @@
{{define "body"}}
<div class="row">
<div class="col-xs-4 offset-4">
<div class="card border-primary mx-auto">
<div class="card-header">
<strong>{{.BodyTitle}}</strong>
</div>
<div class="card-body">
<form class="form-horizontal" action="/section/new" method="post">
<input type="hidden" name="token" value="{{.Token}}">
<div class="form-group">
<label class="col-form-label" for="section">Section</label>
<input class="form-control input-sm" type="text" id="section" name="section" autocomplete="off" value="{{.Data.Name}}" autofocus required>
</div>
<div class="form-group">
<label class="col-form-label" for="members">Members</label>
<select class="form-control input-sm sections" id="members" name="members" multiple required>
{{range $user, $member := .Data.Members}}
{{if $member}}
<option value="{{$user}}" selected>{{$user}}</option>
{{else}}
<option value="{{$user}}">{{$user}}</option>
{{end}}
{{end}}
</select>
</div>
<button class="btn btn-sm btn-primary" type="submit">Create</button>
<a href="/sections" class="btn btn-sm btn-primary">Back</a>
</form>
</div>
</div>
</div>
</div>
{{end}}

View file

@ -0,0 +1,37 @@
{{define "body"}}
<div class="page-header">
<div class="row">
<h2>{{.BodyTitle}}</h2>
</div>
</div>
{{if .Admin}}
<div class="row mb-3">
<div class="col-xs-5">
<a class="btn btn-sm btn-primary" href="/section/new"><b>New Section</b></a>
</div>
</div>
{{end}}
{{if .Data}}
<div class="row">
{{range $index, $item := .Data}}
<div class="col-xs-4 section">
<div class="card border-primary mx-auto">
<div class="card-header">
<strong><a href="/{{$item.Section}}">{{$item.Section}}</a></strong>
</div>
<div class="card-body">
{{if $item.Pages}}
{{range $page := $item.Pages}}
<strong><a href="/{{$page.StoreTitle}}">{{$page.Title}}</a></strong><br>
{{end}}
{{else}}
<strong>Empty section</strong>
{{end}}
</div>
</div>
</div>
<div class="offset-1"></div>
{{end}}
</div>
{{end}}
{{end}}

View file

@ -0,0 +1,33 @@
{{define "body"}}
<div class="page-header">
<div class="row">
<h2>{{.BodyTitle}}</h2>
</div>
</div>
<div class="row">
<table class="table table-stripped table-hover">
<tbody>
<tr>
<td><b>Goroutines</b></td>
<td>{{.Data.Goroutines}}</td>
</tr>
<tr>
<td><b>Memory usage</b></td>
<td>{{.Data.Alloc}} MiB</td>
</tr>
<tr>
<td><b>Memory allocated</b></td>
<td>{{.Data.Sys}} MiB</td>
</tr>
<tr>
<td><b>Documents</b></td>
<td>{{.Data.Docs}}</td>
</tr>
<tr>
<td><b>Go Version</b></td>
<td>{{.Data.GoVersion}}</td>
</tr>
</tbody>
</table>
</div>
{{end}}

View file

@ -0,0 +1,23 @@
{{define "body"}}
{{if .Data}}
<div class="page-header">
<div class="row">
<div class="col-xs-4 offset-4">
<div class="card border-danger mx-auto">
<div class="card-header">
<strong>Delete {{.Data.Username}}?</strong>
</div>
<div class="card-body">
<p>You are about to delete user <a href="/{{.Data.Username}}">{{.Data.Username}}</a>.<br>This will also remove all pages of this user.</p>
<form class="form-horizontal" action="/user/del/{{.Data.Username}}" method="post">
<input type="hidden" name="token" value="{{.Token}}">
<button class="btn btn-sm btn-danger" type="submit">Delete</button>
<a href="/users" class="btn btn-sm btn-primary">Back</a>
</form>
</div>
</div>
</div>
</div>
</div>
{{end}}
{{end}}

View file

@ -0,0 +1,65 @@
{{define "body"}}
{{if .Data}}
<div class="row">
<div class="col-xs-4 offset-4">
<div class="card border-primary mx-auto">
<div class="card-header">
<strong>{{.BodyTitle}}</strong>
{{if .Data.Secret}}
<a href="/user/totp/{{.Data.Username}}" class="btn btn-sm btn-danger float-right">Disable TOTP</a>
{{else}}
<a href="/user/totp/{{.Data.Username}}" class="btn btn-sm btn-success float-right">Enable TOTP</a>
{{end}}
</div>
<div class="card-body">
<form class="form-horizontal" action="/user/edit/{{.Data.Username}}" method="post">
<input type="hidden" name="token" value="{{.Token}}">
<div class="form-group">
<label class="col-form-label" for="user">Username</label>
<input class="form-control input-sm" type="text" id="user" name="user" value="{{.Data.Username}}" disabled>
</div>
<div class="form-group">
<label class="col-form-label" for="password">Password</label>
<input class="form-control input-sm" type="password" id="password" name="password" autofocus>
</div>
<div class="form-group">
<label class="col-form-label" for="repeat">Repeat</label>
<input class="form-control input-sm" type="password" id="repeat" name="repeat">
</div>
{{if .Admin}}
<div class="form-group">
<div class="custom-control custom-checkbox">
{{if .Data.Admin}}
<input type="checkbox" class="custom-control-input" id="admin" name="admin" value="0" checked>
{{else}}
<input type="checkbox" class="custom-control-input" id="admin" name="admin" value="0">
{{end}}
<label class="custom-control-label" for="admin">Admin Privileges</label>
</div>
</div>
{{end}}
<button class="btn btn-sm btn-primary" type="submit">Update</button>
{{if .Admin}}
<a href="/users" class="btn btn-sm btn-primary">Back</a>
{{else}}
<a href="/" class="btn btn-sm btn-primary">Back</a>
{{end}}
<a class="btn btn-sm btn-danger" href="/user/del/{{.Data.Username}}">Delete</a>
</form>
{{if .Admin}}
{{if eq .Data.Locked 3}}
<form class="form-horizontal" action="/user/unlock/{{.Data.Username}}" method="post">
<input type="hidden" name="token" value="{{.Token}}">
<div class="form-group">
<label class="col-form-label" for="unlock">Locked</label>
<button class="btn btn-sm btn-warning" type="submit" id="unlock">Unlock</button>
</div>
</form>
{{end}}
{{end}}
</div>
</div>
</div>
</div>
{{end}}
{{end}}

View file

@ -0,0 +1,36 @@
{{define "body"}}
<div class="row">
<div class="col-xs-4 offset-4">
<div class="card border-primary mx-auto">
<div class="card-header">
<strong>{{.BodyTitle}}</strong>
</div>
<div class="card-body">
<form class="form-horizontal" action="/user/new" method="post">
<input type="hidden" name="token" value="{{.Token}}">
<div class="form-group">
<label class="col-form-label" for="user">Username</label>
<input class="form-control input-sm" type="text" id="user" name="user" autocomplete="off" autofocus required>
</div>
<div class="form-group">
<label class="col-form-label" for="password">Password</label>
<input class="form-control input-sm" type="password" id="password" name="password" required>
</div>
<div class="form-group">
<label class="col-form-label" for="repeat">Repeat</label>
<input class="form-control input-sm" type="password" id="repeat" name="repeat" required>
</div>
<div class="form-group">
<div class="custom-control custom-checkbox">
<input type="checkbox" class="custom-control-input" id="admin" name="admin" value="0">
<label class="custom-control-label" for="admin">Admin Privileges</label>
</div>
</div>
<button class="btn btn-sm btn-primary" type="submit">Create</button>
<a href="/users" class="btn btn-sm btn-primary">Back</a>
</form>
</div>
</div>
</div>
</div>
{{end}}

View file

@ -0,0 +1,38 @@
{{define "body"}}
{{if .Data}}
<div class="row">
<div class="col-xs-4 offset-4">
<div class="card border-primary mx-auto">
<div class="card-header">
<strong>{{.BodyTitle}} - {{.Data.Username}}</strong>
</div>
<div class="card-body">
<form class="form-horizontal" action="/user/totp/{{.Data.Username}}" method="post">
<input type="hidden" name="token" value="{{.Token}}">
{{if .Data.Secret}}
<input type="hidden" name="url" value="{{.Data.URL}}">
<div class="form-group">
<img src="{{.Data.Image}}" alt="{{.Data.Secret}}">
</div>
<div class="form-group">
<label class="col-form-label" for="secret">Secret</label>
<pre><code id="secret">{{.Data.Secret}}</code></pre>
</div>
{{end}}
<div class="form-group">
<label class="col-form-label" for="pin">PIN</label>
<input class="form-control input-sm" type="text" id="pin" name="pin" autocomplete="off" autofocus required>
</div>
{{if .Data.Secret}}
<button class="btn btn-sm btn-success" type="submit">Enable</button>
{{else}}
<button class="btn btn-sm btn-danger" type="submit">Disable</button>
{{end}}
<a href="/user/edit/{{.Data.Username}}" class="btn btn-sm btn-primary">Back</a>
</form>
</div>
</div>
</div>
</div>
{{end}}
{{end}}

View file

@ -0,0 +1,55 @@
{{define "body"}}
<div class="page-header">
<div class="row">
<h2>{{.BodyTitle}}</h2>
</div>
</div>
{{if .Admin}}
<div class="row mb-3">
<div class="col-xs-5">
<a class="btn btn-sm btn-primary" href="/user/new"><b>New User</b></a>
</div>
</div>
{{end}}
<div class="row">
<div class="col">
{{if .Data}}
<table class="table table-hover">
<thead>
<tr>
<th>Username</th>
<th>Flags</th>
<th>Options</th>
</tr>
</thead>
<tbody>
{{$user := .User}}
{{$admin := .Admin}}
{{range $item := .Data}}
<tr>
<td>{{$item.Username}}</td>
<td>
{{if $item.Secret}}<span class="badge badge-success">TOTP</span>{{end}}
{{if eq $item.Locked 3}}<span class="badge badge-warning">Locked</span>{{end}}
{{if $item.Admin}}<span class="badge badge-danger">Admin</span>{{end}}
</td>
<td>
<div class="btn-group">
{{if $admin}}
<a class="btn btn-sm btn-primary" href="/user/edit/{{$item.Username}}">Edit</a>
<a class="btn btn-sm btn-danger" href="/user/del/{{$item.Username}}">Delete</a>
{{else}}
{{if eq $item.Username $user}}
<a class="btn btn-sm btn-primary" href="/user/edit/{{$item.Username}}">Edit</a>
{{end}}
{{end}}
</div>
</td>
</tr>
{{end}}
</tbody>
</table>
{{end}}
</div>
</div>
{{end}}

44
pkg/server/xsrf.go Normal file
View file

@ -0,0 +1,44 @@
// Copyright (C) 2021 Marius Schellenberger
package server
import (
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"git.giftfish.de/ston1th/gowiki/pkg/log"
)
const (
keySize = 16
fullSize = keySize * 2
)
func genKey(length int) (bytes []byte) {
bytes = make([]byte, length)
if _, err := rand.Reader.Read(bytes); err != nil {
log.Println("genKey:", err)
}
return
}
func newXsrf(secret []byte) string {
mac := hmac.New(sha256.New, secret)
rnd := genKey(keySize)
mac.Write(rnd)
return base64.RawURLEncoding.EncodeToString(append(rnd, mac.Sum(nil)[:keySize]...))
}
func checkXsrf(xsrf string, secret []byte) bool {
t, err := base64.RawURLEncoding.DecodeString(xsrf)
if err != nil {
return false
}
if len(t) != fullSize {
return false
}
mac := hmac.New(sha256.New, secret)
mac.Write(t[:keySize])
return hmac.Equal(t[keySize:], mac.Sum(nil)[:keySize])
}

31
pkg/server/xsrf_test.go Normal file
View file

@ -0,0 +1,31 @@
// Copyright (C) 2021 Marius Schellenberger
package server
import (
"testing"
)
func TestXsrf(t *testing.T) {
sec := genKey(keySize)
if len(sec) != keySize {
t.Fatal("len(sec) != keySize")
}
xsrf := newXsrf(sec)
if xsrf == "" {
t.Fatal("xsrf is empty")
}
if !checkXsrf(xsrf, sec) {
t.Fatal("valid xsrf check failed")
}
if checkXsrf("test", sec) {
t.Fatal("invalid xsrf check succeeded")
}
rnd := genKey(keySize)
if checkXsrf(xsrf, rnd) {
t.Fatal("invalid xsrf check succeeded")
}
}

139
pkg/store/boltstore.go Normal file
View file

@ -0,0 +1,139 @@
// Copyright (C) 2021 Marius Schellenberger
package store
import (
"encoding/json"
bolt "go.etcd.io/bbolt"
"io"
)
const (
defaultBoltBucket = "default"
fileMode = 0o600
)
type BoltStore struct {
Marshaler
db *bolt.DB
}
func NewBoltStore(file string, m Marshaler) (Store, error) {
if m == nil {
m = NewGOB()
}
db, err := bolt.Open(file, fileMode, nil)
if err != nil {
return nil, err
}
if err := db.Update(func(tx *bolt.Tx) (err error) {
_, err = tx.CreateBucketIfNotExists([]byte(defaultBoltBucket))
return
}); err != nil {
return nil, err
}
return &BoltStore{m, db}, nil
}
type dump struct {
K string `json:"k"`
V []byte `json:"v"`
}
func (bs *BoltStore) Dump(w io.Writer) (err error) {
if w == nil {
return ErrWriterIsNil
}
var d []dump
err = bs.ForEach(func(k string, v []byte) error {
b := make([]byte, len(v))
copy(b, v)
d = append(d, dump{k, b})
return nil
})
if err != nil {
return
}
return json.NewEncoder(w).Encode(d)
}
func (bs *BoltStore) Restore(r io.Reader) (err error) {
if r == nil {
return ErrReaderIsNil
}
var d []dump
err = json.NewDecoder(r).Decode(&d)
if err != nil {
return
}
if err = bs.db.Update(func(tx *bolt.Tx) (err error) {
if err = tx.DeleteBucket([]byte(defaultBoltBucket)); err != nil {
return
}
_, err = tx.CreateBucket([]byte(defaultBoltBucket))
return
}); err != nil {
return
}
return bs.db.Update(func(tx *bolt.Tx) (err error) {
b := tx.Bucket([]byte(defaultBoltBucket))
for _, v := range d {
b.Put([]byte(v.K), v.V)
}
return nil
})
}
func (bs *BoltStore) Get(key string, v interface{}) (err error) {
err = bs.db.View(func(tx *bolt.Tx) error {
b := tx.Bucket([]byte(defaultBoltBucket)).Get([]byte(key))
if b == nil {
return ErrKeyNotFound
}
if v != nil {
return bs.Unmarshal(b, v)
}
return nil
})
return
}
func (bs *BoltStore) Set(key string, v interface{}) error {
return bs.db.Update(func(tx *bolt.Tx) (err error) {
var b []byte
if v != nil {
b, err = bs.Marshal(v)
if err != nil {
return
}
}
return tx.Bucket([]byte(defaultBoltBucket)).Put([]byte(key), b)
})
}
func (bs *BoltStore) ForEach(f func(string, []byte) error) error {
return bs.db.View(func(tx *bolt.Tx) error {
return tx.Bucket([]byte(defaultBoltBucket)).ForEach(func(k, v []byte) error {
return f(string(k), v)
})
})
}
func (bs *BoltStore) ForEachPrefix(prefix string, f func(string, []byte) error) error {
return bs.ForEach(func(k string, v []byte) error {
if trim, ok := hasTrimPrefix(k, prefix); ok {
return f(trim, v)
}
return nil
})
}
func (bs *BoltStore) Delete(key string) error {
return bs.db.Update(func(tx *bolt.Tx) error {
return tx.Bucket([]byte(defaultBoltBucket)).Delete([]byte(key))
})
}
func (bs *BoltStore) Close() error {
return bs.db.Close()
}

View file

@ -0,0 +1,98 @@
// Copyright (C) 2021 Marius Schellenberger
package store
import (
"bytes"
"errors"
"os"
"path/filepath"
"testing"
)
type testData struct {
Data string
}
func TestBoltStore(t *testing.T) {
data := []testData{
{"123"},
{"hello"},
}
dir, err := os.MkdirTemp("", "")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(dir)
bs, err := NewBoltStore(filepath.Join(dir, "bolt.db"), nil)
if err != nil {
t.Fatal(err)
}
err = bs.Set("key0", data[0])
if err != nil {
t.Fatal(err)
}
err = bs.Set("key1", data[1])
if err != nil {
t.Fatal(err)
}
t.Run("GetKey0", func(t *testing.T) {
var d testData
err := bs.Get("key0", &d)
if err != nil {
t.Fatal(err)
}
if d != data[0] {
t.Error("d is not data[0]")
}
})
t.Run("ForEach", func(t *testing.T) {
err := bs.ForEach(func(k string, v []byte) error {
if k == "key0" || k == "key1" {
return nil
}
return errors.New("key0 or key1 not found")
})
if err != nil {
t.Fatal(err)
}
})
t.Run("DumpRestore", func(t *testing.T) {
buf := new(bytes.Buffer)
err := bs.Dump(buf)
if err != nil {
t.Fatal(err)
}
if buf.Len() == 0 {
t.Error("no dump written")
}
err = bs.Restore(buf)
if err != nil {
t.Fatal(err)
}
})
t.Run("DeleteKey1", func(t *testing.T) {
err := bs.Delete("key1")
if err != nil {
t.Fatal(err)
}
})
t.Run("GetKey1", func(t *testing.T) {
var d testData
err := bs.Get("key1", &d)
if err == nil {
t.Error("key1 should be deleted")
}
if d == data[1] {
t.Error("d is not data[1]")
}
})
t.Run("Close", func(t *testing.T) {
err := bs.Close()
if err != nil {
t.Fatal(err)
}
})
}

13
pkg/store/dump.go Normal file
View file

@ -0,0 +1,13 @@
// Copyright (C) 2021 Marius Schellenberger
package store
import "io"
type Dumper interface {
Dump(io.Writer) error
}
type Restorer interface {
Restore(io.Reader) error
}

25
pkg/store/gob.go Normal file
View file

@ -0,0 +1,25 @@
// Copyright (C) 2021 Marius Schellenberger
package store
import (
"bytes"
"encoding/gob"
)
type gobMarshaler struct{}
func NewGOB() Marshaler {
return gobMarshaler{}
}
func (gobMarshaler) Marshal(v interface{}) (b []byte, err error) {
buf := new(bytes.Buffer)
err = gob.NewEncoder(buf).Encode(v)
b = buf.Bytes()
return
}
func (gobMarshaler) Unmarshal(data []byte, v interface{}) error {
return gob.NewDecoder(bytes.NewBuffer(data)).Decode(v)
}

39
pkg/store/gob_test.go Normal file
View file

@ -0,0 +1,39 @@
// Copyright (C) 2021 Marius Schellenberger
package store
import "testing"
var g = NewGOB()
func BenchmarkGOBMarshalPage(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
g.Marshal(p)
}
}
func BenchmarkGOBUnmarshalPage(b *testing.B) {
bytes, _ := g.Marshal(p)
var pp Page
b.ReportAllocs()
for i := 0; i < b.N; i++ {
g.Unmarshal(bytes, &pp)
}
}
func BenchmarkGOBMarshalUser(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
g.Marshal(u)
}
}
func BenchmarkGOBUnmarshalUser(b *testing.B) {
bytes, _ := g.Marshal(u)
var uu User
b.ReportAllocs()
for i := 0; i < b.N; i++ {
g.Unmarshal(bytes, &uu)
}
}

11
pkg/store/helper.go Normal file
View file

@ -0,0 +1,11 @@
// Copyright (C) 2021 Marius Schellenberger
package store
// Parts taken from strings.TrimPrefix
func hasTrimPrefix(s, prefix string) (string, bool) {
if len(s) >= len(prefix) && s[0:len(prefix)] == prefix {
return s[len(prefix):], true
}
return s, false
}

8
pkg/store/marshal.go Normal file
View file

@ -0,0 +1,8 @@
// Copyright (C) 2021 Marius Schellenberger
package store
type Marshaler interface {
Marshal(v interface{}) ([]byte, error)
Unmarshal(data []byte, v interface{}) error
}

34
pkg/store/marshal_test.go Normal file
View file

@ -0,0 +1,34 @@
// Copyright (C) 2021 Marius Schellenberger
package store
import "time"
type Page struct {
Text string
Date time.Time
Number int
Test bool
}
type User struct {
Username string
Password string
Number int
Test bool
Today time.Time
}
var p = &Page{
"Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet.",
time.Now(),
34534534,
true,
}
var u = &User{
"svdfnvdnfvon",
"func BenchmarkNew(b *testing.B) {",
8794355,
false,
time.Now(),
}

23
pkg/store/store.go Normal file
View file

@ -0,0 +1,23 @@
// Copyright (C) 2021 Marius Schellenberger
package store
import "errors"
var (
ErrKeyNotFound = errors.New("store: key not found")
ErrWriterIsNil = errors.New("store: writer is nil")
ErrReaderIsNil = errors.New("store: reader is nil")
)
type Store interface {
Marshaler
Dumper
Restorer
Get(string, interface{}) error
Set(string, interface{}) error
ForEach(func(string, []byte) error) error
ForEachPrefix(string, func(string, []byte) error) error
Delete(string) error
Close() error
}

107
render.go
View file

@ -1,107 +0,0 @@
// Copyright (C) 2017 Marius Schellenberger
package main
import (
"html/template"
"regexp"
"strconv"
"strings"
"github.com/blevesearch/bleve/analysis"
htmlFilter "github.com/blevesearch/bleve/analysis/char_filters/html_char_filter"
"github.com/russross/blackfriday"
)
var filter = makeFilter()
func makeFilter() analysis.CharFilter {
f, _ := htmlFilter.CharFilterConstructor(nil, nil)
return f
}
var (
reHeader = regexp.MustCompile("(<h[1-6])>(.+)(</h[1-6]>)")
reLink = regexp.MustCompile(`id="(.+)">`)
whiteSpace = regexp.MustCompile(`\s+`)
reIndex = regexp.MustCompile("<h([1-6])>(.+)</h[1-6]>")
)
func render(text string) Article {
rend := blackfriday.MarkdownCommon([]byte(text))
html := string(rend)
index := buildIndex(html)
html = strings.Replace(html, "<table>", `<table class="table">`, -1)
html = reHeader.ReplaceAllString(html, `${1} id="${2}">${2}${3}`)
html = reLink.ReplaceAllStringFunc(html, makeLinkTitle)
html = strings.Replace(html, "</h1>", `</h1><hr>`, -1)
html = strings.Replace(html, "<a", `<a target="_blank"`, -1)
return Article{
Search: whiteSpace.ReplaceAllString(string(filter.Filter(rend)), " "),
Index: template.HTML(index),
Text: template.HTML(html),
MD: text,
}
}
func makeLinkTitle(title string) string {
return strings.Replace(title, " ", "+", -1)
}
func buildIndex(html string) (ret string) {
hn := reIndex.FindAllStringSubmatch(html, -1)
for k, v := range hn {
link := makeLinkTitle(v[2])
ret += before(hn, k)
ret += `<li><a href="#` + link + `">` + v[2] + "</a>"
ret += after(hn, k)
}
return
}
func before(arr [][]string, i int) string {
c, _ := strconv.Atoi(arr[i][1])
if c == 1 {
return `<ul type="circle">`
}
return ""
}
func after(arr [][]string, i int) string {
c, _ := strconv.Atoi(arr[i][1])
if len(arr) <= i+1 {
return closeTag(c-1) + "</ul>"
}
n, _ := strconv.Atoi(arr[i+1][1])
if n == 1 {
return closeTag(c-n) + "</ul>"
} else if n < c {
return closeTag(c - n)
} else if n > c {
return openTag(n - c)
} else if n == c {
return "</li>"
}
return ""
}
func openTag(j int) (ret string) {
for i := 0; i < j; i++ {
ret += `<ul type="circle"><li>`
}
if j > 1 {
ret = strings.Replace(ret, "circle", "none", j-1)
}
ret = ret[0 : len(ret)-4]
return
}
func closeTag(j int) (ret string) {
for i := 0; i < j; i++ {
ret += "</li></ul></li>"
}
if j > 1 {
ret = strings.Replace(ret, "</li></li>", "</li>", -1)
}
return
}

135
routes.go
View file

@ -1,135 +0,0 @@
// Copyright (C) 2017 Marius Schellenberger
package main
import (
"net/http"
"github.com/gorilla/mux"
)
type route struct {
Path string
Handler http.HandlerFunc
Methods []string
}
func buildRoutes() http.Handler {
m := mux.NewRouter()
m.NotFoundHandler = notFoundHandler{}
for _, v := range routes {
m.HandleFunc(v.Path, v.Handler).Methods(v.Methods...)
}
return m
}
var routes = []route{
{
"/",
contextWrapper(
indexHandler),
[]string{"GET"},
},
{
"/bootstrap.css",
contextWrapper(
staticHandler),
[]string{"GET"},
},
{
"/custom.css",
contextWrapper(
staticHandler),
[]string{"GET"},
},
{
"/login",
contextWrapper(
loginHandler),
[]string{"GET", "POST"},
},
{
"/search",
contextWrapper(
searchHandler),
[]string{"GET", "POST"},
},
{
"/new",
contextWrapper(
wikiNewHandler),
[]string{"GET", "POST"},
},
{
"/all",
contextWrapper(
allHandler),
[]string{"GET"},
},
{
"/wiki/{title:[a-zA-Z0-9+]+$}",
contextWrapper(
wikiHandler),
[]string{"GET"},
},
{
"/wiki/{title:[a-zA-Z0-9+]+}/md",
contextWrapper(
wikiMDHandler(
wikiHandler)),
[]string{"GET"},
},
{
"/wiki/{title:[a-zA-Z0-9+]+}/edit",
contextWrapper(
wikiEditHandler),
[]string{"GET", "POST"},
},
{
"/wiki/{title:[a-zA-Z0-9+]+}/del",
contextWrapper(
wikiDelHandler),
[]string{"POST"},
},
{
"/users",
contextWrapper(
adminAuthHandler(
usersHandler)),
[]string{"GET"},
},
{
"/user/new",
contextWrapper(
adminAuthHandler(
userNewHandler)),
[]string{"GET", "POST"},
},
{
"/user/del/{user:[a-z]+$}",
contextWrapper(
adminAuthHandler(
userDelHandler)),
[]string{"GET", "POST"},
},
{
"/user/unlock/{user:[a-z]+$}",
contextWrapper(
adminAuthHandler(
userUnlockHandler)),
[]string{"POST"},
},
{
"/user/edit/{user:[a-z]+$}",
contextWrapper(
userAuthHandler(
userEditHandler)),
[]string{"GET", "POST"},
},
{
"/logout",
contextWrapper(
logoutHandler),
[]string{"GET", "POST"},
},
}

View file

@ -1,55 +0,0 @@
#!/bin/sh
### BEGIN INIT INFO
# Provides: gowiki
# Required-Start: $syslog $network
# Required-Stop: $syslog
# Default-Start: 2 3 4 5
# Default-Stop: 0 1 6
# Short-Description: GoWiki
# Description: GoWiki
### END INIT INFO
PATH=/sbin:/usr/sbin:/bin:/usr/bin
DESC="wiki engine"
NAME=gowiki
DAEMON=/usr/bin/${NAME}
DAEMON_OPTS="-s"
PID=$(ps aux|grep "${DAEMON}"|awk '!/grep/{print $2}')
. /lib/lsb/init-functions
do_start() {
${DAEMON} ${DAEMON_OPTS}
log_end_msg ${?}
}
do_stop() {
kill ${PID}
return "${?}"
}
case "${1}" in
start)
log_action_msg "Starting ${DESC}" "${NAME}"
do_start
;;
stop)
log_daemon_msg "Stopping ${DESC}" "${NAME}"
do_stop
;;
restart)
log_daemon_msg "Restarting ${DESC}" "${NAME}"
do_stop
if [ ${?} -ne 0 ]; then
log_end_msg 1
fi
do_start
;;
status)
status_of_proc -p ${PID} "${DAEMON}" "${NAME}" && exit 0 || exit ${?}
;;
*)
echo "Usage: service ${NAME} {start|stop|restart|status}"
exit 3
;;
esac

View file

@ -1,4 +0,0 @@
#!/bin/sh
useradd wiki
mkdir /var/{lib,log}/gowiki
chown wiki: /var/{lib,log}/gowiki

32
scripts/install_linux.sh Executable file
View file

@ -0,0 +1,32 @@
#!/bin/sh
groupadd wiki
useradd -g wiki wiki
mkdir /var/lib/gowiki
chown wiki: /var/lib/gowiki
mkdir /etc/gowiki
cat <<EOF> /etc/gowiki/gowiki.conf
{
"data_dir": "/var/lib/gowiki",
"secret": "$(openssl rand -hex 32)"
}
EOF
cat <<EOF> /etc/systemd/system/gowiki.service
[Unit]
Description=GoWiki
After=syslog.target
After=network.target
[Service]
Type=forking
ExecStart=/usr/local/bin/gowiki server -c /etc/gowiki/gowiki.conf
Restart=always
[Install]
WantedBy=multi-user.target
EOF
systemctl enable gowiki
cat <<EOF
copy your gowiki binary to /usr/local/bin/gowiki
then run: systemctl start gowiki
EOF

41
scripts/install_openbsd.sh Executable file
View file

@ -0,0 +1,41 @@
#!/bin/sh
cat <<'EOF'> /etc/rc.d/gowiki
#!/bin/ksh
daemon="/usr/local/bin/gowiki"
daemon_user="_wiki"
daemon_logger="daemon.info"
. /etc/rc.d/rc.subr
pexp=".*${daemon}.*"
rc_bg=YES
rc_reload=NO
rc_cmd $1
EOF
chmod 555 /etc/rc.d/gowiki
rcctl enable gowiki
rcctl set gowiki flags server -c /var/wiki/gowiki.conf
mkdir /var/wiki
groupadd _wiki
useradd -d /var/wiki -s /sbin/nologin -g _wiki _wiki
chmod 750 /var/wiki
chown _wiki:_wiki /var/wiki
cat <<EOF> /var/wiki/gowiki.conf
{
"data_dir": "/var/wiki",
"secret": "$(openssl rand -hex 32)"
}
EOF
chmod 640 /var/wiki/gowiki.conf
chgrp _wiki /var/wiki/gowiki.conf
cat <<EOF
copy your gowiki binary to /usr/local/bin/gowiki
then run: rcctl start gowiki
EOF

11
sri.sh
View file

@ -1,9 +1,10 @@
#!/bin/bash
# Copyright (C) 2017 Marius Schellenberger
# Copyright (C) 2021 Marius Schellenberger
HASH="sha256"
DIR="templates"
FILES="*.css *.js *.ico"
DIR="pkg/server/static"
FILES="*.css *.js"
BINS="*.ico"
echo "SRI (subresource integrity) hashes"
cd ${DIR}
for f in $(ls ${FILES} 2>/dev/null); do
@ -12,3 +13,7 @@ for f in $(ls ${FILES} 2>/dev/null); do
printf "%13s: " ${f}
echo "${HASH}-$(cat ${f}|openssl dgst -${HASH} -binary|openssl enc -base64 -A)"
done
for f in $(ls ${BINS} 2>/dev/null); do
printf "%13s: " ${f}
echo "${HASH}-$(cat ${f}|openssl dgst -${HASH} -binary|openssl enc -base64 -A)"
done

File diff suppressed because one or more lines are too long

View file

@ -1,94 +0,0 @@
#!/bin/bash
# Copyright (C) 2017 Marius Schellenberger
# this file generates Go source code for the gowiki
# it contains html and css and uses the contents
# of the templates directory
templates_go="templates.go"
echo "Generating templates.go"
if [ ! -d templates ]; then
echo "error: templates directory not found" 1>&2
exit 2
fi
cat << EOF > ${templates_go}
// Copyright (C) 2017 Marius Schellenberger
// This file is auto-generated by build/templates.sh
// using the contents of the templates directory
package main
import (
"errors"
"html/template"
)
EOF
echo "const (" >> ${templates_go}
for f in $(ls templates/*.html); do
fname=${f/templates\//}
fname=${fname/.html/}
echo " ${fname} = \`$(cat ${f})\`" >> ${templates_go}
done
for f in $(ls templates/*.css); do
fname=${f/templates\//}
fname=${fname/.css/CSS}
echo " ${fname} = \`$(cat ${f})\`" >> ${templates_go}
done
cat << EOF >> ${templates_go}
)
var (
templ map[string]*template.Template
res map[string][]byte
)
func loadTemplates() {
var errf bool
templ = make(map[string]*template.Template)
res = make(map[string][]byte)
parse := func(html ...string) (temp *template.Template) {
var err error
temp = template.New("")
for _, s := range html {
temp, err = temp.Parse(s)
if err != nil {
log.Println("parse:", err)
errf = true
return
}
}
return
}
// resources
res["bootstrap.css"] = []byte(bootstrapCSS)
res["custom.css"] = []byte(customCSS)
// pages.go
templ["notFoundHandler"] = parse(index, menu, notFound)
templ["loginHandler"] = parse(index, menu, login)
templ["searchHandler"] = parse(index, menu, search)
templ["allHandler"] = parse(index, menu, all)
templ["wikiNewHandler"] = parse(index, menu, wikiNew)
templ["wikiEditHandler"] = parse(index, menu, wikiEdit)
templ["wikiHandler"] = parse(index, menu, wiki)
templ["wikiMDHandler"] = parse(index, menu, wikiMD)
// users.go
templ["usersHandler"] = parse(index, menu, users)
templ["userNewHandler"] = parse(index, menu, userNew)
templ["userEditHandler"] = parse(index, menu, userEdit)
templ["userDelHandler"] = parse(index, menu, userDel)
if errf {
log.Fatal("parse: ", errors.New("parsing templates failed"))
}
}
EOF

View file

@ -1,16 +0,0 @@
{{define "body"}}
<div class="page-header">
<div class="row">
<h2>{{.BodyTitle}}</h2>
</div>
</div>
<div class="row">
<ul type="circle">
{{if .Data}}
{{range $item := .Data}}
<li><a href="/wiki/{{$item.LinkTitle}}"><b>{{$item.Title}}</b></a></li>
{{end}}
{{end}}
</ul>
</div>
{{end}}

File diff suppressed because one or more lines are too long

View file

@ -1,29 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<title>GoWiki | {{.Title}}</title>
<link rel="stylesheet" type="text/css" href="/bootstrap.css" mdeia="screen" integrity="sha256-KC5lAzGRWwscU0sTmXtd8ka5mt7Vk8a0L5JqOhwA28s="></link>
<link rel="stylesheet" type="text/css" href="/custom.css" integrity="sha256-uAIARpL4VhLyhfuh5bS4YWjdcAvN+KjjTli9S8Ud8O4="></link>
</head>
<body style="padding-top:50px">
<div class="navbar navbar-default navbar-fixed-top">
<div class="container">
<div class="navbar-header">
<a href="/" class="navbar-brand">GoWiki</a>
</div>
<div class="navbar-collapse collapse">
{{template "menu" .}}
</div>
</div>
</div>
<div class="container">
{{if .Msg}}
<div class="alert alert-danger" style="margin-top:20px">
<h4>Error!</h4>
<p>{{.Msg}}</p>
</div>
{{end}}
{{template "body" .}}
</div>
</body>
</html>

View file

@ -1,37 +0,0 @@
{{define "body"}}
<div class="page-header">
<div class="row">
<div class="col-xs-6 col-xs-offset-3">
<div class="panel panel-default">
<div class="panel-heading">
<h3 class="panel-title">{{.BodyTitle}}</h3>
</div>
<div class="panel-body">
<form class="form-horizontal" action="/login" method="post">
<input type="hidden" name="token" value="{{.Token}}">
<fieldset>
<div class="form-group">
<label class="col-lg-4 control-label">Username</label>
<div class="col-lg-6">
<input class="form-control input-sm" type="text" name="user" autofocus required>
</div>
</div>
<div class="form-group">
<label class="col-lg-4 control-label">Password</label>
<div class="col-lg-6">
<input class="form-control input-sm" type="password" name="password" required>
</div>
</div>
<div class="form-group">
<div class="col-lg-6 col-lg-offset-4">
<button class="btn btn-sm btn-primary" type="submit" name="submit" value="Login">Login</button>
</div>
</div>
</fieldset>
</form>
</div>
</div>
</div>
</div>
</div>
{{end}}

View file

@ -1,49 +0,0 @@
{{define "menu"}}
{{if .Login}}
<ul class="nav navbar-nav">
<li><a href="/wiki/Index">
Index
</a></li>
<li><a href="/search">
Search
</a></li>
<li><a href="/all">
All
</a></li>
<li><a href="/new">
New
</a></li>
</ul>
<ul class="nav navbar-nav navbar-right">
{{if .Admin}}
<li><a href="/users">
Users
</a></li>
{{else}}
<li><a href="/user/edit/{{.User}}">
Profile
</a></li>
{{end}}
<li><a href="/logout">
Logout
</a></li>
</ul>
{{else}}
<ul class="nav navbar-nav">
<li><a href="/wiki/Index">
Index
</a></li>
<li><a href="/search">
Search
</a></li>
<li><a href="/all">
All
</a></li>
</ul>
<ul class="nav navbar-nav navbar-right">
<li><a href="/login">
Login
</a></li>
</ul>
{{end}}
{{end}}

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