Compare commits

..

No commits in common. "v2.0" and "master" have entirely different histories.

1371 changed files with 279547 additions and 519473 deletions

5
.gitignore vendored
View file

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

View file

@ -1,4 +1,4 @@
Copyright (C) 2021 Marius Schellenberger
Copyright (C) 2016 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 Normal file
View file

@ -0,0 +1,232 @@
{
"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 Normal file
View file

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

View file

@ -1,59 +1,32 @@
CC=go
BUILD=build -v -trimpath
VERSION=$(shell cat VERSION)
GCFLAGS=-gcflags '-e'
LDFLAGS=-ldflags '-buildid=$(VERSION) -X main.version=$(VERSION) -s -w'
BUILD=build -v
LDFLAGS=--ldflags "-s -w"
PROGRAM=gowiki
CMD=-o $(PROGRAM) cmd/gowiki/main.go
ENV=GOGC=0 CGO_ENABLED=0 GO111MODULE=on
all: $(PROGRAM)
setup:
$(CC) install github.com/client9/misspell/cmd/misspell@latest
$(CC) install honnef.co/go/tools/cmd/staticcheck@latest
$(CC) get github.com/tools/godep
$(CC) get github.com/client9/misspell
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)
$(PROGRAM): clean codeqa
CGO_ENABLED=0 $(CC) $(BUILD) $(LDFLAGS)
clean:
$(CC) clean -x
codeqa: templates gofmt misspell golint
gofmt:
$(CC) fmt ./...
gofmt -w .
codeqa: govet misspell staticcheck test
govet:
$(CC) vet ./...
update:
$(ENV) $(CC) get -u ./...
golint:
$(GOPATH)/bin/golint .
misspell:
$(GOPATH)/bin/misspell cmd/* pkg/* Makefile README.md sri.sh
$(GOPATH)/bin/misspell **/*
staticcheck:
$(GOPATH)/bin/staticcheck ./...
templates:
$(CC) generate
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
.PHONY: setup build clean templates gofmt misspell golint

View file

@ -1,61 +1 @@
# 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).

View file

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

View file

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

250
context.go Normal file
View file

@ -0,0 +1,250 @@
// 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 Normal file
View file

@ -0,0 +1,86 @@
// 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 Normal file
View file

@ -0,0 +1,270 @@
// 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
View file

@ -1,40 +0,0 @@
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
View file

@ -1,171 +0,0 @@
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=

515
handler.go Normal file
View file

@ -0,0 +1,515 @@
// 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)
}

23
logger.go Normal file
View file

@ -0,0 +1,23 @@
// 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 Normal file
View file

@ -0,0 +1,157 @@
// 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
View file

@ -1,54 +0,0 @@
// 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()
}

View file

@ -1,50 +0,0 @@
// 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()
}
})
}

View file

@ -1,281 +0,0 @@
// 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...)
}

View file

@ -1,26 +0,0 @@
// 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"
)

View file

@ -1,13 +0,0 @@
// 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
}

View file

@ -1,41 +0,0 @@
// 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

@ -1,77 +0,0 @@
// 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()
}
})
}
}

View file

@ -1,94 +0,0 @@
// 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"`
}

View file

@ -1,33 +0,0 @@
// 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
}

View file

@ -1,122 +0,0 @@
// 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
}

View file

@ -1,55 +0,0 @@
// 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()
}

View file

@ -1,13 +0,0 @@
// 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
}

View file

@ -1,115 +0,0 @@
// 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
}

View file

@ -1,241 +0,0 @@
// 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)
}

View file

@ -1,29 +0,0 @@
// 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)
}

View file

@ -1,118 +0,0 @@
// 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
}

View file

@ -1,230 +0,0 @@
// 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)
}

View file

@ -1,130 +0,0 @@
// 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
}

View file

@ -1,56 +0,0 @@
// 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
}

View file

@ -1,63 +0,0 @@
// 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...)
}
}

View file

@ -1,76 +0,0 @@
// 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
}

View file

@ -1,25 +0,0 @@
// 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")
}
}

View file

@ -1,87 +0,0 @@
// 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
}

View file

@ -1,35 +0,0 @@
// 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()
}
})
}

View file

@ -1,305 +0,0 @@
// 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)

View file

@ -1,890 +0,0 @@
// 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)
}

View file

@ -1,66 +0,0 @@
// 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(),
}
}

View file

@ -1,187 +0,0 @@
// 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"},
},
}

View file

@ -1,99 +0,0 @@
// 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))
}
}

File diff suppressed because one or more lines are too long

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.3 KiB

View file

@ -1,67 +0,0 @@
// 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

@ -1,17 +0,0 @@
{{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

@ -1,46 +0,0 @@
<!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

@ -1,34 +0,0 @@
{{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

@ -1,24 +0,0 @@
{{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

@ -1,27 +0,0 @@
{{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

@ -1,17 +0,0 @@
{{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

@ -1,40 +0,0 @@
{{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

@ -1,23 +0,0 @@
{{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

@ -1,20 +0,0 @@
{{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

@ -1,30 +0,0 @@
{{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

@ -1,14 +0,0 @@
{{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

@ -1,41 +0,0 @@
{{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

@ -1,43 +0,0 @@
{{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

@ -1,31 +0,0 @@
{{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

@ -1,30 +0,0 @@
{{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

@ -1,39 +0,0 @@
{{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

@ -1,23 +0,0 @@
{{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

@ -1,34 +0,0 @@
{{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

@ -1,34 +0,0 @@
{{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

@ -1,37 +0,0 @@
{{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

@ -1,33 +0,0 @@
{{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

@ -1,23 +0,0 @@
{{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

@ -1,65 +0,0 @@
{{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

@ -1,36 +0,0 @@
{{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

@ -1,38 +0,0 @@
{{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

@ -1,55 +0,0 @@
{{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}}

View file

@ -1,44 +0,0 @@
// 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])
}

View file

@ -1,31 +0,0 @@
// 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")
}
}

View file

@ -1,139 +0,0 @@
// 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

@ -1,98 +0,0 @@
// 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)
}
})
}

View file

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

View file

@ -1,25 +0,0 @@
// 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)
}

View file

@ -1,39 +0,0 @@
// 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)
}
}

View file

@ -1,11 +0,0 @@
// 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
}

View file

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

View file

@ -1,34 +0,0 @@
// 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(),
}

View file

@ -1,23 +0,0 @@
// 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 Normal file
View file

@ -0,0 +1,107 @@
// 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 Normal file
View file

@ -0,0 +1,135 @@
// 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"},
},
}

55
scripts/gowiki_init Executable file
View file

@ -0,0 +1,55 @@
#!/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

4
scripts/install.sh Executable file
View file

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

View file

@ -1,32 +0,0 @@
#!/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

View file

@ -1,41 +0,0 @@
#!/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,10 +1,9 @@
#!/bin/bash
# Copyright (C) 2021 Marius Schellenberger
# Copyright (C) 2017 Marius Schellenberger
HASH="sha256"
DIR="pkg/server/static"
FILES="*.css *.js"
BINS="*.ico"
DIR="templates"
FILES="*.css *.js *.ico"
echo "SRI (subresource integrity) hashes"
cd ${DIR}
for f in $(ls ${FILES} 2>/dev/null); do
@ -13,7 +12,3 @@ 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

641
templates.go Normal file

File diff suppressed because one or more lines are too long

94
templates.sh Executable file
View file

@ -0,0 +1,94 @@
#!/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

16
templates/all.html Normal file
View file

@ -0,0 +1,16 @@
{{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}}

11
templates/bootstrap.css vendored Normal file

File diff suppressed because one or more lines are too long

View file

@ -22,12 +22,7 @@
float: left !important;
}
}
body {
padding-top: 60px;
}
.msg {
margin-top: 30px;
}
a.dark {
color: #222222;
}
@ -35,16 +30,6 @@ 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;
@ -52,6 +37,8 @@ nav {
}
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;
}
@ -67,7 +54,10 @@ h3 {
h4 {
font-size: 1.25em;
}
h5,h6 {
h5 {
font-size: 1em;
}
h6 {
font-size: 1em;
}
.md-text {
@ -87,64 +77,14 @@ pre {
border: 1px solid #444444;
border-radius: 2px;
}
pre.wrap {
white-space: pre-wrap;
}
code {
padding: 3px 3px 1px 0px;
padding: 3px 3px 1px 3px;
font-size: 90%;
color: #0cdba6;
background-color: #444444;
border-radius: 2px;
}
textarea,input,select {
textarea,input {
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%);
}

29
templates/index.html Normal file
View file

@ -0,0 +1,29 @@
<!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>

37
templates/login.html Normal file
View file

@ -0,0 +1,37 @@
{{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}}

49
templates/menu.html Normal file
View file

@ -0,0 +1,49 @@
{{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