diff --git a/.gitignore b/.gitignore index 7fb25a4..bd54b44 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,3 @@ gowiki +tmp +run.sh diff --git a/Godeps/Godeps.json b/Godeps/Godeps.json index ab38ad3..02c4088 100644 --- a/Godeps/Godeps.json +++ b/Godeps/Godeps.json @@ -1,12 +1,12 @@ { "ImportPath": "git.giftfish.de/ston1th/gowiki", - "GoVersion": "go1.7", - "GodepVersion": "v74", + "GoVersion": "go1.8", + "GodepVersion": "v79", "Deps": [ { "ImportPath": "git.giftfish.de/ston1th/godrop", - "Comment": "v1.0", - "Rev": "8666e4461c9612e1432f05af6e0abfd716adca36" + "Comment": "v1.1", + "Rev": "108583814ef55f34ac8e939cc47699dc7e9befb8" }, { "ImportPath": "github.com/blevesearch/bleve", @@ -183,8 +183,8 @@ }, { "ImportPath": "github.com/gorilla/mux", - "Comment": "v1.1", - "Rev": "0eeaf8392f5b04950925b8a69fe70f110fa7cbfc" + "Comment": "v1.3.0", + "Rev": "392c28fe23e1c45ddba891b0320b3b5df220beea" }, { "ImportPath": "github.com/gorilla/securecookie", @@ -193,12 +193,13 @@ }, { "ImportPath": "github.com/gorilla/sessions", - "Rev": "f7261893ca3ea922c30eabe742c036d2c1de6e0a" + "Comment": "v1.1", + "Rev": "ca9ada44574153444b00d3fd9c8559e4cc95f896" }, { "ImportPath": "github.com/russross/blackfriday", - "Comment": "v1.4-31-g2004188", - "Rev": "2004188462c3946efefbdcd91aa83e49b4175bfb" + "Comment": "v1.4", + "Rev": "0b647d0506a698cca42caca173e55559b12a69f2" }, { "ImportPath": "github.com/shurcooL/sanitized_anchor_name", @@ -208,6 +209,11 @@ "ImportPath": "github.com/steveyen/gtreap", "Rev": "0abe01ef9be25c4aedc174758ec2d917314d6d70" }, + { + "ImportPath": "github.com/urfave/cli", + "Comment": "v1.19.1", + "Rev": "0bdeddeeb0f650497d603c4ad7b20cfe685682f6" + }, { "ImportPath": "golang.org/x/crypto/bcrypt", "Rev": "9477e0b78b9ac3d0b03822fd95422e2fe07627cd" diff --git a/Makefile b/Makefile index d21289b..3131911 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,8 @@ CC=go BUILD=build -v -LDFLAGS=--ldflags "-s -w" +VERSION=$(shell cat VERSION) +GCFLAGS=-gcflags '-e' +LDFLAGS=-ldflags '-X main.version=$(VERSION) -s -w' PROGRAM=gowiki all: $(PROGRAM) diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..c0ec837 --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +v2.0 diff --git a/cache.go b/cache.go new file mode 100644 index 0000000..27723ba --- /dev/null +++ b/cache.go @@ -0,0 +1,49 @@ +package main + +import ( + "sync" + "time" +) + +type CachedArticle struct { + Article *Article + CacheTime time.Time +} + +type IndexCache struct { + sync.RWMutex + Cache map[string]*CachedArticle +} + +func NewIndexCache() *IndexCache { + return &IndexCache{Cache: make(map[string]*CachedArticle)} +} + +func (ic IndexCache) Add(title string, a *Article) { + now := time.Now() + ic.Lock() + defer ic.Unlock() + c, ok := ic.Cache[title] + if ok && now.After(c.CacheTime) { + c.Article = a + c.CacheTime = now + ic.Cache[title] = c + return + } + ic.Cache[title] = &CachedArticle{a, now} +} + +func (ic IndexCache) Get(title string) *Article { + ic.RLock() + defer ic.RUnlock() + if a, ok := ic.Cache[title]; ok { + return a.Article + } + return nil +} + +func (ic *IndexCache) Delete(title string) { + ic.Lock() + delete(ic.Cache, title) + ic.Unlock() +} diff --git a/cmd.go b/cmd.go new file mode 100644 index 0000000..f50f4d5 --- /dev/null +++ b/cmd.go @@ -0,0 +1,193 @@ +// Copyright (C) 2017 Marius Schellenberger + +package main + +import ( + "errors" + "git.giftfish.de/ston1th/godrop" + "github.com/urfave/cli" + logger "log" + "net" + "os" + "os/signal" + "syscall" + "time" +) + +const ( + blevePath = "bleve" + + defDataDir = "/var/lib/gowiki" + defListen = "127.0.0.1:8090" + defLogFile = "gowiki.log" + + defRunUser = "wiki" + defRunGroup = "wiki" +) + +func server(conf serverConfig) (err error) { + dropCfg := godrop.Config{ + User: conf.User, + Group: conf.Group, + Foreground: conf.Foreground, + } + err = godrop.Drop(dropCfg, func() (net.Listener, error) { + return net.Listen("tcp", conf.ListenAddr) + }) + if err != nil { + return + } + if err = os.Chdir(conf.DataDir); err != nil { + return + } + + newLogger(conf.LogFile) + + l, err := godrop.GetListener() + if err != nil { + return + } + + srv := NewHTTPServer(l, conf.Version, conf.SecureCookie) + err = srv.Start() + if err != nil { + return + } + 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 = srv.Stop() + if err != nil { + log.Println(err) + } + log.Println("gowiki stopped") + return +} + +var ( + verbose bool + conf serverConfig +) + +func run(app *cli.App) { + app.Commands = []cli.Command{ + { + Name: "server", + Usage: "start gowiki server", + Flags: defaultFlags(serverFlags()), + Action: func(c *cli.Context) error { + conf.Version = app.Version + if err := server(conf); err != nil { + logger.Fatal("server: ", err) + } + return nil + }, + }, + { + Name: "dump", + Usage: "create a json dump of all articles in the data directory (dump is written to stdout) gowiki needs to be stopped", + Flags: defaultFlags(dumpRestoreFlags()), + Action: func(c *cli.Context) error { + if err := os.Chdir(conf.DataDir); err != nil { + logger.Fatal("dump: ", err) + } + if err := dump(blevePath, verbose); err != nil { + logger.Fatal("dump: ", err) + } + return nil + }, + }, + { + Name: "restore", + Usage: "restore a json dump to the data directory (dump is read from stdin) gowiki needs to be stopped", + Flags: defaultFlags(dumpRestoreFlags()), + Action: func(c *cli.Context) error { + if err := os.Chdir(conf.DataDir); err != nil { + logger.Fatal("restore: ", err) + } + if err := restore(blevePath, verbose); err != nil { + logger.Fatal("restore: ", err) + } + return nil + }, + }, + } + app.Run(os.Args) +} + +type serverConfig struct { + DataDir string + User string + Group string + ListenAddr string + LogFile string + Version string + Foreground bool + SecureCookie bool +} + +func serverFlags() []cli.Flag { + return []cli.Flag{ + cli.StringFlag{ + Name: "user, u", + Value: defRunUser, + Usage: "drop privileges to user", + Destination: &conf.User, + }, + cli.StringFlag{ + Name: "group, g", + Value: defRunGroup, + Usage: "drop privileges to group", + Destination: &conf.Group, + }, + cli.StringFlag{ + Name: "listen, l", + Value: defListen, + Usage: "listening
:", + Destination: &conf.ListenAddr, + }, + cli.StringFlag{ + Name: "log", + Value: defLogFile, + Usage: "log file (leave empty for stdout, this only works in combination with -f)", + Destination: &conf.LogFile, + }, + cli.BoolFlag{ + Name: "foreground, f", + Usage: "do not fork into the background", + Destination: &conf.Foreground, + }, + cli.BoolFlag{ + Name: "secure, s", + Usage: "enable secure cookie", + Destination: &conf.SecureCookie, + }, + } +} + +func dumpRestoreFlags() []cli.Flag { + return []cli.Flag{ + cli.BoolFlag{ + Name: "verbose, v", + Usage: "enable verbose mode", + Destination: &verbose, + }, + } +} + +func defaultFlags(f []cli.Flag) []cli.Flag { + return append([]cli.Flag{ + cli.StringFlag{ + Name: "data, d", + Value: defDataDir, + Usage: "data directory", + Destination: &conf.DataDir, + }, + }, f...) +} diff --git a/context.go b/context.go index 0513111..44f9c79 100644 --- a/context.go +++ b/context.go @@ -3,63 +3,16 @@ 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) +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") @@ -68,18 +21,27 @@ func newContext(w http.ResponseWriter, r *http.Request) *Context { 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 Data webData } type webData struct { + Version string + Time int64 + Title string BodyTitle string @@ -91,25 +53,53 @@ type webData struct { Msg string Search string + Editor *Editor + Data interface{} } func (c *Context) Exec() { + defer c.log() if c.T == nil { - log.Println("Exec:", errors.New("template is nil")) + c.Status = http.StatusInternalServerError + c.Err = errors.New("template is nil") return } + c.newToken() + c.Data.Version = c.Srv.Version + if c.Data.BodyTitle == "" { + c.Data.BodyTitle = c.Data.Title + } 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) + c.Data.Time = time.Since(c.Time).Nanoseconds() / 1e6 + 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) NotFound() { + c.Template("notFoundHandler") + c.Data = webData{Title: "404"} + c.Status = http.StatusNotFound + c.Response.WriteHeader(http.StatusNotFound) + c.Exec() +} func (c *Context) Template(name string) { - c.T = templ[name] + c.T = c.Srv.templ[name] } func (c *Context) Write(buf []byte) (err error) { @@ -122,6 +112,7 @@ func (c *Context) SetHeader(name, value string) { } func (c *Context) Redirect(uri string, code int) { + 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) } @@ -149,12 +140,12 @@ func (c *Context) Var(name string) (ret string) { return } -func (c *Context) InitToken() { +func (c *Context) initToken() { token := c.getToken() if token == nil || len(token) != keyLen { - s, _ := store.Get(c.Request, cookieName) + s, _ := c.Srv.Store.Get(c.Request, cookieName) s.Values["token"] = genKey(keyLen) - s.Options = store.Options + s.Options = c.Srv.Store.Options err := s.Save(c.Request, c.Response) if err != nil { log.Println("InitToken: ", err) @@ -162,14 +153,14 @@ func (c *Context) InitToken() { } } -// NewToken sets and returns the xsrf token -func (c *Context) NewToken() { +func (c *Context) newToken() { + c.initToken() 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 { +func (c *Context) CheckToken(f func()) bool { token, err := base64.StdEncoding.DecodeString(c.Form("token")) if err != nil { log.Println("CheckToken: ", err) @@ -178,36 +169,45 @@ func (c *Context) CheckToken() bool { if len(token) != authLen { return false } - return subtle.ConstantTimeCompare(c.getToken(), xor(token[keyLen:], token[:keyLen])) == 1 + ok := subtle.ConstantTimeCompare(c.getToken(), xor(token[keyLen:], token[:keyLen])) == 1 + if !ok { + if f != nil { + f() + return ok + } + c.Data.Msg = "wrong csrf token" + c.Exec() + } + return ok } func (c *Context) LoggedOn() (ok bool) { - s, _ := store.Get(c.Request, cookieName) + s, _ := c.Srv.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, _ := c.Srv.Store.Get(c.Request, cookieName) s.Values["user"] = user s.Values["admin"] = admin - s.Options = store.Options + s.Options = c.Srv.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, _ := c.Srv.Store.Get(c.Request, cookieName) s.Values = make(map[interface{}]interface{}) - s.Options = store.Options + s.Options = c.Srv.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) + s, _ := c.Srv.Store.Get(c.Request, cookieName) if ret, ok := s.Values["user"].(string); ok { return ret } @@ -215,7 +215,7 @@ func (c *Context) GetUser() string { } func (c *Context) GetAdmin() bool { - s, _ := store.Get(c.Request, cookieName) + s, _ := c.Srv.Store.Get(c.Request, cookieName) if ret, ok := s.Values["admin"].(bool); ok { return ret } @@ -223,7 +223,7 @@ func (c *Context) GetAdmin() bool { } func (c *Context) getToken() []byte { - s, _ := store.Get(c.Request, cookieName) + s, _ := c.Srv.Store.Get(c.Request, cookieName) t, ok := s.Values["token"] if !ok { return nil diff --git a/dump.go b/dump.go index 4cea771..5712078 100644 --- a/dump.go +++ b/dump.go @@ -6,10 +6,14 @@ import ( "encoding/json" "fmt" "os" - - "github.com/blevesearch/bleve" ) +type Dump struct { + Articles []DumpArticle `json:"articles"` + Snippets []Snippet `json:"snippets"` + Users []User `json:"users"` +} + type DumpArticle struct { Title string `json:"title"` LinkTitle string `json:"link_title"` @@ -19,35 +23,38 @@ type DumpArticle struct { Public bool `json:"public"` } -func verboseLog(prefix, title string) { - if verbose { +func verboseLog(prefix, title string, v bool) { + if v { fmt.Fprintln(os.Stderr, prefix, title) } } -func dumpWiki() error { - index, err := bleve.OpenUsing(bleveStore, map[string]interface{}{"read_only": true}) +func dump(path string, v bool) (err error) { + i, err := NewDumpIndex(path) if err != nil { - return err + return } - engine = NewEngine(index) - art, err := engine.Get(false, indexName) + defer i.Close() + bs, err := NewBoltStore() if err != nil { - return err + return } - defer engine.Close() - var arts []DumpArticle - titles, err := engine.Dump() + art, err := i.Get(false, indexName) if err != nil { - return err + return + } + var dump Dump + titles, err := i.Dump() + if err != nil { + return } for _, title := range titles { - art, err = engine.Get(false, title) + art, err = i.Get(false, title) if err != nil { - return err + return } - verboseLog("dump:", art.Title) - arts = append(arts, DumpArticle{ + verboseLog("dump:", art.Title, v) + dump.Articles = append(dump.Articles, DumpArticle{ Title: art.Title, LinkTitle: art.LinkTitle, MD: art.MD, @@ -56,31 +63,41 @@ func dumpWiki() error { 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) + //TODO snippets + //verboseLog("dump:", "snippets", v) + verboseLog("dump:", "users", v) + dump.Users, err = bs.DumpUsers() if err != nil { return } - for _, art := range arts { - verboseLog("restore:", art.Title) - err = engine.Restore(art) + return json.NewEncoder(os.Stdout).Encode(dump) +} + +func restore(path string, v bool) (err error) { + i, err := NewIndex(path) + if err != nil { + return + } + defer i.Close() + bs, err := NewBoltStore() + if err != nil { + return + } + var dump Dump + err = json.NewDecoder(os.Stdin).Decode(&dump) + if err != nil { + return + } + for _, art := range dump.Articles { + verboseLog("restore:", art.Title, v) + err = i.Restore(&art) if err != nil { return } } + //TODO snippets + //verboseLog("restore:", "snippets", v) + verboseLog("restore:", "users", v) + err = bs.RestoreUsers(dump.Users) return } diff --git a/dump.test b/dump.test new file mode 100644 index 0000000..e52948d --- /dev/null +++ b/dump.test @@ -0,0 +1 @@ +{"articles":[{"title":"Index","link_title":"Index","md":"# Welcome to GoWiki\nThis is the [Index](/wiki/Index) page. \nYou can customize it how you like.","created":"admin 2017-04-20 15:25:38","updated":"","public":true},{"title":"dcsdcsd","link_title":"dcsdcsd","md":"# OpenBSD httpd\r\n\r\nEnable and start service:\r\n\r\n```\r\nrcctl enable httpd\r\nrcctl start httpd\r\n```\r\n\r\n## pf Rules\r\n\r\n```\r\ncat \u003c\u003cEOF\u003e\u003e /etc/pf.conf\r\npass in on egress proto tcp to any port http\r\npass in on egress proto tcp to any port https\r\nEOF\r\npfctl -f /etc/pf.conf\r\n```\r\n\r\n## vHosts\r\n\r\nhttpd config file: `/etc/httpd.conf`\r\n\r\n### TLS vHost\r\n\r\nCertificate using acme. More info at [OpenBSD Lets Encrypt](/wiki/OpenBSD+Lets+Encrypt)\r\n\r\n```\r\ndomain=\"example.com\"\r\n\r\nserver $domain {\r\n listen on * port 80\r\n location \"/.well-known/acme-challenge/*\" {\r\n root { \"/acme\", strip 2 }\r\n }\r\n location \"/*\" {\r\n block return 301 \"https://$SERVER_NAME$DOCUMENT_URI\"\r\n }\r\n}\r\n\r\nserver $domain {\r\n listen on * tls port 443\r\n tls {\r\n certificate \"/etc/ssl/acme/example.com/fullchain.pem\"\r\n key \"/etc/ssl/acme/private/example.com/privkey.pem\"\r\n ecdhe secp384r1\r\n ciphers ECDHE-RSA-CHACHA20-POLY1305:CHACHA20:ECDHE+AESGCM:ECDHE+AES\r\n }\r\n hsts { max-age 31536000, subdomains }\r\n location \"/\" {\r\n directory auto index\r\n }\r\n}\r\n```\r\n## robots.txt\r\n\r\nCreate a new directory: `mkdir /var/www/robots`\r\n\r\nCreate the robots.txt file:\r\n\r\n```\r\ncat \u003c\u003cEOF\u003e /var/www/robots/robots.txt\r\nUser-agent: *\r\nDisallow: /\r\nEOF\r\n```\r\n\r\nAdd this to your httpd.conf:\r\n\r\n```\r\nlocation \"/robots.txt\" {\r\n root \"/robots\"\r\n}\r\n```","created":"admin 2017-04-22 13:14:30","updated":"admin 2017-04-22 13:23:49","public":true},{"title":"mail","link_title":"mail","md":"# OpenBSD Mail Server\r\n\r\n## Intro\r\n\r\nThis is how to setup an OpenBSD Mail Server with:\r\n\r\n* OpenSMTPD (mta)\r\n* Spamd\r\n* ClamAV (in and out scanning)\r\n* Zarafa (webapp, z-push) not included are IMAP, POP3, CalDav. Maybe they will be added later.\r\n\r\n\r\nNote: This is mostly a copy/past guide. It is still recommended to read the man-pages to 'understand' why and how things happen.\r\n\r\n## Mail-Flow\r\n\r\n### Incoming mail\r\n\r\n```\r\npf -\u003e relay to spamd -\u003e send to opensmtpd on lo0 -\u003e relay untagged mail to clamsmtpd on port 10025 -\u003e relay to clamd on port 3310 -\u003e\r\nreturn to clamsmtpd -\u003e return to opensmtpd on lo0 port 10026 and tag it CLAM_IN -\u003e deliver to mda (zarafa-dagent)\r\n```\r\n\r\n### Outgoing mail\r\n\r\n```\r\nzarafa (zarafa-spooler) -\u003e opensmtpd on lo0 -\u003e relay untagged mail to clamsmtpd on port 10027 -\u003e relay to clamd on port 3310 -\u003e\r\nreturn to clamsmtpd -\u003e return to opensmtpd on lo0 port 10028 and tag it CLAM_OUT -\u003e relay out\r\n```\r\n\r\n## smtpd\r\n\r\nBoot startup: \r\n\r\n```\r\nrcctl enable smtpd\r\nrcctl set smtpd flags -v\r\n```\r\n\r\n### Virtual Domains\r\n\r\nThis file holds all virtual domains:\r\n\r\n```\r\ncat /etc/mail/vdomains\r\nexample.com\r\nexample.org\r\n```\r\n\r\n### Virtual Users\r\n\r\nThis file holds all virtual users:\r\n\r\n```\r\ncat /etc/mail/vusers\r\njohn@example.com john\r\n@example.com john@example.com\r\ndoe@example.org doe\r\nhostmaster@example.org doe@example.org\r\n```\r\n\r\nThis file holds all user mappings:\r\n\r\n```\r\ncat /etc/mail/umap\r\njohn 1000:100:/var/virtual\r\ndoe 1000:100:/var/virtual\r\n```\r\n\r\n### smtpd.conf\r\n\r\nReplace `example.com` with your domain.\r\n\r\n```\r\nmax-message-size 20MB\r\npki mail.example.com certificate \"/etc/ssl/acme/example.com/fullchain.pem\"\r\npki mail.example.com key \"/etc/ssl/acme/private/example.com/privkey.pem\"\r\nciphers ECDHE-RSA-CHACHA20-POLY1305:CHACHA20:ECDHE+AESGCM:ECDHE+AES\r\n\r\nlisten on lo0\r\nlisten on lo0 port 10026 tag CLAM_IN \r\nlisten on lo0 port 10028 tag CLAM_OUT \r\nlisten on egress tls pki mail.example.com auth-optional\r\n\r\ntable aliases file:/etc/mail/aliases\r\ntable vdomains file:/etc/mail/vdomains\r\ntable vusers file:/etc/mail/vusers\r\ntable umap file:/etc/mail/umap\r\n\r\naccept for local alias \u003caliases\u003e deliver to mbox\r\n\r\naccept tagged CLAM_IN for domain \u003cvdomains\u003e virtual \u003cvusers\u003e userbase \u003cumap\u003e \\\r\n deliver to mda \"/usr/local/sbin/zarafa-dagent %{dest}\" as \"_zarafa\"\r\naccept tagged CLAM_OUT for any relay\r\n\r\naccept from any for domain \u003cvdomains\u003e relay via smtp://127.0.0.1:10025 \r\naccept from local for any relay via smtp://127.0.0.1:10027\r\n```\r\n\r\nRestart smtpd: `rcctl restart smtpd`\r\n\r\n## spamd\r\n\r\nCreate custom spam files:\r\n\r\n```\r\ntouch /etc/mail/nospamd\r\ntouch /etc/mail/spamd.black\r\n```\r\n\r\nConfig:\r\n\r\n```\r\nall:\\\r\n :nixspam:custom_black:\r\n\r\nnixspam:\\\r\n :black:\\\r\n :msg=\"Your address %A is in the nixspam list\\n\\\r\n See http://www.heise.de/ix/nixspam/dnsbl_en/ for details\":\\\r\n :method=http:\\\r\n :file=www.openbsd.org/spamd/nixspam.gz\r\n\r\ncustom_black:\\\r\n :black:\\\r\n :msg=\"Your address %A has been manually blacklisted\":\\\r\n :method=file:\\\r\n :file=/etc/mail/custom.black:\r\n```\r\n\r\nBoot startup: \r\n\r\nReplace `example.com` with your domain.\r\n\r\n```\r\nrcctl enable spamd\r\nrcctl set spamd flags \"-v -G 10:4:864 -K /etc/ssl/acme/private/example.com/privkey.pem -C /etc/ssl/acme/example.com/fullchain.pem\"\r\nrcctl enable spamlogd\r\n```\r\n\r\nSince we use 10 minutes greylisting `-G 10:` reload the blacklist every 5 minutes.\r\n\r\nCreate `spamd-setup` cronjob: `echo \"5 * * * * /usr/libexec/spamd-setup -b \u003e/dev/null 2\u003e\u00261\" \u003e\u003e /var/cron/tabs/root`\r\n\r\n### pf Rules\r\n\r\n```\r\ncat \u003c\u003cEOF\u003e\u003e /etc/pf.conf\r\nset limit table-entries 400000\r\n\r\ntable \u003cspamd\u003e persist\r\ntable \u003cnospamd\u003e persist file \"/etc/mail/nospamd\"\r\ntable \u003cspamd-white\u003e persist\r\n\r\npass in quick log on egress proto tcp \\\r\n from { \u003cnospamd\u003e, \u003cspamd-white\u003e } \\\r\n to any port smtp \\\r\n flags S/SA modulate state\r\n\r\npass in quick proto tcp from \u003cspamd\u003e \\\r\n to egress port { smtp, submission, smtps } \\\r\n rdr-to 127.0.0.1 port spamd\r\n\r\npass in quick proto tcp from any \\\r\n to egress port smtp \\\r\n rdr-to 127.0.0.1 port spamd\r\n\r\npass out log on egress proto tcp to any port smtp\r\nEOF\r\npfctl -f /etc/pf.conf\r\n```\r\n\r\nStart spamd and spamlogd:\r\n\r\n```\r\nrcctl start spamd\r\nrcctl start spamlogd\r\n```\r\n\r\n## ClamAV\r\n\r\nInstall: `pkg_add clamav clamsmtp`\r\n\r\nBoot startup:\r\n\r\n```\r\nrcctl enable clamd\r\nrcctl enable clamsmtpd_in\r\nrcctl enable clamsmtpd_out\r\n```\r\n\r\n### Config\r\n\r\n#### clamd\r\n\r\n```\r\n/etc/clamd.conf\r\n\r\nComment out the 'Example' string:\r\n\r\n#Example\r\n\r\nUncomment these options:\r\n\r\nTCPSocket 3310\r\nTCPAddr 127.0.0.1\r\n```\r\n\r\n#### freshclam\r\n\r\n```\r\n/etc/freshclam.conf\r\n\r\nComment out the 'Example' string:\r\n\r\n#Example\r\n\r\nChange\r\n#DatabaseMirror db.XY.clamav.net\r\nto\r\nDatabaseMirror db.de.clamav.net\r\n```\r\n\r\nCreate the update cronjob:\r\n\r\n`echo \"20 * * * * /usr/local/bin/freshclam \u003e/dev/null 2\u003e\u00261\" \u003e\u003e /var/cron/tabs/root`\r\n\r\n#### clamsmtpd\r\n\r\nCreate two clamsmtpd configs:\r\n\r\n```\r\ncp /etc/clamsmtpd.conf /etc/clamsmtpd-in.conf\r\ncp /etc/clamsmtpd.conf /etc/clamsmtpd-out.conf\r\n```\r\n\r\n```\r\n/etc/clamsmtpd-in.conf\r\nChange to match these values:\r\nOutAddress: 10026\r\nListen: 127.0.0.1:10025\r\nUser: _clamav\r\n\r\nAdd this option:\r\nClamAddress: 127.0.0.1:3310\r\n```\r\n\r\n```\r\n/etc/clamsmtpd-out.conf\r\nChange to match these values:\r\nOutAddress: 10028\r\nListen: 127.0.0.1:10027\r\nUser: _clamav\r\n\r\nAdd this option:\r\nClamAddress: 127.0.0.1:3310\r\n```\r\n\r\nCreate the two init scripts:\r\n\r\n```\r\ncat \u003c\u003cEOF\u003e /etc/rc.d/clamsmtpd_in\r\n#!/bin/sh\r\n\r\ndaemon=\"/usr/local/sbin/clamsmtpd -f /etc/clamsmtpd-in.conf\"\r\n\r\n. /etc/rc.d/rc.subr\r\n\r\nrc_cmd $1\r\nEOF\r\n```\r\n\r\n```\r\ncat \u003c\u003cEOF\u003e /etc/rc.d/clamsmtpd_out\r\n#!/bin/sh\r\n\r\ndaemon=\"/usr/local/sbin/clamsmtpd -f /etc/clamsmtpd-out.conf\"\r\n\r\n. /etc/rc.d/rc.subr\r\n\r\nrc_cmd $1\r\nEOF\r\n```\r\n\r\n`chmod 0755 /etc/rc.d/clamsmtpd_{in,out}`\r\n\r\nRun the initial freshclam: `/usr/local/bin/freshclam`\r\n\r\nStart the daemons:\r\n\r\n```\r\nrcctl start clamd\r\nrcctl start clamsmtpd_in\r\nrcctl start clamsmtpd_out\r\n```\r\n\r\n## MariaDB\r\n\r\nInstall: `pkg_add mariadb-server`\r\n\r\nBoot startup: `rcctl enable mysqld`\r\n\r\n### Init mysql\r\n\r\n```\r\nmysql_install_db\r\nrcctl start mysqld\r\nmysql_secure_installation\r\n```\r\n\r\n### Config\r\n\r\n```\r\n/etc/my.cnf\r\n\r\nComment out these options:\r\n[client]\r\nport = 3306\r\n\r\n[mysqld]\r\nport = 3306\r\nbind-address = 127.0.0.1\r\n```\r\n\r\nRestart mysqld: `rcctl restart mysqld`\r\n\r\n### Database\r\n\r\n```\r\nmysql -u root -p\r\n\r\nCREATE DATABASE zarafa;\r\nGRANT ALL ON zarafa.* TO 'zarafa'@'localhost' IDENTIFIED BY '\u003cpassword\u003e';\r\nFLUSH PRIVILEGES;\r\n```\r\n\r\n## Zarafa\r\n\r\nInstall: `pkg_add zarafa`\r\n\r\nBoot startup: `rcctl enable zarafa`\r\n\r\n### Config\r\n\r\n```\r\n/etc/rc.d/zarafa\r\n\r\nChange\r\n_pkg_scripts=\"zarafa_server zarafa_spooler zarafa_monitor zarafa_gateway zarafa_ical\"\r\nto\r\n_pkg_scripts=\"zarafa_server zarafa_spooler zarafa_monitor\"\r\n```\r\n\r\nAdd this after the `rc_pre()` function in `/etc/rc.d/zarafa_server`:\r\n\r\n```\r\nsock=\"/var/run/zarafad/server.sock\"\r\nnew=\"/var/www/run/server.sock\"\r\n\r\nrc_start() {\r\n ${rcexec} \"${daemon} ${daemon_flags} ${_bg}\"\r\n exc=$?\r\n sleep 2\r\n ln $sock $new\r\n chgrp www $new\r\n return $exc\r\n}\r\n\r\nrc_post() {\r\n rm -f $new\r\n}\r\n```\r\n\r\n```\r\n/etc/zarafa/server.cfg\r\n\r\nChange\r\nserver_tcp_enabled = yes\r\nto\r\nserver_tcp_enabled = no\r\n\r\nSet mysql credentials\r\nmysql_user = zarafa\r\nmysql_password = \u003cpassword\u003e\r\nmysql_socket = /var/run/mysql/mysql.sock\r\n```\r\n\r\nAdd _zarafa user to the user mapping:\r\n\r\n```\r\nawk -F':' '/_zarafa/{print $1\" \"$3\":\"$4\":\"$6}' /etc/passwd \u003e\u003e /etc/mail/umap\r\n```\r\n\r\nRestart smtpd: `rcctl restart smtpd`\r\n\r\nStart zarafa: `rcctl start zarafa`\r\n\r\nCreate a user: `zarafa-admin -c \"john@example.com\" -f \"John Doe\" -e \"john@example.com\" -P`\r\n\r\n## Zarafa Webapp\r\n\r\nInstall: `pkg_add zarafa-webapp`\r\n\r\nBoot startup: `rcctl enable php56_fpm`\r\n\r\n### Config\r\n\r\n`echo \"extension=mapi.so\" \u003e /etc/php-5.6/zarafa.ini`\r\n\r\n```\r\n/etc/php-5.6.ini\r\n\r\nChange\r\nexpose_php = On\r\nto\r\nexpose_php = off\r\n```\r\n\r\n```\r\n/var/www/zarafa-webapp/config.php\r\n\r\nComment out this if/else block and insert the single DEFAULT_SERVER define.\r\nIt should look like this:\r\n\r\n /*if(version_compare(phpversion('mapi'), '7.2.2', '\u003e=')) {\r\n define(\"DEFAULT_SERVER\", \"default:\");\r\n } else {\r\n #define(\"DEFAULT_SERVER\",\"http://localhost:236/zarafa\");\r\n define(\"DEFAULT_SERVER\",\"file:///var/run/zarafa\");\r\n }*/\r\n define(\"DEFAULT_SERVER\", \"file:///run/server.sock\");\r\n```\r\n\r\nStart php-fpm: `rcctl start php56_fpm`\r\n\r\n## Z-Push\r\n\r\nInstall: `pkg_add z-push`\r\n\r\n### Config\r\n\r\nCreate directories:\r\n\r\n```\r\nmkdir -p /var/www/var/{db/z-push,log/z-push}\r\nchown www /var/www/var/{db/z-push,log/z-push}\r\n```\r\n\r\n```\r\n/var/www/z-push/config.php\r\n\r\nChange if needed\r\ndefine('USE_FULLEMAIL_FOR_LOGIN', false);\r\nto\r\ndefine('USE_FULLEMAIL_FOR_LOGIN', true);\r\n```\r\n\r\n```\r\n/var/www/z-push/backend/zarafa/config.php\r\n\r\nChange\r\ndefine('MAPI_SERVER', 'http://127.0.0.1:236/zarafa');\r\nto\r\ndefine('MAPI_SERVER', 'file:///run/server.sock');\r\n```\r\n\r\n## httpd\r\n\r\nBasic config at [OpenBSD httpd](/wiki/OpenBSD+httpd)\r\n\r\nZarafa config:\r\n\r\n```\r\n root \"/zarafa-webapp\"\r\n directory index \"index.php\"\r\n location \"*.php\" {\r\n fastcgi socket \"/run/php-fpm.sock\"\r\n }\r\n location \"/Microsoft-Server-ActiveSync\" {\r\n root \"/z-push/index.php\"\r\n fastcgi socket \"/run/php-fpm.sock\"\r\n }\r\n```\r\n\r\n# Optionals\r\n\r\n## OpenBGPD\r\n\r\nProject: [bgp-spamd](http://bgp-spamd.net/)\r\n\r\nBoot startup: `rcctl enable bgpd`\r\n\r\n### Config\r\n\r\nOpenBGPD config:\r\n\r\n```\r\ncat \u003c\u003cEOF\u003e /etc/bgpd.conf\r\nspamdAS=\"65066\"\r\n\r\nAS 65001\r\n# Mandatory, to not update the local routing table\r\nfib-update no\r\n\r\ngroup \"spamd-bgp\" {\r\n remote-as $spamdAS\r\n multihop 64\r\n\r\n # Do not send Route Server any information\r\n announce none\r\n\r\n # us.bgp-spamd.net\r\n neighbor 64.142.121.62\r\n\r\n # eu.bgp-spamd.net\r\n neighbor 217.31.80.170\r\n\r\n # IPv6 eu.bgp-spamd.net\r\n #neighbor 2a00:15a8:0:100:0:d91f:50aa:1\r\n}\r\n \r\n# 'match' is required, to remove entries when routes are withdrawn\r\nmatch from group \"spamd-bgp\" community $spamdAS:42 set pftable \"bgp_spamd_bypass\"\r\nmatch from group \"spamd-bgp\" community $spamdAS:666 set pftable \"bgp_spamd\"\r\nEOF\r\nchmod 0600 /etc/bgpd.conf\r\n```\r\n\r\nStart OpenBGPD: `rctl start pbgd`\r\n\r\nAdd these options to your `/etc/pf.conf`:\r\n\r\n```\r\nset limit table-entries 400000\r\n\r\ntable \u003cbgp_spamd_bypass\u003e persist\r\ntable \u003cbgp_spamd\u003e persist\r\n\r\nbgp_addrs = \"{ 64.142.121.62, 217.31.80.170, 2a00:15a8:0:100:0:d91f:50aa:1 }\"\r\npass in on egress proto tcp from $bgp_addrs to any port bgp\r\n```\r\n\r\nChange your smtp rules to match these:\r\n\r\n```\r\npass in quick log on egress proto tcp \\\r\n from { \u003cbgp_spamd_bypass\u003e, \u003cnospamd\u003e, \u003cspamd-white\u003e } \\\r\n to any port smtp \\\r\n flags S/SA modulate state\r\n\r\npass in quick proto tcp from { \u003cbgp_spamd\u003e, \u003cspamd\u003e } \\\r\n to egress port { smtp, submission, smtps } \\\r\n rdr-to 127.0.0.1 port spamd\r\n```\r\n\r\nReload pf: `pfctl -f /etc/pf.conf`\r\n\r\nEdit the `/etc/mail/spamd.conf`:\r\n\r\n```\r\n# Add bgp-spamd to the all list:\r\nall:\\\r\n :nixspam:custom_black:bgp-spamd:\r\n\r\n# Add the bgp-spamd list:\r\nbgp-spamd:\\\r\n :black:\\\r\n :msg=\"Your address %A has sent mail to a spamtrap\\n\\\r\n within the last 24 hours\":\\\r\n :method=file:\\\r\n :file=/etc/mail/spamd.black:\r\n```\r\n\r\nCreate the spamd-reload script:\r\n\r\n```\r\ncat \u003c\u003cEOF\u003e /usr/local/sbin/bgp-spamd.black.sh\r\n#!/bin/sh\r\n\r\nblack=\"/etc/mail/spamd.black\"\r\n\r\nbgpctl show rib community 65066:666 | tail -n +5 |\r\n awk '{sub(/\\/.*/, \"\", $1);print $1}' |\r\n uniq \u003e ${black}\r\n#sed -e '1,4d' -e 's/\\/.*$//' -e 's/[ \\*\\\u003e]*//' \u003e ${black}\r\n\r\n/usr/libexec/spamd-setup -b\r\nEOF\r\nchmod 0744 /usr/local/sbin/bgp-spamd.black.sh\r\n```\r\n\r\nReplace the `spamd-setup` cronjob from above with the new script and run it once manually: `/usr/local/sbin/bgp-spamd.black.sh`\r\n\r\n## SpamAssassin\r\n\r\nInstall: `pkg_add p5-Mail-SpamAssassin spampd`\r\n\r\nBoot startup:\r\n\r\n```\r\nrcctl enable spampd\r\nrcctl enable spamassassin\r\nrcctl set spampd flags \"--port=10029 --relayhost=127.0.0.1:10030 --tagall --aw\"\r\n```\r\n\r\n### Config\r\n\r\n```\r\n/etc/mail/spamassassin/local.cf\r\n\r\nUncomment this option\r\nrewrite_header\r\n```\r\n\r\nChange your `smtpd.conf` to match this:\r\n\r\n```\r\n/etc/mail/smtpd.conf\r\n\r\nlisten on lo0 port 10030 tag SPAM_IN\r\n\r\naccept tagged SPAM_IN for domain \u003cvdomains\u003e virtual \u003cvusers\u003e userbase \u003cumap\u003e \\\r\n deliver to mda \"/usr/local/sbin/zarafa-dagent %{dest}\" as \"_zarafa\"\r\naccept tagged CLAM_IN for any relay via smtp://127.0.0.1:10029\r\naccept tagged CLAM_OUT for any relay\r\n```\r\n\r\nStart the daemons:\r\n\r\n```\r\nrcctl start spampd\r\nrcctl start spamassassin\r\n```\r\n\r\nRestart OpenSMTPD: `rcctl restart smtpd`\r\n\r\n## Backup\r\n\r\n```\r\n#!/bin/sh\r\n\r\ndb=\"zarafa\"\r\ndb_user=\"zarafa\"\r\ndb_pw=\"\u003cpassword\u003e\"\r\n\r\nset -A paths \\\r\n/var/db/zarafa \\\r\n/etc/zarafa \\\r\n/etc/mail \\\r\n/etc/pf.conf \\\r\n/etc/httpd.conf \\\r\n/etc/clamsmtpd-in.conf \\\r\n/etc/clamsmtpd-out.conf \\\r\n/etc/clamd.conf \\\r\n/etc/freshclam.conf \\\r\n/etc/ssl/acme \\\r\n/etc/acme \\\r\n/etc/bgpd.conf \\\r\n/etc/rc.conf.local \\\r\n/etc/rc.d/clamsmtpd_in \\\r\n/etc/rc.d/clamsmtpd_out \\\r\n/etc/my.cnf \\\r\n/etc/rc.d/zarafa \\\r\n/etc/rc.d/zarafa_server \\\r\n/etc/php-5.6/zarafa.ini \\\r\n/etc/php-5.6.ini \\\r\n/var/www/zarafa-webapp/config.php \\\r\n/var/www/z-push/config.php \\\r\n/var/www/z-push/backend/zarafa/config.php \\\r\n/usr/local/sbin/bgp-spamd.black.sh \\\r\n/usr/local/sbin/cron-acme-update \\\r\n/etc/mail/spamassassin/local.cf \\\r\n/var/cron/tabs/root \\\r\n/tmp/${db}.sql\r\n\r\nmysqldump -u ${db_user} -p${db_pw} ${db} \u003e/tmp/${db}.sql\r\n\r\ntar cfvz /root/mailserver_backup_$(date +%Y%m%d_%H%M%S).tgz ${paths[@]}\r\n\r\nrm /tmp/${db}.sql\r\n```\r\n\r\n# END","created":"admin 2017-04-22 13:55:37","updated":"","public":true},{"title":"sdcsdcs","link_title":"sdcsdcs","md":"# Bla\r\n\r\n## HTTP\r\n\r\n### Config\r\n\r\nBla\r\n\r\n## HTTPS\r\n\r\n### Config\r\n\r\nBlubb","created":"admin 2017-04-22 13:45:27","updated":"admin 2017-04-22 13:54:42","public":true}],"snippets":null,"users":[{"Username":"admin","Password":"$2a$13$M5UIpGUG4WmEItvd7c6Vi.x1aOQ4U3wz1SOScrXT0C3dUy8eNiu7m","Admin":true,"Locked":0}]} diff --git a/editor.go b/editor.go new file mode 100644 index 0000000..24ba7da --- /dev/null +++ b/editor.go @@ -0,0 +1,43 @@ +package main + +import "sync" + +type Editor struct { + Username string + Time string +} + +type EditorStore struct { + sync.RWMutex + m map[string]*Editor +} + +func NewEditorStore() *EditorStore { + return &EditorStore{m: make(map[string]*Editor)} +} + +func (e *EditorStore) Set(page, username string) *Editor { + e.Lock() + defer e.Unlock() + editor := e.m[page] + if editor != nil { + return editor + } + e.m[page] = &Editor{username, now()} + return nil +} + +func (e *EditorStore) RemoveEditor(page, username string) { + e.Lock() + defer e.Unlock() + if editor, ok := e.m[page]; ok { + if editor.Username == username { + delete(e.m, page) + } + } +} +func (e *EditorStore) Remove(page string) { + e.Lock() + defer e.Unlock() + delete(e.m, page) +} diff --git a/engine.go b/engine.go deleted file mode 100644 index 0e50af9..0000000 --- a/engine.go +++ /dev/null @@ -1,270 +0,0 @@ -// Copyright (C) 2017 Marius Schellenberger - -package main - -import ( - "errors" - "html/template" - "regexp" - "time" - - "github.com/blevesearch/bleve" - "github.com/blevesearch/bleve/document" -) - -const ( - timeFmt = "2006-01-02 15:04:05" - maxAllResult = 1000000 - maxSearchResult = 101 -) - -var titleRe = regexp.MustCompile("[^a-zA-Z0-9+ ]+") - -func now() string { - return time.Now().Format(timeFmt) -} - -type Article struct { - Title string `json:"title"` - LinkTitle string - Search string `json:"search"` - Index template.HTML `json:"index"` - Text template.HTML `json:"text"` - MD string `json:"md"` - Created string `json:"created"` - Updated string `json:"updated"` - Public bool `json:"public"` -} - -type Engine struct { - index bleve.Index -} - -func NewEngine(index bleve.Index) *Engine { - return &Engine{index} -} - -func (e *Engine) Close() error { - return e.index.Close() -} - -func (e *Engine) Restore(dumpArt DumpArticle) error { - art := render(dumpArt.MD) - art.Title = dumpArt.Title - art.Created = dumpArt.Created - art.Updated = dumpArt.Updated - art.Public = dumpArt.Public - return e.index.Index(dumpArt.LinkTitle, art) -} - -func (e *Engine) Index(title, text, user string, public bool) (string, error) { - if user == "" { - return "", errors.New("engine: user cannot be empty") - } - title = titleRe.ReplaceAllString(title, "") - newTitle := makeLinkTitle(title) - if err := e.Exists(false, newTitle); err == nil { - return "", errors.New("engine: article '" + title + "' already exists") - } - art := render(text) - art.Title = title - art.Created = user + " " + now() - art.Public = public - err := e.index.Index(newTitle, art) - return newTitle, err -} - -func (e *Engine) Exists(public bool, title string) error { - _, err := e.Get(public, title) - if err != nil { - return err - } - return nil -} - -func (e *Engine) Delete(title string) error { - if title == indexName { - return errors.New("engine: page '" + indexName + "' cannot be deleted") - } - if err := e.Exists(false, title); err != nil { - return err - } - return e.index.Delete(title) -} - -func (e *Engine) Update(title, text, user string, public bool) error { - if user == "" { - return errors.New("engine: user cannot be empty") - } - if title == indexName && !public { - return errors.New("engine: page '" + indexName + "' must stay public") - } - if err := e.Exists(false, title); err != nil { - return err - } - art, err := e.Get(false, title) - if err != nil { - return err - } - rend := render(text) - art.Updated = user + " " + now() - art.Search = rend.Search - art.Index = rend.Index - art.Text = rend.Text - art.MD = rend.MD - art.Public = public - return e.index.Index(title, art) -} - -func (e *Engine) Get(public bool, title string) (Article, error) { - var art Article - doc, err := e.index.Document(title) - if err != nil { - return art, err - } - if doc == nil || doc.Fields == nil { - return art, errors.New("engine: article '" + title + "' does not esist") - } - for _, field := range doc.Fields { - switch f := field.(type) { - case *document.TextField: - switch field.Name() { - case "title": - art.Title = string(f.Value()) - case "index": - art.Index = template.HTML(string(f.Value())) - case "text": - art.Text = template.HTML(string(f.Value())) - case "md": - art.MD = string(f.Value()) - case "created": - art.Created = string(f.Value()) - case "updated": - art.Updated = string(f.Value()) - } - case *document.BooleanField: - if field.Name() == "public" { - p, err := f.Boolean() - if err != nil { - return art, err - } - art.Public = p - } - } - } - art.LinkTitle = title - if public && !art.Public { - err = errors.New("engine: article '" + title + "' does not esist") - } - return art, err -} - -func (e *Engine) Search(public bool, search string) (string, error) { - var ( - res *bleve.SearchResult - req *bleve.SearchRequest - err error - ) - if public { - req = bleve.NewSearchRequest(bleve.NewConjunctionQuery([]bleve.Query{ - bleve.NewBoolFieldQuery(true).SetField("public"), - bleve.NewQueryStringQuery("title:" + search + " search:" + search), - })) - } else { - req = bleve.NewSearchRequest(bleve.NewQueryStringQuery("title:" + search + " search:" + search)) - } - req.Highlight = bleve.NewHighlightWithStyle("html") - req.Size = maxSearchResult - res, err = e.index.Search(req) - if err != nil { - return "", err - } - if res.Total == 0 { - return "No search results", 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 += `` + art.Title + "" - for fragField, frags := range hit.Fragments { - if fragField == "title" || fragField == "search" { - html += `
`
-				for _, f := range frags {
-					html += f
-				}
-				html += "
" - break - } - } - html += "
" - } - 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 -} diff --git a/handler.go b/handler.go index 5165955..d7e6f6a 100644 --- a/handler.go +++ b/handler.go @@ -4,39 +4,16 @@ 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 { + s *HTTPServer } -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 (nf *notFoundHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + newContext(w, r, nf.s).NotFound() } func adminAuthHandler(h ctxHandler) ctxHandler { @@ -45,7 +22,7 @@ func adminAuthHandler(h ctxHandler) ctxHandler { h(ctx) return } - ctx.Redirect("/404", 302) + ctx.NotFound() } } @@ -59,7 +36,17 @@ func userAuthHandler(h ctxHandler) ctxHandler { h(ctx) return } - ctx.Redirect("/404", 302) + ctx.NotFound() + } +} + +func authHandler(h ctxHandler) ctxHandler { + return func(ctx *Context) { + if ctx.LoggedOn() { + h(ctx) + return + } + ctx.NotFound() } } @@ -69,9 +56,9 @@ func staticHandler(ctx *Context) { ctx.SetHeader("Expires", time.Now().Add(time.Hour*12).Format(http.TimeFormat)) switch ctx.Path() { case "/bootstrap.css": - err = ctx.Write(res["bootstrap.css"]) + err = ctx.Write(ctx.Srv.res["bootstrap.css"]) case "/custom.css": - err = ctx.Write(res["custom.css"]) + err = ctx.Write(ctx.Srv.res["custom.css"]) } if err != nil { log.Println("static:", err) @@ -79,7 +66,6 @@ func staticHandler(ctx *Context) { } func indexHandler(ctx *Context) { - ctx.InitToken() if !ctx.LoggedOn() { ctx.UnsetUdata() } @@ -87,7 +73,6 @@ func indexHandler(ctx *Context) { } func loginHandler(ctx *Context) { - ctx.InitToken() if ctx.LoggedOn() { ctx.Redirect("/wiki/Index", 302) return @@ -99,38 +84,31 @@ func loginHandler(ctx *Context) { } 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() + if !ctx.CheckToken(nil) { return } if user == "" || password == "" || submit != "Login" { - ctx.NewToken() ctx.Data.Msg = "wrong inputs" ctx.Exec() return } - err := db.Login(user, password) + err := ctx.Srv.BS.Login(user, password) if err != nil { - ctx.NewToken() ctx.Data.Msg = err.Error() ctx.Exec() return } - ctx.SetUdata(user, db.UserIsAdmin(user)) + ctx.SetUdata(user, ctx.Srv.BS.UserIsAdmin(user)) ctx.Redirect("/wiki/Index", 302) } } func searchHandler(ctx *Context) { - ctx.InitToken() ctx.Template("searchHandler") ctx.Data = webData{ Title: "Search", @@ -138,34 +116,27 @@ func searchHandler(ctx *Context) { } 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() + if !ctx.CheckToken(nil) { return } - if search == "" || submit != "Search" { - ctx.NewToken() + if submit != "Search" { ctx.Data.Msg = "wrong inputs" ctx.Exec() return } - res, err := engine.Search(!ctx.LoggedOn(), search) + res, err := ctx.Srv.Index.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() } } @@ -177,7 +148,7 @@ func allHandler(ctx *Context) { BodyTitle: "All Articles", Admin: ctx.GetAdmin(), } - all, err := engine.GetAll(!ctx.LoggedOn()) + all, err := ctx.Srv.Index.GetAll(!ctx.LoggedOn()) if err != nil { ctx.Data.Msg = err.Error() } @@ -187,14 +158,9 @@ func allHandler(ctx *Context) { func wikiNewHandler(ctx *Context) { ctx.Template("wikiNewHandler") - ctx.Data = webData{ - Title: "New Article", - BodyTitle: "New Article", - Admin: ctx.GetAdmin(), - } + ctx.Data = webData{Title: "New Article"} switch ctx.Method() { case "GET": - ctx.NewToken() ctx.Exec() case "POST": title := ctx.Form("title") @@ -210,28 +176,7 @@ func wikiNewHandler(ctx *Context) { 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() + if !ctx.CheckToken(nil) { return } if submit != "Submit" { @@ -239,7 +184,7 @@ func wikiNewHandler(ctx *Context) { ctx.Exec() return } - newTitle, err := engine.Index(title, text, ctx.GetUser(), pub) + newTitle, err := ctx.Srv.Index.Index(title, text, ctx.GetUser(), pub) if err != nil { ctx.Data.Msg = err.Error() ctx.Exec() @@ -256,11 +201,10 @@ func wikiHandler(ctx *Context) { ctx.Data = webData{ Admin: ctx.GetAdmin(), } - ctx.NewToken() title := ctx.Var("title") - art, err := engine.Get(!ctx.LoggedOn(), title) + art, err := ctx.Srv.Index.Get(!ctx.LoggedOn(), title) if err != nil { - ctx.Redirect("/404", 302) + ctx.NotFound() return } art.LinkTitle = title @@ -283,16 +227,16 @@ func wikiEditHandler(ctx *Context) { title := ctx.Var("title") switch ctx.Method() { case "GET": - ctx.NewToken() - art, err := engine.Get(!ctx.LoggedOn(), title) + art, err := ctx.Srv.Index.Get(!ctx.LoggedOn(), title) if err != nil { - ctx.Redirect("/404", 302) + ctx.NotFound() return } art.LinkTitle = title ctx.Data.Data = art ctx.Data.Title = art.Title ctx.Data.BodyTitle = art.Title + ctx.Data.Editor = ctx.Srv.Editors.Set(title, ctx.GetUser()) ctx.Exec() case "POST": ftitle := ctx.Form("title") @@ -311,24 +255,26 @@ func wikiEditHandler(ctx *Context) { } ctx.Data.Title = ftitle ctx.Data.BodyTitle = ftitle - if !ctx.CheckToken() { - ctx.NewToken() - ctx.Data.Msg = "wrong csrf token" - ctx.Exec() + if !ctx.CheckToken(nil) { + return + } + if submit == "Back" { + ctx.Srv.Editors.RemoveEditor(title, ctx.GetUser()) + ctx.Redirect("/wiki/"+title, 302) return } - ctx.NewToken() if submit != "Submit" { ctx.Data.Msg = "wrong inputs" ctx.Exec() return } - err := engine.Update(title, text, ctx.GetUser(), pub) + err := ctx.Srv.Index.Update(title, text, ctx.GetUser(), pub) if err != nil { ctx.Data.Msg = err.Error() ctx.Exec() return } + ctx.Srv.Editors.Remove(title) ctx.Redirect("/wiki/"+title, 302) } } @@ -336,24 +282,26 @@ func wikiEditHandler(ctx *Context) { func wikiDelHandler(ctx *Context) { title := ctx.Var("title") submit := ctx.Form("submit") - if !ctx.CheckToken() { + f := func() { ctx.Redirect("/wiki/"+title, 302) + } + if !ctx.CheckToken(f) { return } if submit != "Delete" { - ctx.Redirect("/wiki/"+title, 302) + f() return } - if err := engine.Delete(title); err != nil { + if err := ctx.Srv.Index.Delete(title); err != nil { log.Println("delete:", err) - ctx.Redirect("/wiki/"+title, 302) + f() } ctx.Redirect("/wiki/Index", 302) } func usersHandler(ctx *Context) { if !ctx.LoggedOn() { - ctx.Redirect("/404", 302) + ctx.NotFound() return } ctx.Template("usersHandler") @@ -362,8 +310,7 @@ func usersHandler(ctx *Context) { BodyTitle: "Users", User: ctx.GetUser(), } - ctx.NewToken() - us, err := db.GetUsers() + us, err := ctx.Srv.BS.GetUsers() if err != nil { ctx.Data.Msg = err.Error() } @@ -379,7 +326,6 @@ func userNewHandler(ctx *Context) { } switch ctx.Method() { case "GET": - ctx.NewToken() ctx.Exec() case "POST": user := ctx.Form("user") @@ -391,21 +337,15 @@ func userNewHandler(ctx *Context) { adm = true } submit := ctx.Form("submit") - if !ctx.CheckToken() { - ctx.NewToken() - ctx.Data.Msg = "wrong csrf token" - ctx.Exec() + if !ctx.CheckToken(nil) { 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() + if err := ctx.Srv.BS.CreateUser(user, password, adm); err != nil { ctx.Data.Msg = err.Error() ctx.Exec() return @@ -422,14 +362,13 @@ func userEditHandler(ctx *Context) { } switch ctx.Method() { case "GET": - u, err := db.GetUserWithoutPassword(ctx.Var("user")) + u, err := ctx.Srv.BS.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") @@ -441,29 +380,23 @@ func userEditHandler(ctx *Context) { adm = true } submit := ctx.Form("submit") - if !ctx.CheckToken() { - ctx.NewToken() - ctx.Data.Msg = "wrong csrf token" - ctx.Exec() + if !ctx.CheckToken(nil) { 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() + if err := ctx.Srv.BS.AdminUpdateUser(user, password, adm); err != nil { ctx.Data.Msg = err.Error() ctx.Exec() return } } else { if user == ctx.GetUser() { - if err := db.UpdateUserPassword(user, password); err != nil { - ctx.NewToken() + if err := ctx.Srv.BS.UpdateUserPassword(user, password); err != nil { ctx.Data.Msg = err.Error() ctx.Exec() return @@ -478,7 +411,7 @@ func userEditHandler(ctx *Context) { func userUnlockHandler(ctx *Context) { user := ctx.Var("user") - if err := db.UnlockUser(user); err != nil { + if err := ctx.Srv.BS.UnlockUser(user); err != nil { log.Println("unlock:", err) } ctx.Redirect("/users", 302) @@ -492,17 +425,16 @@ func userDelHandler(ctx *Context) { } switch ctx.Method() { case "GET": - user, err := db.GetUserWithoutPassword(ctx.Var("user")) + user, err := ctx.Srv.BS.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 { + if err := ctx.Srv.BS.DeleteUser(ctx.Var("user")); err != nil { log.Println("delete:", err) } ctx.Redirect("/users", 302) diff --git a/helper.go b/helper.go new file mode 100644 index 0000000..c197ce8 --- /dev/null +++ b/helper.go @@ -0,0 +1,36 @@ +package main + +import ( + "fmt" + "strconv" + "time" +) + +const timeFmt = "2006-01-02 15:04:05" + +func now() string { + return time.Now().Format(timeFmt) +} + +func since(t time.Time) string { + s := time.Since(t) + u := uint64(s) + if u <= 0 { + return "0s" + } + str := s.String() + switch { + case u >= uint64(time.Second): + f, _ := strconv.ParseFloat(str[:len(str)-1], 64) + return fmt.Sprintf("%.fs", f) + case u < uint64(time.Microsecond): + f, _ := strconv.ParseFloat(str[:len(str)-2], 64) + return fmt.Sprintf("%.fns", f) + case u < uint64(time.Millisecond): + f, _ := strconv.ParseFloat(str[:len(str)-3], 64) + return fmt.Sprintf("%.fµs", f) + default: + f, _ := strconv.ParseFloat(str[:len(str)-2], 64) + return fmt.Sprintf("%.fms", f) + } +} diff --git a/index.go b/index.go new file mode 100644 index 0000000..5eda052 --- /dev/null +++ b/index.go @@ -0,0 +1,328 @@ +// Copyright (C) 2017 Marius Schellenberger + +package main + +import ( + "fmt" + "html/template" + "os" + + "github.com/blevesearch/bleve" + "github.com/blevesearch/bleve/document" +) + +const ( + maxResult = 1e6 + maxSearchResult = 101 + noResults = "No search results" + indexName = "Index" + welcome = `# Welcome to GoWiki +This is the [Index](/wiki/Index) page. +You can customize it how you like.` +) + +type Article struct { + Title string `json:"title"` + LinkTitle string `json:"-"` + 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"` +} + +func notExists(title string) error { + return indexErr("article '" + title + "' does not esist") +} + +func indexErr(i interface{}) error { + return fmt.Errorf("index: %s", i) +} + +type Index struct { + index bleve.Index + cache *IndexCache +} + +func NewIndex(path string) (i *Index, err error) { + var bi bleve.Index + if _, err = os.Stat(path); os.IsNotExist(err) { + mapping := bleve.NewIndexMapping() + bi, err = bleve.New(path, mapping) + if err != nil { + err = indexErr(err) + return + } + i = &Index{index: bi} + _, err = i.Index(indexName, welcome, defUser, true) + if err != nil { + err = indexErr(err) + return + } + } else { + bi, err = bleve.Open(path) + if err != nil { + err = indexErr(err) + return + } + i = &Index{index: bi} + } + i.cache = NewIndexCache() + return +} + +func NewDumpIndex(path string) (i *Index, err error) { + bi, err := bleve.OpenUsing(path, map[string]interface{}{"read_only": true}) + if err != nil { + err = indexErr(err) + return + } + i = &Index{index: bi} + return +} + +func (i *Index) Close() error { + return i.index.Close() +} + +func (i *Index) Restore(dumpArt *DumpArticle) error { + art := render(dumpArt.MD).Article() + art.Title = dumpArt.Title + art.Created = dumpArt.Created + art.Updated = dumpArt.Updated + art.Public = dumpArt.Public + return i.index.Index(dumpArt.LinkTitle, art) +} + +func (i *Index) Index(title, text, user string, public bool) (string, error) { + if user == "" { + return "", indexErr("user cannot be empty") + } + newTitle, title := makeLinkTitle(title) + if i.exists(false, newTitle) { + return "", indexErr("article '" + title + "' already exists") + } + art := render(text).Article() + art.Title = title + art.Created = user + " " + now() + art.Public = public + err := i.index.Index(newTitle, art) + i.cache.Add(newTitle, art) + return newTitle, err +} + +func (i *Index) exists(public bool, title string) bool { + _, err := i.Get(public, title) + return err == nil +} + +func (i *Index) Delete(title string) error { + if title == indexName { + return indexErr("page '" + indexName + "' cannot be deleted") + } + if !i.exists(false, title) { + return notExists(title) + } + err := i.index.Delete(title) + if err != nil { + return err + } + i.cache.Delete(title) + return err +} + +func (i *Index) Update(title, text, user string, public bool) error { + if user == "" { + return indexErr("user cannot be empty") + } + if title == indexName && !public { + return indexErr("page '" + indexName + "' must stay public") + } + if !i.exists(false, title) { + return notExists(title) + } + art, err := i.Get(false, title) + if err != nil { + return err + } + rend := render(text).Article() + art.Updated = user + " " + now() + art.Search = rend.Search + art.Index = rend.Index + art.Text = rend.Text + art.MD = rend.MD + art.Public = public + err = i.index.Index(title, art) + if err != nil { + return err + } + i.cache.Add(title, &art) + return err +} + +func (i *Index) Get(public bool, title string) (art Article, err error) { + a := i.cache.Get(title) + switch { + case a != nil: + art = *a + default: + var doc *document.Document + doc, err = i.index.Document(title) + if err != nil { + return + } + if doc == nil || doc.Fields == nil { + err = notExists(title) + return + } + 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, e := f.Boolean() + if e != nil { + err = e + return + } + art.Public = p + } + } + } + } + art.LinkTitle = title + if public && !art.Public { + err = notExists(title) + } + return +} + +func (i *Index) Search(public bool, search string) (html string, err error) { + if search == "" { + html = noResults + return + } + var ( + res *bleve.SearchResult + req *bleve.SearchRequest + ) + 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 = i.index.Search(req) + if err != nil { + return + } + if res.Total == 0 { + html = noResults + return + } else if res.Total == maxSearchResult { + err = indexErr("too many search results") + return + } + for _, hit := range res.Hits { + if hit == nil { + continue + } + art, err := i.Get(false, hit.ID) + if err != nil { + continue + } + html += `` + art.Title + "" + for fragField, frags := range hit.Fragments { + if fragField == "title" || fragField == "search" { + html += `
`
+				for _, f := range frags {
+					html += f
+				}
+				html += "
" + break + } + } + html += "
" + } + return +} + +type Title struct { + Title string + LinkTitle string +} + +func (i *Index) 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 = maxResult + res, err = i.index.Search(req) + if err != nil { + return + } + for _, v := range res.Hits { + if v == nil { + continue + } + if v.ID == indexName { + continue + } + doc, err := i.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 (i *Index) Dump() (titles []string, err error) { + req := bleve.NewSearchRequest(bleve.NewMatchAllQuery()) + req.Size = maxResult + res, err := i.index.Search(req) + if err != nil { + return + } + for _, v := range res.Hits { + if v == nil { + continue + } + titles = append(titles, v.ID) + } + return +} diff --git a/main.go b/main.go index 7b9d41c..3f7c614 100644 --- a/main.go +++ b/main.go @@ -4,154 +4,14 @@ package main -import ( - "errors" - "flag" - logger "log" - "net" - "os" - "os/signal" - "syscall" - "time" +import "github.com/urfave/cli" - "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 -) +var version string 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
:") - 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) - } + app := cli.NewApp() + app.Name = "gowiki" + app.Usage = "a simple wiki engine" + app.Version = version + run(app) } diff --git a/render.go b/render.go index 1636cc5..be2b360 100644 --- a/render.go +++ b/render.go @@ -25,38 +25,74 @@ var ( reLink = regexp.MustCompile(`id="(.+)">`) whiteSpace = regexp.MustCompile(`\s+`) reIndex = regexp.MustCompile("(.+)") + titleRe = regexp.MustCompile("[^a-zA-Z0-9 -]+") ) -func render(text string) Article { +type rendered struct { + Index template.HTML + Text template.HTML + MD string + Raw []byte +} + +func (r *rendered) Article() *Article { + return &Article{ + Index: r.Index, + Text: r.Text, + MD: r.MD, + Search: whiteSpace.ReplaceAllString(string(filter.Filter(r.Raw)), " "), + } +} + +func (r *rendered) Snippet() *Snippet { + return &Snippet{ + Index: r.Index, + Text: r.Text, + MD: r.MD, + } +} + +func render(text string) *rendered { rend := blackfriday.MarkdownCommon([]byte(text)) - html := string(rend) - index := buildIndex(html) + index, html := buildIndex(string(rend)) html = strings.Replace(html, "", `
`, -1) - html = reHeader.ReplaceAllString(html, `${1} id="${2}">${2}${3}`) - html = reLink.ReplaceAllStringFunc(html, makeLinkTitle) html = strings.Replace(html, "", `
`, -1) html = strings.Replace(html, "${2}${3}`) + html = reLink.ReplaceAllStringFunc(html, spaceReplace) for k, v := range hn { - link := makeLinkTitle(v[2]) + link, _ := makeLinkTitle(v[2]) + i := m[link] + m[link] = i + 1 + newLink := link + strconv.Itoa(i) + html = strings.Replace(html, link+`"`, newLink+`"`, 1) ret += before(hn, k) - ret += `
  • ` + v[2] + "" + ret += `
  • ` + v[2] + "" ret += after(hn, k) } - return + return ret, html } func before(arr [][]string, i int) string { diff --git a/routes.go b/routes.go index d6426fe..b9f09c7 100644 --- a/routes.go +++ b/routes.go @@ -2,134 +2,126 @@ package main -import ( - "net/http" - - "github.com/gorilla/mux" -) - type route struct { Path string - Handler http.HandlerFunc + Handler ctxHandler 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), + indexHandler, []string{"GET"}, }, { "/bootstrap.css", - contextWrapper( - staticHandler), + staticHandler, []string{"GET"}, }, { "/custom.css", - contextWrapper( - staticHandler), + staticHandler, []string{"GET"}, }, { "/login", - contextWrapper( - loginHandler), + loginHandler, []string{"GET", "POST"}, }, { "/search", - contextWrapper( - searchHandler), + searchHandler, []string{"GET", "POST"}, }, { "/new", - contextWrapper( + authHandler( wikiNewHandler), []string{"GET", "POST"}, }, { "/all", - contextWrapper( - allHandler), + allHandler, []string{"GET"}, }, { - "/wiki/{title:[a-zA-Z0-9+]+$}", - contextWrapper( + "/wiki/{title:[a-zA-Z0-9+-]+$}", + wikiHandler, + []string{"GET"}, + }, + { + "/wiki/{title:[a-zA-Z0-9+-]+}/md", + wikiMDHandler( wikiHandler), []string{"GET"}, }, { - "/wiki/{title:[a-zA-Z0-9+]+}/md", - contextWrapper( - wikiMDHandler( - wikiHandler)), - []string{"GET"}, - }, - { - "/wiki/{title:[a-zA-Z0-9+]+}/edit", - contextWrapper( + "/wiki/{title:[a-zA-Z0-9+-]+}/edit", + authHandler( wikiEditHandler), []string{"GET", "POST"}, }, { - "/wiki/{title:[a-zA-Z0-9+]+}/del", - contextWrapper( + "/wiki/{title:[a-zA-Z0-9+-]+}/del", + authHandler( wikiDelHandler), []string{"POST"}, }, { "/users", - contextWrapper( - adminAuthHandler( - usersHandler)), + adminAuthHandler( + usersHandler), []string{"GET"}, }, { - "/user/new", - contextWrapper( - adminAuthHandler( - userNewHandler)), + "/users/{user:[a-zA-Z0-9]+}/{title:[a-zA-Z0-9+-]+$}", + wikiHandler, + []string{"GET"}, + }, + { + "/users/{user:[a-zA-Z0-9]+}/{title:[a-zA-Z0-9+-]+}/md", + wikiMDHandler( + wikiHandler), + []string{"GET"}, + }, + { + "/users/{user:[a-zA-Z0-9]+}/{title:[a-zA-Z0-9+-]+}/edit", + wikiEditHandler, []string{"GET", "POST"}, }, { - "/user/del/{user:[a-z]+$}", - contextWrapper( - adminAuthHandler( - userDelHandler)), - []string{"GET", "POST"}, - }, - { - "/user/unlock/{user:[a-z]+$}", - contextWrapper( - adminAuthHandler( - userUnlockHandler)), + "/users/{user:[a-zA-Z0-9]+}/{title:[a-zA-Z0-9+-]+}/del", + wikiDelHandler, []string{"POST"}, }, { - "/user/edit/{user:[a-z]+$}", - contextWrapper( - userAuthHandler( - userEditHandler)), + "/user/new", + adminAuthHandler( + userNewHandler), + []string{"GET", "POST"}, + }, + { + "/user/del/{user:[a-zA-Z0-9]+$}", + adminAuthHandler( + userDelHandler), + []string{"GET", "POST"}, + }, + { + "/user/unlock/{user:[a-zA-Z0-9]+$}", + adminAuthHandler( + userUnlockHandler), + []string{"POST"}, + }, + { + "/user/edit/{user:[a-zA-Z0-9]+$}", + userAuthHandler( + userEditHandler), []string{"GET", "POST"}, }, { "/logout", - contextWrapper( - logoutHandler), + logoutHandler, []string{"GET", "POST"}, }, } diff --git a/scripts/gowiki_init b/scripts/gowiki_init old mode 100755 new mode 100644 diff --git a/server.go b/server.go new file mode 100644 index 0000000..9d54f92 --- /dev/null +++ b/server.go @@ -0,0 +1,118 @@ +package main + +import ( + "context" + "crypto/rand" + "github.com/gorilla/mux" + "github.com/gorilla/sessions" + "html/template" + "io" + "net" + "net/http" + "time" +) + +const ( + keyLen = 32 + authLen = keyLen * 2 + cookieName = "gosession" +) + +type HTTPServer struct { + Version string + SecCookie bool + Store *sessions.CookieStore + Editors *EditorStore + BS *BoltStore + Index *Index + listener net.Listener + srv *http.Server + templ map[string]*template.Template + res map[string][]byte +} + +func NewHTTPServer(l net.Listener, versin string, secCookie bool) (srv *HTTPServer) { + srv = &HTTPServer{ + Version: version, + SecCookie: secCookie, + listener: l, + } + srv.Editors = NewEditorStore() + srv.cookieStore() + srv.loadTemplates() + srv.srv = &http.Server{ + Handler: srv.buildRoutes(), + } + return +} + +func (s *HTTPServer) Start() (err error) { + s.BS, err = NewBoltStore() + if err != nil { + return + } + s.Index, err = NewIndex(blevePath) + if err != nil { + return + } + go func() { + log.Println(s.srv.Serve(s.listener)) + }() + return +} + +func (s *HTTPServer) Stop() error { + s.BS.Close() + s.Index.Close() + ctx, cancel := context.WithTimeout(context.Background(), time.Second*5) + defer cancel() + return s.srv.Shutdown(ctx) +} + +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) { + h(newContext(w, r, s)) + } +} +func (s *HTTPServer) cookieStore() { + options := &sessions.Options{ + Path: "/", + MaxAge: 3600 * 12, + Secure: s.SecCookie, + HttpOnly: true, + } + authKey := genKey(authLen) + encKey := genKey(keyLen) + s.Store = sessions.NewCookieStore(authKey, encKey) + s.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 + + s.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 +} diff --git a/snippet.go b/snippet.go new file mode 100644 index 0000000..e267c3e --- /dev/null +++ b/snippet.go @@ -0,0 +1,148 @@ +package main + +import ( + "bytes" + "github.com/boltdb/bolt" + "html/template" + "strings" +) + +type Snippet struct { + Title string `json:"title"` + LinkTitle string `json:"-"` + Index template.HTML `json:"index"` + Text template.HTML `json:"text"` + MD string `json:"md"` + Created string `json:"created"` + Updated string `json:"updated"` + Permission int `json:"permission"` +} + +func copyBuf(buf []byte) (b []byte) { + b = make([]byte, len(buf)) + copy(b, buf) + return +} + +var ( + errSnippetNotExists = dbErr("snippet does not exist") + errSnippetExists = dbErr("snippet already exist") + errInvalidPermission = dbErr("invalid permission") +) + +func (bs *BoltStore) GetSnippets(username string) (snip []Snippet, err error) { + user := []byte(username) + var buf [][]byte + err = bs.db.View(func(tx *bolt.Tx) error { + return tx.Bucket([]byte(snippetTable)).ForEach(func(k, v []byte) error { + if !bytes.HasPrefix(k, user) { + return nil + } + buf = append(buf, copyBuf(v)) + return nil + }) + }) + if err != nil { + return + } + for _, b := range buf { + s := new(Snippet) + if err = bs.Unmarshal(b, s); err != nil { + return + } + snip = append(snip, *s) + } + return +} + +func (bs *BoltStore) GetSnippet(username, title string, perm int) (*Snippet, error) { + s := new(Snippet) + err := bs.db.View(func(tx *bolt.Tx) error { + v := tx.Bucket([]byte(snippetTable)).Get([]byte(title)) + if v == nil { + return errSnippetNotExists + } + return bs.Unmarshal(v, s) + }) + if err != nil { + return nil, err + } + switch s.Permission { + case 0: + return s, nil + case 1: + if username != "" { + return s, nil + } + case 2: + if strings.HasPrefix(title, username) { + return s, nil + } + } + return nil, errSnippetNotExists +} + +func (bs *BoltStore) snippetExists(username, title string, perm int) (err error) { + _, err = bs.GetSnippet(username, title, perm) + return +} + +func makeKey(username, title string) string { + return username + title +} + +func checkPerm(p int) error { + switch p { + case 0, 1, 2: + return nil + } + return errInvalidPermission +} + +func (bs *BoltStore) CreateSnippet(username, title, text string, perm int) (newTitle string, err error) { + newTitle, title = makeLinkTitle(title) + err = checkPerm(perm) + if err != nil { + return + } + err = bs.snippetExists(username, title, perm) + if err == nil { + err = errSnippetExists + return + } + err = bs.db.Update(func(tx *bolt.Tx) error { + snip := render(text).Snippet() + snip.Title = title + snip.Created = username + " " + now() + snip.Permission = perm + v, err := bs.Marshal(snip) + if err != nil { + return err + } + return tx.Bucket([]byte(snippetTable)).Put([]byte(makeKey(username, title)), v) + }) + return +} + +func (bs *BoltStore) EditSnippet(username, title, text string, perm int) (err error) { + err = checkPerm(perm) + if err != nil { + return + } + err = bs.snippetExists(username, title, perm) + if err != nil { + return + } + err = bs.db.Update(func(tx *bolt.Tx) error { + snip := render(text).Snippet() + snip.Title = title + snip.Created = username + " " + now() + snip.Permission = perm + v, err := bs.Marshal(snip) + if err != nil { + return err + } + return tx.Bucket([]byte(snippetTable)).Put([]byte(makeKey(username, title)), v) + }) + return +} diff --git a/snippet_handler.go b/snippet_handler.go new file mode 100644 index 0000000..f2c4fd7 --- /dev/null +++ b/snippet_handler.go @@ -0,0 +1,155 @@ +package main + +func allSnippetHandler(ctx *Context) { + ctx.Template("allSnippetHandler") + ctx.Data = webData{Title: "All Snippets"} + all, err := ctx.Srv.BS.GetSnippets(ctx.GetUser()) + if err != nil { + ctx.Data.Msg = err.Error() + } + ctx.Data.Data = all + ctx.Exec() +} + +func snippetNewHandler(ctx *Context) { + ctx.Template("snippetNewHandler") + ctx.Data = webData{Title: "New Snippet"} + switch ctx.Method() { + case "GET": + ctx.Exec() + case "POST": + user := ctx.GetUser() + title := ctx.Form("title") + text := ctx.Form("text") + perm := ctx.Form("permission") + submit := ctx.Form("submit") + snip := Snippet{ + Title: title, + MD: text, + } + ctx.Data.Data = art + if !ctx.CheckToken() { + ctx.Data.Msg = "wrong csrf token" + ctx.Exec() + return + } + iperm, err := strconv.Atoi(perm) + if err != nil || submit != "Submit" { + ctx.Data.Msg = "wrong inputs" + ctx.Exec() + return + } + newTitle, err := ctx.Srv.BS.CreateSnippet(user, title, text, iperm) + if err != nil { + ctx.Data.Msg = err.Error() + ctx.Exec() + return + } + ctx.Redirect("/users/"+user+"/"+newTitle, 302) + } +} + +func wikiHandler(ctx *Context) { + if ctx.T == nil { + ctx.Template("wikiHandler") + } + ctx.Data = webData{ + Admin: ctx.GetAdmin(), + } + title := ctx.Var("title") + art, err := ctx.Srv.Index.Get(!ctx.LoggedOn(), title) + if err != nil { + ctx.NotFound() + 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": + art, err := ctx.Srv.Index.Get(!ctx.LoggedOn(), title) + if err != nil { + ctx.NotFound() + return + } + art.LinkTitle = title + ctx.Data.Data = art + ctx.Data.Title = art.Title + ctx.Data.BodyTitle = art.Title + ctx.Data.Editor = ctx.Srv.Editors.Set(title, ctx.GetUser()) + 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.Data.Msg = "wrong csrf token" + ctx.Exec() + return + } + if submit == "Back" { + ctx.Srv.Editors.RemoveEditor(title, ctx.GetUser()) + ctx.Redirect("/wiki/"+title, 302) + return + } + if submit != "Submit" { + ctx.Data.Msg = "wrong inputs" + ctx.Exec() + return + } + err := ctx.Srv.Index.Update(title, text, ctx.GetUser(), pub) + if err != nil { + ctx.Data.Msg = err.Error() + ctx.Exec() + return + } + ctx.Srv.Editors.Remove(title) + 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 := ctx.Srv.Index.Delete(title); err != nil { + log.Println("delete:", err) + ctx.Redirect("/wiki/"+title, 302) + } + ctx.Redirect("/wiki/Index", 302) +} diff --git a/templates.go b/templates.go index 73efdc1..dcc6edc 100644 --- a/templates.go +++ b/templates.go @@ -31,10 +31,10 @@ const ( GoWiki | {{.Title}} - - + + - + {{end}}` menu = `{{define "menu"}} -{{if .Login}} + + +
    +
    + + + + +
    +
    + {{else}} - - {{end}} + {{end}}` notFound = `{{define "body"}} {{end}}` wikiEdit = `{{define "body"}} +{{if .Editor}} +
    +

    Warning!

    +

    {{.Editor.Username}} is editing this page since {{.Editor.Time}}

    +
    +{{end}}