Create wiki system

This commit is contained in:
Peter Stuifzand 2018-11-24 13:34:51 +01:00
commit f37d02b434
12 changed files with 789 additions and 0 deletions

4
.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
.idea
wiki
data/
session/

152
file.go Normal file
View File

@ -0,0 +1,152 @@
package main
import (
"bufio"
"bytes"
"github.com/sergi/go-diff/diffmatchpatch"
"html"
"html/template"
"io/ioutil"
"log"
"os"
"os/exec"
"path/filepath"
"strings"
)
type FilePages struct {
dirname string
}
func NewFilePages(dirname string) PagesRepository {
fp := &FilePages{dirname}
return fp
}
func (fp *FilePages) Get(p string) Page {
f, err := os.Open(filepath.Join(fp.dirname, strings.Replace(p, " ", "_", -1)))
if err != nil {
return Page{}
}
defer f.Close()
body, err := ioutil.ReadAll(f)
if err != nil {
return Page{}
}
return Page{Content: string(body)}
}
func (fp *FilePages) Save(p string, page Page, summary, author string) {
f, err := os.Create(filepath.Join(fp.dirname, strings.Replace(p, " ", "_", -1)))
if err != nil {
return
}
defer f.Close()
f.WriteString(strings.Replace(page.Content, "\r\n", "\n", -1))
saveWithGit(fp, p, summary, author)
return
}
func saveWithGit(fp *FilePages, p string, summary, author string) {
cmd := exec.Command("git", "add", ".")
cmd.Dir = fp.dirname
cmd.Run()
cmd = exec.Command("git", "commit", "-m", "Changes to "+p+" by "+author+"\n\n"+summary)
cmd.Dir = fp.dirname
cmd.Run()
}
func (fp *FilePages) Exist(p string) bool {
f, err := os.Open(filepath.Join(fp.dirname, strings.Replace(p, " ", "_", -1)))
if err != nil {
return os.IsExist(err)
}
f.Close()
return true
}
func DiffPrettyHtml(diffs []diffmatchpatch.Diff) string {
var buff bytes.Buffer
for _, diff := range diffs {
// text := strings.Replace(html.EscapeString(diff.Text), "\n", "<span class=\"lighter\">&para;</span><br>", -1)
text := html.EscapeString(diff.Text)
switch diff.Type {
case diffmatchpatch.DiffInsert:
_, _ = buff.WriteString("<ins style=\"background:#e6ffe6;\">")
_, _ = buff.WriteString(text)
_, _ = buff.WriteString("</ins>")
case diffmatchpatch.DiffDelete:
_, _ = buff.WriteString("<del style=\"background:#ffe6e6;\">")
_, _ = buff.WriteString(text)
_, _ = buff.WriteString("</del>")
case diffmatchpatch.DiffEqual:
_, _ = buff.WriteString("<span>")
_, _ = buff.WriteString(text)
_, _ = buff.WriteString("</span>")
}
}
return buff.String()
}
func (fp *FilePages) PageHistory(p string) ([]Revision, error) {
page := strings.Replace(p, " ", "_", -1)
cmd := exec.Command("git", "log", "--pretty=oneline", "--no-decorate", "--color=never", page)
cmd.Dir = fp.dirname
output, err := cmd.StdoutPipe()
if err != nil {
return nil, err
}
defer output.Close()
err = cmd.Start()
if err != nil {
log.Println("Start")
return nil, err
}
buf := bufio.NewScanner(output)
var revisions []Revision
for buf.Scan() {
line := buf.Text()
start := strings.Index(line, " ")
commitId := line[0:start]
rest := line[start+1:]
pageText := gitRevision(fp.dirname, page, commitId)
revisions = append(revisions, Revision{
Version: commitId,
Page: DiffPage{Content: pageText},
Summary: rest,
})
}
dmp := diffmatchpatch.New()
prevText := ""
for i := len(revisions) - 1; i >= 0; i-- {
diffs := dmp.DiffMain(prevText, revisions[i].Page.Content, false)
revisions[i].Page.Diff = template.HTML(DiffPrettyHtml(diffs))
prevText = revisions[i].Page.Content
}
if err := cmd.Wait(); err != nil {
log.Println("wait")
return nil, err
}
return revisions, nil
}
func gitRevision(dirname, page, version string) string {
cmd := exec.Command("git", "show", version+":"+page)
cmd.Dir = dirname
buf := bytes.Buffer{}
cmd.Stdout = &buf
cmd.Start()
cmd.Wait()
return buf.String()
}

349
main.go Normal file
View File

@ -0,0 +1,349 @@
package main
import (
"fmt"
"gitlab.com/golang-commonmark/markdown"
"html/template"
"log"
"net/http"
"net/url"
"p83.nl/go/ekster/pkg/util"
"p83.nl/go/indieauth"
"regexp"
"strings"
)
var (
mp PagesRepository
)
const (
ClientID = "https://wiki.p83.nl/"
RedirectURI = "https://wiki.p83.nl/auth/callback"
)
// Page
type Page struct {
Content string
}
type DiffPage struct {
Content string
Diff template.HTML
}
type Revision struct {
Version string
Page DiffPage
Summary string
}
type PagesRepository interface {
Get(p string) Page
Save(p string, page Page, summary, author string)
Exist(p string) bool
PageHistory(p string) ([]Revision, error)
}
type indexPage struct {
Session *Session
Title string
Name string
Content template.HTML
}
type editPage struct {
Session *Session
Title string
Content string
Name string
}
type historyPage struct {
Session *Session
Title string
Name string
History []Revision
}
type indexHandler struct{}
type saveHandler struct{}
type editHandler struct{}
type historyHandler struct{}
type authHandler struct{}
func (*authHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
defer r.Body.Close()
sess, err := NewSession(w, r)
if err != nil {
http.Error(w, err.Error(), 500)
return
}
defer sess.Flush()
if r.Method == http.MethodGet {
if r.URL.Path == "/auth/login" {
t, err := template.ParseFiles("templates/layout.html", "templates/login.html")
if err != nil {
http.Error(w, err.Error(), 500)
return
}
err = t.Execute(w, nil)
if err != nil {
http.Error(w, err.Error(), 500)
return
}
} else if r.URL.Path == "/auth/callback" {
code := r.FormValue("code")
state := r.FormValue("state")
if state != sess.State {
http.Error(w, "mismatched state", 500)
return
}
authURL := sess.AuthorizationEndpoint
verified, response, err := indieauth.VerifyAuthCode(ClientID, code, RedirectURI, authURL)
if err != nil {
http.Error(w, err.Error(), 500)
return
}
if !verified {
http.Redirect(w, r, "/auth/login", 302)
return
}
sess.Me = response.Me
sess.LoggedIn = true
sess.State = ""
http.Redirect(w, r, sess.NextURI, 302)
return
} else if r.URL.Path == "/auth/logout" {
t, err := template.ParseFiles("templates/layout.html", "templates/logout.html")
if err != nil {
http.Error(w, err.Error(), 500)
return
}
err = t.Execute(w, nil)
if err != nil {
http.Error(w, err.Error(), 500)
return
}
}
} else if r.Method == http.MethodPost {
if r.URL.Path == "/auth/login" {
r.ParseForm()
urlString := r.PostFormValue("url")
u, err := url.Parse(urlString)
if err != nil {
http.Error(w, err.Error(), 400)
return
}
endpoints, err := indieauth.GetEndpoints(u)
if err != nil {
http.Error(w, err.Error(), 500)
return
}
authURL, err := url.Parse(endpoints.AuthorizationEndpoint)
if err != nil {
http.Error(w, err.Error(), 500)
return
}
state := util.RandStringBytes(32)
sess.AuthorizationEndpoint = authURL.String()
sess.Me = urlString
sess.LoggedIn = false
sess.RedirectURI = RedirectURI
sess.NextURI = "/"
sess.State = state
newURL := indieauth.CreateAuthenticationURL(*authURL, urlString, ClientID, RedirectURI, state)
http.Redirect(w, r, newURL, 302)
return
} else if r.URL.Path == "/auth/logout" {
sess.LoggedIn = false
sess.Me = ""
sess.AuthorizationEndpoint = ""
http.Redirect(w, r, "/", 302)
return
}
} else {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
}
func (h *historyHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
defer r.Body.Close()
sess, err := NewSession(w, r)
if err != nil {
http.Error(w, err.Error(), 500)
return
}
defer sess.Flush()
r.ParseForm()
page := r.URL.Path[9:]
if page == "" {
page = "Home"
}
history, err := mp.PageHistory(page)
if err != nil {
http.Error(w, err.Error(), 500)
return
}
funcs := template.FuncMap{
"historyIndex": func(x int, history []Revision) int { return len(history) - x; },
}
t, err := template.New("layout.html").Funcs(funcs).ParseFiles("templates/layout.html", "templates/history.html")
if err != nil {
http.Error(w, err.Error(), 500)
return
}
err = t.Execute(w, historyPage{
Session: sess,
Title: "History of " + strings.Replace(page, "_", " ", -1),
Name: page,
History: history,
})
if err != nil {
http.Error(w, err.Error(), 500)
return
}
}
func (h *saveHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
defer r.Body.Close()
sess, err := NewSession(w, r)
if err != nil {
http.Error(w, err.Error(), 500)
return
}
defer sess.Flush()
r.ParseForm()
page := r.PostForm.Get("p")
summary := r.PostForm.Get("summary")
mp.Save(page, Page{Content: r.PostForm.Get("content")}, summary, sess.Me)
http.Redirect(w, r, "/"+page, http.StatusFound)
}
func (h *editHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
defer r.Body.Close()
sess, err := NewSession(w, r)
if err != nil {
http.Error(w, err.Error(), 500)
return
}
defer sess.Flush()
page := r.URL.Path[6:]
if page == "" {
page = "Home"
}
pageText := mp.Get(page).Content
data := editPage{
Session: sess,
Title: strings.Replace(page, "_", " ", -1),
Content: pageText,
Name: page,
}
t, err := template.ParseFiles("templates/layout.html", "templates/edit.html")
if err != nil {
http.Error(w, err.Error(), 500)
return
}
err = t.Execute(w, data)
if err != nil {
http.Error(w, err.Error(), 500)
return
}
}
func (h *indexHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
defer r.Body.Close()
sess, err := NewSession(w, r)
if err != nil {
http.Error(w, err.Error(), 500)
return
}
defer sess.Flush()
page := r.URL.Path[1:]
if page == "" {
page = "Home"
}
pageText := mp.Get(page).Content
if pageText == "" {
http.Redirect(w, r, "/edit/"+page, 302)
return
}
hrefRE := regexp.MustCompile(`\[\[\s*([\w- ]+)\s*\]\]`)
pageText = hrefRE.ReplaceAllStringFunc(pageText, func(s string) string {
s = strings.TrimPrefix(s, "[[")
s = strings.TrimSuffix(s, "]]")
s = strings.TrimSpace(s)
if !mp.Exist(s) {
// return fmt.Sprintf("<a href=%q class=%q>%s</a>", s, "edit", s)
return fmt.Sprintf("%s[?](/%s)", s, strings.Replace(s, " ", "_", -1))
}
return fmt.Sprintf("[%s](/%s)", s, strings.Replace(s, " ", "_", -1))
})
md := markdown.New(markdown.XHTMLOutput(true))
pageText = md.RenderToString([]byte(pageText))
data := indexPage{
Session: sess,
Title: strings.Replace(page, "_", " ", -1),
Content: template.HTML(pageText),
Name: page,
}
t, err := template.ParseFiles("templates/layout.html", "templates/view.html")
if err != nil {
http.Error(w, err.Error(), 500)
return
}
err = t.Execute(w, data)
if err != nil {
http.Error(w, err.Error(), 500)
return
}
}
func main() {
mp = NewFilePages("data")
http.Handle("/auth/", &authHandler{})
http.Handle("/public/", http.StripPrefix("/public/", http.FileServer(http.Dir("ui/node_modules/trix/dist/"))))
http.Handle("/save/", &saveHandler{})
http.Handle("/edit/", &editHandler{})
http.Handle("/history/", &historyHandler{})
http.Handle("/", &indexHandler{})
fmt.Printf("Running on port 8080")
log.Fatal(http.ListenAndServe(":8080", nil))
}

37
memory.go Normal file
View File

@ -0,0 +1,37 @@
package main
import "sync"
type MemoryPages struct {
m sync.RWMutex
pages map[string]string
}
//
// func NewMemoryPages() PagesRepository {
// return &MemoryPages{
// pages: make(map[string]string),
// }
// }
func (mp *MemoryPages) Get(p string) Page {
mp.m.RLock()
defer mp.m.RUnlock()
if pt, e := mp.pages[p]; e {
return Page{Content: pt}
}
return Page{}
}
func (mp *MemoryPages) Save(p string, page Page, summary, author string) {
mp.m.Lock()
defer mp.m.Unlock()
mp.pages[p] = page.Content
}
func (mp *MemoryPages) Exist(p string) bool {
mp.m.RLock()
defer mp.m.RUnlock()
_, e := mp.pages[p]
return e
}

94
session.go Normal file
View File

@ -0,0 +1,94 @@
package main
import (
"encoding/json"
"fmt"
"net/http"
"os"
"time"
)
type Session struct {
ID string
LoggedIn bool
Me string
AuthorizationEndpoint string
RedirectURI string
State string
NextURI string
}
func NewSession(w http.ResponseWriter, r *http.Request) (*Session, error) {
sessionID, err := getSessionCookie(w, r)
if err != nil {
return nil, err
}
session := &Session{ID: sessionID}
err = loadSession(session)
if err != nil {
return nil, err
}
return session, nil
}
func (sess *Session) Flush() error {
return saveSession(sess)
}
func saveSession(sess *Session) error {
filename := generateFilename(sess.ID)
err := os.Mkdir("session", 0755)
f, err := os.Create(filename)
if err != nil {
return err
}
defer f.Close()
err = json.NewEncoder(f).Encode(sess)
return err
}
func loadSession(sess *Session) error {
filename := generateFilename(sess.ID)
err := os.Mkdir("session", 0755)
f, err := os.Open(filename)
if err != nil {
if os.IsNotExist(err) {
// add defaults to session?
return nil
}
return err
}
defer f.Close()
err = json.NewDecoder(f).Decode(sess)
return err
}
func generateFilename(id string) string {
return fmt.Sprintf("session/%s.json", id)
}
func getSessionCookie(w http.ResponseWriter, r *http.Request) (string, error) {
c, err := r.Cookie("session")
var sessionVar string
if err != nil {
if err == http.ErrNoCookie {
sessionVar = RandStringBytes(16)
newCookie := &http.Cookie{
Name: "session",
Value: sessionVar,
Expires: time.Now().Add(24 * time.Hour),
Path: "/",
}
http.SetCookie(w, newCookie)
return sessionVar, nil
}
return "", err
} else {
sessionVar = c.Value
}
return sessionVar, nil
}

19
templates/edit.html Normal file
View File

@ -0,0 +1,19 @@
{{define "content"}}
<h1 class="title">{{ .Title }}</h1>
<form action="/save/" method="post">
<input type="hidden" name="p" value="{{ .Name }}" />
<textarea name="content" rows="24" style="width:100%">{{ .Content }}</textarea>
<br>
<div class="field">
<label class="label">Summary</label>
<div class="control">
<input type="text" name="summary" class="input" placeholder="Summary"/>
</div>
</div>
<button class="button is-primary" type="submit">Save</button>
</form>
{{ end }}
{{ define "content_head" }}
{{ end }}

14
templates/history.html Normal file
View File

@ -0,0 +1,14 @@
{{ define "navbar" }}
<a href="/edit/{{ .Name }}" class="navbar-item">Edit</a>
{{ end }}
{{ define "content" }}
{{ range $index, $revision := .History }}
<section class="section">
<h2 class="title is-4">Revision {{ historyIndex $index $.History }}</h2>
<h3 class="subtitle is-6">{{ $revision.Summary }}</h3>
<div class="content monospace">{{ $revision.Page.Diff }}</div>
</section>
<hr/>
{{ end }}
{{ end }}

56
templates/layout.html Normal file
View File

@ -0,0 +1,56 @@
<!doctype html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport"
content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/bulma/0.7.2/css/bulma.min.css" />
<title>{{ .Title }} - Wiki</title>
{{ block "content_head" . }} {{ end }}
<style>
.monospace {
font-family: "Fira Code Retina", monospace;
white-space: pre-wrap;
}
.lighter {
color:#ccc;
}
del {
display: block;
text-decoration: none;
}
ins {
display:block;
text-decoration: none;
}
</style>
</head>
<body>
<div class="container">
<nav class="navbar" role="navigation" aria-label="main navigation">
<div class="navbar-brand">
<a class="navbar-item" href="/">
Wiki
</a>
<a role="button" class="navbar-burger burger" aria-label="menu" aria-expanded="false" data-target="navbarBasicExample">
<span aria-hidden="true"></span>
<span aria-hidden="true"></span>
<span aria-hidden="true"></span>
</a>
</div>
<div id="navbarBasicExample" class="navbar-menu">
<div class="navbar-start">
{{ block "navbar" . }}{{ end }}
</div>
</div>
</nav>
<section class="section">
{{ template "content" . }}
</section>
</div>
</body>
</html>

17
templates/login.html Normal file
View File

@ -0,0 +1,17 @@
{{ define "content" }}
<h1 class="title is-1">Login</h1>
<form action="/auth/login" method="post">
<div class="field">
<label for="url" class="label">Web Signin</label>
<div class="control">
<input class="input" type="text" name="url" id="url" placeholder="url, e.g. http://example.com/"/>
</div>
</div>
<div class="field">
<div class="control">
<button class="button" type="submit">Login</button>
</div>
</div>
</form>
{{ end }}

7
templates/logout.html Normal file
View File

@ -0,0 +1,7 @@
{{ define "content" }}
<h1 class="title is-1">Logout</h1>
<form action="/auth/logout" method="post">
<button class="button" type="submit">Logout</button>
</form>
{{ end }}

25
templates/view.html Normal file
View File

@ -0,0 +1,25 @@
{{ define "content" }}
<h1 class="title is-1">{{ .Title }}</h1>
<div class="content">
{{ .Content }}
</div>
{{ end }}
{{ define "navbar" }}
{{ if $.Session.LoggedIn }}
<a href="/edit/{{ .Name }}" class="navbar-item">Edit</a>
<a href="/history/{{ .Name }}" class="navbar-item">History</a>
<a href="/auth/logout" class="navbar-item">Logout</a>
<span class="navbar-item"><b>{{ $.Session.Me }}</b></span>
{{ else }}
<a href="/auth/login" class="navbar-item">Login</a>
{{ end }}
{{ end }}
{{ define "content_head" }}
<style>
.edit {
color: red;
}
</style>
{{ end }}

15
util.go Normal file
View File

@ -0,0 +1,15 @@
package main
import (
"math/rand"
)
const letterBytes = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
func RandStringBytes(n int) string {
b := make([]byte, n)
for i := range b {
b[i] = letterBytes[rand.Intn(len(letterBytes))]
}
return string(b)
}