initial commit
This commit is contained in:
commit
18995db757
871 changed files with 492725 additions and 0 deletions
275
pkg/cmd/cmd.go
Normal file
275
pkg/cmd/cmd.go
Normal file
|
|
@ -0,0 +1,275 @@
|
|||
// Copyright (C) 2019 Marius Schellenberger
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"git.giftfish.de/ston1th/docstore/pkg/core"
|
||||
"git.giftfish.de/ston1th/docstore/pkg/db"
|
||||
"git.giftfish.de/ston1th/docstore/pkg/log"
|
||||
"git.giftfish.de/ston1th/docstore/pkg/server"
|
||||
"git.giftfish.de/ston1th/godrop/v2"
|
||||
"github.com/urfave/cli"
|
||||
stdlog "log"
|
||||
"net"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
defDataDir = "/var/lib/docstore"
|
||||
defListen = "127.0.0.1:8080"
|
||||
defLogFile = "docstore.log"
|
||||
|
||||
defRunUser = "wiki"
|
||||
defRunGroup = "wiki"
|
||||
)
|
||||
|
||||
var (
|
||||
config core.Config
|
||||
dumpFile string
|
||||
configFile string
|
||||
)
|
||||
|
||||
func initServer(cfg core.Config) (err error) {
|
||||
// remove initial pledge due to violation with go1.12
|
||||
// sysctl kern.somaxconn
|
||||
//if err = godrop.PledgePromises("stdio rpath wpath cpath inet fattr flock proc exec id unveil"); err != nil {
|
||||
// return
|
||||
//}
|
||||
dropCfg := godrop.Config{
|
||||
User: cfg.User,
|
||||
Group: cfg.Group,
|
||||
Foreground: cfg.Foreground,
|
||||
}
|
||||
err = godrop.Drop(dropCfg, func() (net.Listener, error) {
|
||||
return net.Listen("tcp", cfg.ListenAddr)
|
||||
})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if err = os.Chdir(cfg.DataDir); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if err = godrop.PledgePromises("stdio rpath wpath cpath inet fattr flock unveil"); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
l, err := godrop.GetListener()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
log.InitLogger(cfg)
|
||||
|
||||
srv := server.NewHTTPServer(cfg, l)
|
||||
err = srv.Start()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
log.Println("docstore " + cfg.Version + " started")
|
||||
sigs := make(chan os.Signal)
|
||||
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("docstore 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 = "docstore"
|
||||
app.Usage = "a simple wiki engine"
|
||||
app.Version = version
|
||||
app.Commands = []cli.Command{
|
||||
{
|
||||
Name: "server",
|
||||
Usage: "start docstore 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 (docstore 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)
|
||||
}
|
||||
if dumpFile != "-" {
|
||||
file, err = os.OpenFile(dumpFile, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0640)
|
||||
if err != nil {
|
||||
stdlog.Fatal("dump: ", err)
|
||||
}
|
||||
}
|
||||
DB, err := db.New(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 (docstore 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.New(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
|
||||
},
|
||||
},
|
||||
}
|
||||
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 - 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...)
|
||||
}
|
||||
14
pkg/core/const.go
Normal file
14
pkg/core/const.go
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
// Copyright (C) 2019 Marius Schellenberger
|
||||
|
||||
package core
|
||||
|
||||
var (
|
||||
IndexURI = "/"
|
||||
TotpURI = "/totp"
|
||||
LoginURI = "/login"
|
||||
LogoutURI = "/logout"
|
||||
|
||||
AutoImport = "auto_import"
|
||||
ManualImport = "manual_import"
|
||||
Imported = "imported"
|
||||
)
|
||||
26
pkg/core/helper.go
Normal file
26
pkg/core/helper.go
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
package core
|
||||
|
||||
import "time"
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
const (
|
||||
timeFmt = "2006-01-02 15:04:05"
|
||||
fileFmt = "20060102_150405"
|
||||
)
|
||||
|
||||
func Now() string {
|
||||
return time.Now().Format(timeFmt)
|
||||
}
|
||||
|
||||
func FileName() string {
|
||||
return time.Now().Format(fileFmt)
|
||||
}
|
||||
41
pkg/core/permission.go
Normal file
41
pkg/core/permission.go
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
// Copyright (C) 2019 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
|
||||
}
|
||||
77
pkg/core/permission_test.go
Normal file
77
pkg/core/permission_test.go
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
// Copyright (C) 2019 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()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
31
pkg/core/timeout.go
Normal file
31
pkg/core/timeout.go
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
package core
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
"time"
|
||||
)
|
||||
|
||||
func Timeout(cmd *exec.Cmd, t time.Duration) (output []byte, err error) {
|
||||
done := make(chan error)
|
||||
out := make(chan []byte)
|
||||
go func() {
|
||||
o, e := cmd.Output()
|
||||
done <- e
|
||||
out <- o
|
||||
}()
|
||||
select {
|
||||
case <-time.After(t):
|
||||
e := cmd.Process.Kill()
|
||||
<-out
|
||||
if e != nil {
|
||||
<-done
|
||||
err = e
|
||||
return
|
||||
}
|
||||
err = <-done
|
||||
case e := <-done:
|
||||
err = e
|
||||
output = <-out
|
||||
}
|
||||
return
|
||||
}
|
||||
76
pkg/core/types.go
Normal file
76
pkg/core/types.go
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
// Copyright (C) 2019 Marius Schellenberger
|
||||
|
||||
package core
|
||||
|
||||
import "html/template"
|
||||
|
||||
type Page struct {
|
||||
Title string `json:"title"`
|
||||
StoreTitle string `json:"store_title"`
|
||||
Markdown string `json:"markdown"`
|
||||
TOC template.HTML `json:"toc"`
|
||||
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
|
||||
Secret string
|
||||
Locked int
|
||||
}
|
||||
|
||||
type Result struct {
|
||||
ID string
|
||||
HTML template.HTML
|
||||
}
|
||||
|
||||
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"`
|
||||
Foreground bool `json:"foreground,omitempty"`
|
||||
SecureCookie bool `json:"secure_cookie,omitempty"`
|
||||
Debug bool `json:"debug,omitempty"`
|
||||
}
|
||||
36
pkg/db/blacklist.go
Normal file
36
pkg/db/blacklist.go
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
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) {
|
||||
if db.store.Get(blacklistPrefix+sig, nil) == nil {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (db *DB) Map() (list jwt.BlacklistMap, err error) {
|
||||
list = make(jwt.BlacklistMap)
|
||||
err = db.store.ForEach(func(k string, v []byte) error {
|
||||
if trim, ok := hasTrimPrefix(k, blacklistPrefix); ok {
|
||||
var exp int64
|
||||
err := db.store.Unmarshal(v, &exp)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
list[trim] = exp
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return
|
||||
}
|
||||
63
pkg/db/db.go
Normal file
63
pkg/db/db.go
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
// Copyright (C) 2019 Marius Schellenberger
|
||||
|
||||
package db
|
||||
|
||||
import (
|
||||
"git.giftfish.de/ston1th/docstore/pkg/core"
|
||||
"git.giftfish.de/ston1th/docstore/pkg/index"
|
||||
"git.giftfish.de/ston1th/docstore/pkg/store"
|
||||
"git.giftfish.de/ston1th/godrop/v2"
|
||||
"io"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
const (
|
||||
storeFile = "store.db"
|
||||
bcryptCost = 13
|
||||
)
|
||||
|
||||
const blevePath = "bleve"
|
||||
|
||||
type DB struct {
|
||||
store store.Store
|
||||
Index *index.Index
|
||||
}
|
||||
|
||||
func New(cfg core.Config) (db *DB, err error) {
|
||||
db = new(DB)
|
||||
dbFile := filepath.Join(cfg.DataDir, storeFile)
|
||||
err = godrop.Unveil(dbFile, "rwc")
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
db.store, err = store.NewBoltStore(dbFile, nil)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
db.Index, err = index.NewIndex(filepath.Join(cfg.DataDir, blevePath))
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
err = db.UnlockUser()
|
||||
if 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) error {
|
||||
return db.store.Restore(r)
|
||||
}
|
||||
|
||||
func (db *DB) Close() (err error) {
|
||||
err = db.store.Close()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
return db.Index.Close()
|
||||
}
|
||||
24
pkg/db/helper.go
Normal file
24
pkg/db/helper.go
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
// Copyright (C) 2019 Marius Schellenberger
|
||||
|
||||
package db
|
||||
|
||||
import "crypto/sha256"
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
48
pkg/db/migration.go
Normal file
48
pkg/db/migration.go
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
package db
|
||||
|
||||
import (
|
||||
"git.giftfish.de/ston1th/docstore/pkg/log"
|
||||
"git.giftfish.de/ston1th/docstore/pkg/store"
|
||||
)
|
||||
|
||||
const (
|
||||
versionKey = "version/version"
|
||||
currentVersion = 1
|
||||
)
|
||||
|
||||
type migrator func(*DB) (int, error)
|
||||
|
||||
var migrators = []migrator{
|
||||
func(db *DB) (int, error) {
|
||||
return 0, nil
|
||||
},
|
||||
}
|
||||
|
||||
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 %d", v)
|
||||
version, err = migrators[v](db)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
err = db.store.Set(versionKey, version)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
169
pkg/db/user.go
Normal file
169
pkg/db/user.go
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
// Copyright (C) 2019 Marius Schellenberger
|
||||
|
||||
package db
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"git.giftfish.de/ston1th/docstore/pkg/core"
|
||||
"git.giftfish.de/ston1th/docstore/pkg/otp"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
userPrefix = "user/"
|
||||
userKey = "user"
|
||||
)
|
||||
|
||||
var (
|
||||
errWrongLogin = errors.New("db: wrong login")
|
||||
errLocked = errors.New("db: user locked")
|
||||
errUserExists = errors.New("db: user already exist")
|
||||
|
||||
userRe = regexp.MustCompile("[^a-zA-Z0-9]+")
|
||||
)
|
||||
|
||||
func (db *DB) UserExists() (b bool) {
|
||||
db.store.ForEach(func(k string, _ []byte) error {
|
||||
if strings.HasPrefix(k, userPrefix) {
|
||||
b = true
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
func (db *DB) GetUserWithoutPassword() (u core.User, err error) {
|
||||
u, err = db.GetUser()
|
||||
u.Password = ""
|
||||
return
|
||||
}
|
||||
|
||||
func (db *DB) GetUserSecret() (secret string, err error) {
|
||||
var user core.User
|
||||
user, err = db.GetUser()
|
||||
secret = user.Secret
|
||||
return
|
||||
}
|
||||
|
||||
func (db *DB) GetUser() (u core.User, err error) {
|
||||
err = db.store.Get(userPrefix+userKey, &u)
|
||||
return
|
||||
}
|
||||
|
||||
func (db *DB) CreateUser(username, password string) error {
|
||||
username = userRe.ReplaceAllString(username, "")
|
||||
if db.UserExists() {
|
||||
return errUserExists
|
||||
}
|
||||
hash, err := bcrypt.GenerateFromPassword(validatePassword(password), bcryptCost)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return db.store.Set(userPrefix+userKey, &core.User{
|
||||
Username: username,
|
||||
Password: string(hash),
|
||||
})
|
||||
}
|
||||
|
||||
func (db *DB) Login(username, password string) (u core.User, err error) {
|
||||
u, err = db.GetUser()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if u.Username != username {
|
||||
err = errWrongLogin
|
||||
return
|
||||
}
|
||||
if u.Locked == 3 {
|
||||
err = errLocked
|
||||
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 = errWrongLogin
|
||||
return
|
||||
}
|
||||
if u.Locked > 0 {
|
||||
u.Locked = 0
|
||||
err = db.store.Set(userPrefix+userKey, u)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (db *DB) Totp(pin string) (u core.User, err error) {
|
||||
u, err = db.GetUser()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if u.Locked == 3 {
|
||||
err = errLocked
|
||||
return
|
||||
}
|
||||
if !otp.Validate(pin, u.Secret) {
|
||||
u.Locked++
|
||||
err = db.store.Set(userPrefix+userKey, u)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
err = errors.New("db: wrong pin")
|
||||
return
|
||||
}
|
||||
if u.Locked > 0 {
|
||||
u.Locked = 0
|
||||
err = db.store.Set(userPrefix+userKey, u)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (db *DB) UnlockUser() (err error) {
|
||||
var u core.User
|
||||
u, err = db.GetUser()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if u.Locked > 0 {
|
||||
u.Locked = 0
|
||||
err = db.store.Set(userPrefix+userKey, u)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (db *DB) UpdateUserSecret(secret string) error {
|
||||
u, err := db.GetUser()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
u.Secret = secret
|
||||
return db.store.Set(userPrefix+userKey, u)
|
||||
}
|
||||
|
||||
func (db *DB) UpdateUserPassword(password string) error {
|
||||
if password == "" {
|
||||
return errors.New("empty password")
|
||||
}
|
||||
u, err := db.GetUser()
|
||||
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+userKey, u)
|
||||
}
|
||||
97
pkg/index/index.go
Normal file
97
pkg/index/index.go
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
// Copyright (C) 2019 Marius Schellenberger
|
||||
|
||||
package index
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"git.giftfish.de/ston1th/docstore/pkg/core"
|
||||
"git.giftfish.de/ston1th/godrop/v2"
|
||||
"github.com/blevesearch/bleve"
|
||||
"html/template"
|
||||
"os"
|
||||
)
|
||||
|
||||
const maxSearchResult = 101
|
||||
|
||||
type indexDocument struct {
|
||||
Text string `json:"text"`
|
||||
Date string `json:"date"`
|
||||
Type string `json:"type"`
|
||||
}
|
||||
|
||||
type Index struct {
|
||||
i bleve.Index
|
||||
}
|
||||
|
||||
func NewIndex(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) {
|
||||
mapping := bleve.NewIndexMapping()
|
||||
bi, err = bleve.New(path, mapping)
|
||||
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) Close() error {
|
||||
return i.i.Close()
|
||||
}
|
||||
|
||||
func (i *Index) Add(id, text, docType string) error {
|
||||
doc, _ := i.i.Document(id)
|
||||
if doc != nil {
|
||||
err := i.Delete(id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return i.i.Index(id, indexDocument{text, core.Now(), docType})
|
||||
}
|
||||
|
||||
func (i *Index) Delete(id string) error {
|
||||
return i.i.Delete(id)
|
||||
}
|
||||
|
||||
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{ID: hit.ID}
|
||||
d, err := i.i.Document(hit.ID)
|
||||
if d == nil || err != nil {
|
||||
continue
|
||||
}
|
||||
for fragField, frags := range hit.Fragments {
|
||||
if fragField == "text" || fragField == "type" || fragField == "date" {
|
||||
for _, f := range frags {
|
||||
r.HTML += template.HTML(f)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
results = append(results, r)
|
||||
}
|
||||
return
|
||||
}
|
||||
59
pkg/log/log.go
Normal file
59
pkg/log/log.go
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
// Copyright (C) 2019 Marius Schellenberger
|
||||
|
||||
package log
|
||||
|
||||
import (
|
||||
"git.giftfish.de/ston1th/docstore/pkg/core"
|
||||
"git.giftfish.de/ston1th/godrop/v2"
|
||||
stdlog "log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
var (
|
||||
log = stdlog.New(os.Stdout, "", stdlog.LstdFlags)
|
||||
debug = false
|
||||
)
|
||||
|
||||
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, 0640)
|
||||
if err != nil {
|
||||
stdlog.Fatal(err)
|
||||
}
|
||||
stdlog.SetOutput(f)
|
||||
log = stdlog.New(f, "", stdlog.LstdFlags)
|
||||
}
|
||||
|
||||
func Println(v ...interface{}) {
|
||||
log.Println(v...)
|
||||
}
|
||||
|
||||
func Printf(fmt string, v ...interface{}) {
|
||||
log.Printf(fmt, v...)
|
||||
}
|
||||
|
||||
func Fatal(v ...interface{}) {
|
||||
log.Fatal(v...)
|
||||
}
|
||||
|
||||
func Debug(v ...interface{}) {
|
||||
if debug {
|
||||
log.Println(append([]interface{}{"debug:"}, v...)...)
|
||||
}
|
||||
}
|
||||
|
||||
func Debugf(fmt string, v ...interface{}) {
|
||||
if debug {
|
||||
log.Printf("debug: "+fmt, v...)
|
||||
}
|
||||
}
|
||||
76
pkg/otp/otp.go
Normal file
76
pkg/otp/otp.go
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
// Copyright (C) 2019 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: "docstore",
|
||||
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
|
||||
}
|
||||
312
pkg/server/context.go
Normal file
312
pkg/server/context.go
Normal file
|
|
@ -0,0 +1,312 @@
|
|||
// Copyright (C) 2019 Marius Schellenberger
|
||||
|
||||
package server
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"git.giftfish.de/ston1th/docstore/pkg/core"
|
||||
"git.giftfish.de/ston1th/docstore/pkg/log"
|
||||
"git.giftfish.de/ston1th/jwt/v3"
|
||||
"github.com/gorilla/mux"
|
||||
"html/template"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
userClaim = "user"
|
||||
totpClaim = "totp"
|
||||
rememberClaim = "remember"
|
||||
refererClaim = "referer"
|
||||
)
|
||||
|
||||
func newContext(w http.ResponseWriter, r *http.Request, s *HTTPServer) (ctx *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")
|
||||
ctx = &Context{
|
||||
Request: r,
|
||||
Response: w,
|
||||
Srv: s,
|
||||
Time: time.Now(),
|
||||
}
|
||||
path := ctx.Path()
|
||||
if path == "/bootstrap.css" || path == "/custom.css" || path == "/favicon.ico" {
|
||||
return
|
||||
}
|
||||
|
||||
if c, err := r.Cookie(cookieName); err == nil {
|
||||
t, err := jwt.DecodeToken(c.Value)
|
||||
if err != nil {
|
||||
ctx.LogSetCookie("DecodeToken:", err)
|
||||
return
|
||||
}
|
||||
if err = s.JWT.Verify(t); err != nil {
|
||||
ctx.LogSetCookie("VerifyToken:", err)
|
||||
return
|
||||
}
|
||||
if t.Claims.GetString(totpClaim) != "" {
|
||||
if path == core.TotpURI || path == core.LoginURI || path == core.LogoutURI {
|
||||
ctx.Token = *t
|
||||
return
|
||||
}
|
||||
ctx.Redirect(core.TotpURI, http.StatusFound)
|
||||
return nil
|
||||
|
||||
}
|
||||
if err = s.JWT.Invalidate(t); err != nil {
|
||||
log.Println("Invalidate:", err)
|
||||
}
|
||||
}
|
||||
ctx.SetCookie(nil, 0)
|
||||
return
|
||||
}
|
||||
|
||||
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 int64
|
||||
|
||||
Title string
|
||||
BodyTitle string
|
||||
|
||||
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()[:keySize])
|
||||
|
||||
c.Data.Version = c.Srv.Config.Version
|
||||
if c.Data.BodyTitle == "" {
|
||||
c.Data.BodyTitle = c.Data.Title
|
||||
}
|
||||
c.Data.User = c.User()
|
||||
c.Data.Login = c.LoggedOn()
|
||||
c.Data.Time = time.Since(c.Time).Nanoseconds() / 1e6
|
||||
|
||||
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) SwitchHandler() {
|
||||
c.Srv.srv.Handler = c.Srv.handler
|
||||
c.Redirect(core.IndexURI, http.StatusFound)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// CheckXsrf validates the xsrf token
|
||||
func (c *Context) CheckXsrf() (ok bool) {
|
||||
ok = checkXsrf(c.Form("token"), c.Token.RawSig()[:keySize])
|
||||
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}, 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)
|
||||
}
|
||||
|
||||
type ctxHandler func(*Context)
|
||||
864
pkg/server/handler.go
Normal file
864
pkg/server/handler.go
Normal file
|
|
@ -0,0 +1,864 @@
|
|||
// Copyright (C) 2019 Marius Schellenberger
|
||||
|
||||
package server
|
||||
|
||||
import (
|
||||
"git.giftfish.de/ston1th/docstore/pkg/core"
|
||||
"git.giftfish.de/ston1th/docstore/pkg/log"
|
||||
"git.giftfish.de/ston1th/docstore/pkg/otp"
|
||||
"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 totpAuthHandler(h ctxHandler) ctxHandler {
|
||||
return func(ctx *Context) {
|
||||
if ctx.Totp() != "" {
|
||||
h(ctx)
|
||||
return
|
||||
}
|
||||
ctx.Redirect(core.IndexURI, http.StatusFound)
|
||||
}
|
||||
}
|
||||
|
||||
func authHandler(h ctxHandler) ctxHandler {
|
||||
return func(ctx *Context) {
|
||||
if ctx.LoggedOn() {
|
||||
h(ctx)
|
||||
return
|
||||
}
|
||||
ctx.Forbidden()
|
||||
}
|
||||
}
|
||||
|
||||
func staticHandler(ctx *Context) {
|
||||
var err error
|
||||
ctx.SetHeader("Content-Type", "text/css; charset=utf-8")
|
||||
ctx.SetHeader("Expires", time.Now().UTC().Add(max).Format(http.TimeFormat))
|
||||
switch ctx.Path() {
|
||||
case "/bootstrap.css":
|
||||
err = ctx.Write(ctx.Srv.res["bootstrap.css"])
|
||||
case "/custom.css":
|
||||
err = ctx.Write(ctx.Srv.res["custom.css"])
|
||||
case "/favicon.ico":
|
||||
ctx.SetHeader("Content-Type", "image/x-icon")
|
||||
err = ctx.Write(ctx.Srv.res["favicon.ico"])
|
||||
}
|
||||
if err != nil {
|
||||
log.Println("static:", err)
|
||||
}
|
||||
}
|
||||
|
||||
type registerNotFoundHandler struct {
|
||||
s *HTTPServer
|
||||
}
|
||||
|
||||
func (nf *registerNotFoundHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
newContext(w, r, nf.s).Redirect(core.IndexURI, http.StatusFound)
|
||||
}
|
||||
|
||||
func registerHandler(ctx *Context) {
|
||||
ctx.Template("registerHandler")
|
||||
ctx.Data = webData{
|
||||
Title: "Register User",
|
||||
BodyTitle: "Register 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")
|
||||
|
||||
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); err != nil {
|
||||
ctx.Error(err)
|
||||
return
|
||||
}
|
||||
ctx.SwitchHandler()
|
||||
}
|
||||
}
|
||||
|
||||
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.Println("login:", data.User, 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
|
||||
}
|
||||
u, err := ctx.Srv.DB.Totp(pin)
|
||||
if err != nil {
|
||||
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 = page.Title
|
||||
ctx.Data.BodyTitle = page.Title
|
||||
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.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("/users", 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("/users", 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("/user/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("/users", 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("/logout", http.StatusFound)
|
||||
return
|
||||
}
|
||||
ctx.Redirect("/users", http.StatusFound)
|
||||
}
|
||||
}
|
||||
|
||||
func logoutHandler(ctx *Context) {
|
||||
user := ctx.User()
|
||||
if user == "" {
|
||||
user = ctx.Totp() + " (totp)"
|
||||
}
|
||||
log.Println("logout:", user)
|
||||
ctx.Srv.JWT.Invalidate(&ctx.Token)
|
||||
ctx.SetCookie(nil, 0)
|
||||
ctx.Redirect(core.IndexURI, http.StatusFound)
|
||||
}
|
||||
45
pkg/server/helper.go
Normal file
45
pkg/server/helper.go
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
// Copyright (C) 2019 Marius Schellenberger
|
||||
|
||||
package server
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
day = time.Hour * 24
|
||||
min = time.Second * 30
|
||||
max = day * 90
|
||||
)
|
||||
|
||||
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
|
||||
}
|
||||
99
pkg/server/routes.go
Normal file
99
pkg/server/routes.go
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
// Copyright (C) 2019 Marius Schellenberger
|
||||
|
||||
package server
|
||||
|
||||
type route struct {
|
||||
Path string
|
||||
Handler ctxHandler
|
||||
Methods []string
|
||||
}
|
||||
|
||||
var static = []route{
|
||||
{
|
||||
"/favicon.ico",
|
||||
staticHandler,
|
||||
[]string{"GET"},
|
||||
},
|
||||
{
|
||||
"/bootstrap.css",
|
||||
staticHandler,
|
||||
[]string{"GET"},
|
||||
},
|
||||
{
|
||||
"/custom.css",
|
||||
staticHandler,
|
||||
[]string{"GET"},
|
||||
},
|
||||
}
|
||||
|
||||
var register = append(static, route{
|
||||
"/",
|
||||
registerHandler,
|
||||
[]string{"GET", "POST"},
|
||||
})
|
||||
|
||||
var routes = append(static, []route{
|
||||
{
|
||||
"/",
|
||||
authHandler(
|
||||
dirHandler),
|
||||
[]string{"GET"},
|
||||
},
|
||||
{
|
||||
"/sections",
|
||||
sectionsHandler,
|
||||
[]string{"GET"},
|
||||
},
|
||||
{
|
||||
"/search",
|
||||
authHandler(
|
||||
searchHandler),
|
||||
[]string{"GET", "POST"},
|
||||
},
|
||||
{
|
||||
"/upload",
|
||||
authHandler(
|
||||
searchHandler),
|
||||
[]string{"GET", "POST"},
|
||||
},
|
||||
{
|
||||
"/login",
|
||||
loginHandler,
|
||||
[]string{"GET", "POST"},
|
||||
},
|
||||
{
|
||||
"/totp",
|
||||
totpAuthHandler(
|
||||
loginTotpHandler),
|
||||
[]string{"GET", "POST"},
|
||||
},
|
||||
{
|
||||
"/user/edit/{user:[a-zA-Z0-9]+$}",
|
||||
authHandler(
|
||||
userEditHandler),
|
||||
[]string{"GET", "POST"},
|
||||
},
|
||||
{
|
||||
"/user/totp/{user:[a-zA-Z0-9]+$}",
|
||||
authHandler(
|
||||
userTotpHandler),
|
||||
[]string{"GET", "POST"},
|
||||
},
|
||||
{
|
||||
"/logout",
|
||||
logoutHandler,
|
||||
[]string{"GET", "POST"},
|
||||
},
|
||||
{
|
||||
"/{dir}",
|
||||
authHandler(
|
||||
dirHandler),
|
||||
[]string{"GET"},
|
||||
},
|
||||
{
|
||||
"/{dir}/{file}",
|
||||
authHandler(
|
||||
fileHandler),
|
||||
[]string{"GET"},
|
||||
},
|
||||
}...)
|
||||
112
pkg/server/server.go
Normal file
112
pkg/server/server.go
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
// Copyright (C) 2019 Marius Schellenberger
|
||||
|
||||
package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"git.giftfish.de/ston1th/docstore/pkg/core"
|
||||
"git.giftfish.de/ston1th/docstore/pkg/db"
|
||||
"git.giftfish.de/ston1th/docstore/pkg/log"
|
||||
"git.giftfish.de/ston1th/godrop/v2"
|
||||
"git.giftfish.de/ston1th/jwt/v3"
|
||||
"github.com/gorilla/mux"
|
||||
"html/template"
|
||||
"net"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
cookieName = "gosession"
|
||||
)
|
||||
|
||||
type HTTPServer struct {
|
||||
Config core.Config
|
||||
|
||||
listener net.Listener
|
||||
srv *http.Server
|
||||
handler http.Handler
|
||||
|
||||
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,
|
||||
handler: srv.buildRoutes(),
|
||||
}
|
||||
srv.loadTemplates()
|
||||
srv.srv = &http.Server{}
|
||||
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
|
||||
}
|
||||
if s.Config.Secret != "" {
|
||||
s.JWT, err = jwt.New(jwt.DefaultExpiry, s.DB, bytes.NewBufferString(s.Config.Secret))
|
||||
} else {
|
||||
s.JWT, err = jwt.New(jwt.DefaultExpiry, s.DB, nil)
|
||||
}
|
||||
if err != nil {
|
||||
return errors.New("server: " + err.Error())
|
||||
}
|
||||
if !s.DB.UserExists() {
|
||||
s.srv.Handler = s.register()
|
||||
} else {
|
||||
s.srv.Handler = s.handler
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
func (s *HTTPServer) register() http.Handler {
|
||||
m := mux.NewRouter()
|
||||
m.NotFoundHandler = ®isterNotFoundHandler{s}
|
||||
for _, v := range register {
|
||||
m.HandleFunc(v.Path, s.contextWrapper(v.Handler)).Methods(v.Methods...)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func (s *HTTPServer) buildRoutes() http.Handler {
|
||||
m := mux.NewRouter()
|
||||
m.NotFoundHandler = ¬FoundHandler{s}
|
||||
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) {
|
||||
ctx := newContext(w, r, s)
|
||||
if ctx == nil {
|
||||
return
|
||||
}
|
||||
h(ctx)
|
||||
}
|
||||
}
|
||||
1039
pkg/server/templates.go
Normal file
1039
pkg/server/templates.go
Normal file
File diff suppressed because one or more lines are too long
49
pkg/server/xsrf.go
Normal file
49
pkg/server/xsrf.go
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
// Copyright (C) 2019 Marius Schellenberger
|
||||
|
||||
package server
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/subtle"
|
||||
"encoding/base64"
|
||||
"git.giftfish.de/ston1th/docstore/pkg/log"
|
||||
"git.giftfish.de/ston1th/jwt/v3"
|
||||
)
|
||||
|
||||
const keySize = jwt.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 {
|
||||
rnd := genKey(keySize)
|
||||
return base64.RawURLEncoding.EncodeToString(append(rnd, xor(rnd, secret)...))
|
||||
}
|
||||
|
||||
func checkXsrf(xsrf string, secret []byte) bool {
|
||||
t, err := base64.RawURLEncoding.DecodeString(xsrf)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
if len(t) != jwt.KeySize {
|
||||
return false
|
||||
}
|
||||
return subtle.ConstantTimeCompare(secret, xor(t[keySize:], t[:keySize])) == 1
|
||||
}
|
||||
|
||||
func xor(a, b []byte) (c []byte) {
|
||||
n := len(a)
|
||||
if len(b) < n {
|
||||
return nil
|
||||
}
|
||||
c = make([]byte, n)
|
||||
for i := 0; i < n; i++ {
|
||||
c[i] = a[i] ^ b[i]
|
||||
}
|
||||
return
|
||||
}
|
||||
31
pkg/server/xsrf_test.go
Normal file
31
pkg/server/xsrf_test.go
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
// Copyright (C) 2019 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")
|
||||
}
|
||||
}
|
||||
130
pkg/store/boltstore.go
Normal file
130
pkg/store/boltstore.go
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
// Copyright (C) 2019 Marius Schellenberger
|
||||
|
||||
package store
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"github.com/boltdb/bolt"
|
||||
"io"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultBoltBucket = "default"
|
||||
fileMode = 0640
|
||||
)
|
||||
|
||||
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) 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()
|
||||
}
|
||||
99
pkg/store/boltstore_test.go
Normal file
99
pkg/store/boltstore_test.go
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
// Copyright (C) 2019 Marius Schellenberger
|
||||
|
||||
package store
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"io/ioutil"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type testData struct {
|
||||
Data string
|
||||
}
|
||||
|
||||
func TestBoltStore(t *testing.T) {
|
||||
data := []testData{
|
||||
{"123"},
|
||||
{"hello"},
|
||||
}
|
||||
dir, err := ioutil.TempDir("", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.RemoveAll(dir)
|
||||
bs, err := NewBoltStore(filepath.Join(dir, "bolt.db"), nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
err = bs.Set("key0", data[0])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
err = bs.Set("key1", data[1])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
t.Run("GetKey0", func(t *testing.T) {
|
||||
var d testData
|
||||
err := bs.Get("key0", &d)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if d != data[0] {
|
||||
t.Error("d is not data[0]")
|
||||
}
|
||||
})
|
||||
t.Run("ForEach", func(t *testing.T) {
|
||||
err := bs.ForEach(func(k string, v []byte) error {
|
||||
if k == "key0" || k == "key1" {
|
||||
return nil
|
||||
}
|
||||
return errors.New("key0 or key1 not found")
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
t.Run("DumpRestore", func(t *testing.T) {
|
||||
buf := new(bytes.Buffer)
|
||||
err := bs.Dump(buf)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if buf.Len() == 0 {
|
||||
t.Error("no dump written")
|
||||
}
|
||||
err = bs.Restore(buf)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
t.Run("DeleteKey1", func(t *testing.T) {
|
||||
err := bs.Delete("key1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
t.Run("GetKey1", func(t *testing.T) {
|
||||
var d testData
|
||||
err := bs.Get("key1", &d)
|
||||
if err == nil {
|
||||
t.Error("key1 should be deleted")
|
||||
}
|
||||
if d == data[1] {
|
||||
t.Error("d is not data[1]")
|
||||
}
|
||||
})
|
||||
t.Run("Close", func(t *testing.T) {
|
||||
err := bs.Close()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
}
|
||||
13
pkg/store/dump.go
Normal file
13
pkg/store/dump.go
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
// Copyright (C) 2019 Marius Schellenberger
|
||||
|
||||
package store
|
||||
|
||||
import "io"
|
||||
|
||||
type Dumper interface {
|
||||
Dump(io.Writer) error
|
||||
}
|
||||
|
||||
type Restorer interface {
|
||||
Restore(io.Reader) error
|
||||
}
|
||||
30
pkg/store/marshal.go
Normal file
30
pkg/store/marshal.go
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
// Copyright (C) 2019 Marius Schellenberger
|
||||
|
||||
package store
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/gob"
|
||||
)
|
||||
|
||||
type Marshaler interface {
|
||||
Marshal(v interface{}) ([]byte, error)
|
||||
Unmarshal(data []byte, v interface{}) error
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
22
pkg/store/store.go
Normal file
22
pkg/store/store.go
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
// Copyright (C) 2019 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
|
||||
Delete(string) error
|
||||
Close() error
|
||||
}
|
||||
60
pkg/tesseract/tesseract.go
Normal file
60
pkg/tesseract/tesseract.go
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
package tesseract
|
||||
|
||||
import (
|
||||
"git.giftfish.de/ston1th/docstore/pkg/core"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Tesseract struct {
|
||||
cmd string
|
||||
args []string
|
||||
dir string
|
||||
timeout time.Duration
|
||||
}
|
||||
|
||||
func NewTesseract(dir string, langs []string, timeout time.Duration) (t *Tesseract, err error) {
|
||||
var (
|
||||
cmd string
|
||||
lang string
|
||||
)
|
||||
cmd, err = exec.LookPath("tesseract")
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
for i, l := range langs {
|
||||
lang += l
|
||||
if i < len(langs) {
|
||||
lang += "+"
|
||||
}
|
||||
}
|
||||
args := []string{"-l", lang, "--oem", "2"}
|
||||
t = &Tesseract{cmd, args, dir, timeout}
|
||||
return
|
||||
}
|
||||
|
||||
func (t *Tesseract) Scan(file string) (filename, text string, err error) {
|
||||
filename = core.FileName() + ".pdf"
|
||||
txt, err := core.Timeout(exec.Command(t.cmd, append(t.args, []string{file, "stdout", "txt"}...)...), t.timeout)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
text = string(txt)
|
||||
pdf, err := core.Timeout(exec.Command(t.cmd, append(t.args, []string{file, "stdout", "pdf"}...)...), t.timeout)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
f, err := os.OpenFile(filepath.Join(t.dir, filename), os.O_RDWR|os.O_CREATE, 0640)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
_, err = f.Write(pdf)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
err = os.Remove(file)
|
||||
return
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue