You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
1317 lines
29 KiB
1317 lines
29 KiB
/* |
|
* Wiki - A wiki with editor |
|
* Copyright (c) 2021 Peter Stuifzand |
|
* |
|
* This program is free software: you can redistribute it and/or modify |
|
* it under the terms of the GNU General Public License as published by |
|
* the Free Software Foundation, either version 3 of the License, or |
|
* (at your option) any later version. |
|
* |
|
* This program is distributed in the hope that it will be useful, |
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of |
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
|
* GNU General Public License for more details. |
|
* |
|
* You should have received a copy of the GNU General Public License |
|
* along with this program. If not, see <http://www.gnu.org/licenses/>. |
|
*/ |
|
|
|
package main |
|
|
|
import ( |
|
"bytes" |
|
"context" |
|
"encoding/json" |
|
"flag" |
|
"fmt" |
|
"html" |
|
"html/template" |
|
"io" |
|
"log" |
|
"net/http" |
|
"net/url" |
|
"os" |
|
"path/filepath" |
|
"regexp" |
|
"strings" |
|
"time" |
|
|
|
"github.com/blevesearch/bleve" |
|
"p83.nl/go/ekster/pkg/util" |
|
"p83.nl/go/indieauth" |
|
"p83.nl/go/wiki/link" |
|
) |
|
|
|
func init() { |
|
log.SetFlags(log.Lshortfile) |
|
} |
|
|
|
type authorizedKey string |
|
|
|
var ( |
|
authKey = authorizedKey("authorizedKey") |
|
) |
|
|
|
var ( |
|
mp PagesRepository |
|
|
|
port = flag.Int("port", 8080, "listen port") |
|
baseurl = flag.String("baseurl", "", "baseurl") |
|
redirectURI = "" |
|
authToken = os.Getenv("API_TOKEN") |
|
) |
|
|
|
type Day struct { |
|
Class string |
|
Text string |
|
URL string |
|
Count string |
|
} |
|
|
|
type Backref struct { |
|
Name string |
|
Title string |
|
LineHTML template.HTML |
|
LineEditHTML template.HTML |
|
Line string |
|
} |
|
|
|
// Page |
|
type Page struct { |
|
// Name is the filename of the page |
|
Name string |
|
|
|
// Title is the human-readable title of the page |
|
Title string |
|
|
|
Content string |
|
|
|
Refs map[string][]Backref |
|
Blocks BlockResponse |
|
Parent string |
|
} |
|
|
|
type DiffPage struct { |
|
Content string |
|
Diff template.HTML |
|
} |
|
|
|
type Revision struct { |
|
Version string |
|
Page DiffPage |
|
Summary string |
|
} |
|
|
|
type Change struct { |
|
Page string |
|
Date time.Time |
|
EndDate time.Time |
|
Body string |
|
Count int |
|
|
|
DateSummary string |
|
TimeSummary string |
|
} |
|
|
|
type PagesRepository interface { |
|
Get(p string) Page |
|
Save(p string, page Page, summary, author string) error |
|
Exist(p string) bool |
|
PageHistory(p string) ([]Revision, error) |
|
RecentChanges() ([]Change, error) |
|
AllPages() ([]Page, error) |
|
} |
|
|
|
type pageBaseInfo struct { |
|
BaseURL string |
|
RedirectURI string |
|
Days []Day |
|
Month string |
|
Year int |
|
PrevMonth string |
|
NextMonth string |
|
} |
|
|
|
type indexPage struct { |
|
pageBaseInfo |
|
Session *Session |
|
Title string |
|
Name string |
|
Content template.HTML |
|
Backrefs map[string][]Backref |
|
ShowGraph bool |
|
TodayPage string |
|
} |
|
|
|
type Node struct { |
|
Id int `json:"id"` |
|
Label string `json:"label"` |
|
Color *string `json:"color"` |
|
Opacity float64 `json:"opacity"` |
|
} |
|
|
|
type Edge struct { |
|
From int `json:"from"` |
|
To int `json:"to"` |
|
} |
|
|
|
type graphPage struct { |
|
pageBaseInfo |
|
Session *Session |
|
Title string |
|
Name string |
|
Nodes template.JS |
|
Edges template.JS |
|
} |
|
|
|
type Parent struct { |
|
Text string |
|
ID string |
|
} |
|
|
|
type editPage struct { |
|
pageBaseInfo |
|
Session *Session |
|
Title string |
|
TitleHTML template.HTML |
|
Content string |
|
Name string |
|
Editor template.HTML |
|
Backrefs map[string][]Backref |
|
ShowGraph bool |
|
TodayPage string |
|
Parent Parent |
|
Parents []Parent |
|
} |
|
|
|
type historyPage struct { |
|
pageBaseInfo |
|
Session *Session |
|
Title string |
|
Name string |
|
History []Revision |
|
} |
|
|
|
type recentPage struct { |
|
pageBaseInfo |
|
Session *Session |
|
Title string |
|
Name string |
|
Recent []Change |
|
} |
|
|
|
var baseTemplate = []string{"templates/layout.html", "templates/sidebar.html", "templates/sidebar-right.html", "templates/calendar.html"} |
|
|
|
type indexHandler struct{} |
|
type graphHandler struct{} |
|
type saveHandler struct { |
|
SearchIndex bleve.Index |
|
} |
|
type editHandler struct{} |
|
type historyHandler struct{} |
|
type recentHandler 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 func() { |
|
if err := sess.Flush(); err != nil { |
|
log.Println(err) |
|
} |
|
}() |
|
|
|
if r.Method == http.MethodGet { |
|
if r.URL.Path == "/auth/login" { |
|
templates := baseTemplate |
|
templates = append(templates, "templates/login.html") |
|
t, err := template.ParseFiles(templates...) |
|
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.URL.Query().Get("code") |
|
state := r.URL.Query().Get("state") |
|
|
|
if state != sess.State { |
|
log.Printf("mismatched state: %s != %s", state, sess.State) |
|
http.Error(w, "mismatched state", 500) |
|
return |
|
} |
|
authURL := sess.AuthorizationEndpoint |
|
|
|
verified, response, err := indieauth.VerifyAuthCode(*baseurl, 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" { |
|
templates := baseTemplate |
|
templates = append(templates, "templates/logout.html") |
|
t, err := template.ParseFiles(templates...) |
|
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, *baseurl, 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 func() { |
|
if err := sess.Flush(); err != nil { |
|
log.Println(err) |
|
} |
|
}() |
|
|
|
if !sess.LoggedIn { |
|
http.Redirect(w, r, "/", http.StatusFound) |
|
return |
|
} |
|
|
|
r.ParseForm() |
|
page := r.URL.Path[9:] |
|
page = resolvePageName(page) |
|
|
|
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 |
|
} |
|
|
|
pageBase := getPageBase(mp, time.Now()) |
|
pageData := historyPage{ |
|
pageBaseInfo: pageBase, |
|
Session: sess, |
|
Title: "History of " + cleanTitle(page), |
|
Name: page, |
|
History: history, |
|
} |
|
|
|
err = t.Execute(w, pageData) |
|
if err != nil { |
|
http.Error(w, err.Error(), 500) |
|
return |
|
} |
|
} |
|
|
|
func daysInMonth(year int, month time.Month) int { |
|
return time.Date(year, month, 1, 0, 0, 0, 0, time.UTC).AddDate(0, 1, -1).Day() |
|
} |
|
|
|
func prepareDays(repo PagesRepository, t time.Time) []Day { |
|
today := time.Now() |
|
|
|
var days []Day |
|
|
|
curDate := t.AddDate(0, 0, -(t.Day() - 1)) |
|
preDays := int(curDate.Weekday()) |
|
if preDays == 0 { |
|
preDays = 7 |
|
} |
|
preDays -= 1 |
|
|
|
maxDays := daysInMonth(curDate.Year(), curDate.Month()) |
|
|
|
endOfMonth := false |
|
|
|
for i := 0; i < 6; i++ { |
|
year, week := curDate.ISOWeek() |
|
days = append(days, Day{ |
|
Class: "week day", |
|
Text: fmt.Sprintf("%02d", week), |
|
URL: fmt.Sprintf("/edit/%s", formatWeekPageName(year, week)), |
|
Count: "", |
|
}) |
|
|
|
for d := 0; d < 7; d++ { |
|
day := -preDays + (i * 7) + 1 + d |
|
fday := fmt.Sprintf("%d", day) |
|
if day <= 0 { |
|
fday = "" |
|
} |
|
if day > maxDays { |
|
fday = "" |
|
endOfMonth = true |
|
} |
|
class := "day" |
|
if timeEqual(curDate, today) { |
|
class += " today" |
|
} |
|
if timeEqual(curDate, t) { |
|
class += " current" |
|
} |
|
pageName := formatDatePageName(curDate) |
|
if fday != "" && pageHasContent(mp, pageName) { |
|
class += " has-content" |
|
} |
|
days = append(days, Day{ |
|
Class: class, |
|
Text: fday, |
|
URL: fmt.Sprintf("/edit/%s", pageName), |
|
Count: "", |
|
}) |
|
if fday != "" { |
|
curDate = curDate.AddDate(0, 0, 1) |
|
} |
|
if day == maxDays { |
|
endOfMonth = true |
|
} |
|
} |
|
|
|
if endOfMonth { |
|
break |
|
} |
|
} |
|
|
|
return days |
|
} |
|
|
|
func pageHasContent(repo PagesRepository, day string) bool { |
|
return repo.Exist(day) |
|
// page := repo.Get(day) |
|
// return page.Blocks.Children != nil |
|
} |
|
|
|
func formatWeekPageName(year int, week int) string { |
|
return fmt.Sprintf("%04dW%02d", year, week) |
|
} |
|
|
|
func timeEqual(date time.Time, today time.Time) bool { |
|
if date.Year() != today.Year() { |
|
return false |
|
} |
|
if date.Month() != today.Month() { |
|
return false |
|
} |
|
if date.Day() != today.Day() { |
|
return false |
|
} |
|
return true |
|
} |
|
|
|
func getPageBase(repo PagesRepository, t time.Time) pageBaseInfo { |
|
clientID := *baseurl |
|
|
|
pageBase := pageBaseInfo{ |
|
BaseURL: clientID, |
|
RedirectURI: redirectURI, |
|
Days: prepareDays(mp, t), |
|
Month: t.Month().String(), |
|
Year: t.Year(), |
|
} |
|
|
|
pageBase.PrevMonth = t.AddDate(0, -1, -t.Day()+1).Format("2006-01-02") |
|
pageBase.NextMonth = t.AddDate(0, 1, -t.Day()+1).Format("2006-01-02") |
|
|
|
return pageBase |
|
} |
|
|
|
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) |
|
log.Println(err) |
|
return |
|
} |
|
defer func() { |
|
if err := sess.Flush(); err != nil { |
|
log.Println(err) |
|
} |
|
}() |
|
|
|
if !sess.LoggedIn { |
|
http.Redirect(w, r, "/", http.StatusFound) |
|
return |
|
} |
|
|
|
err = r.ParseForm() |
|
if err != nil { |
|
http.Error(w, err.Error(), 500) |
|
log.Println(err) |
|
return |
|
} |
|
isJson := r.PostForm.Get("json") == "1" |
|
page := r.PostForm.Get("p") |
|
summary := r.PostForm.Get("summary") |
|
|
|
pageData := Page{Content: r.PostForm.Get("content")} |
|
err = mp.Save(page, pageData, summary, sess.Me) |
|
if err != nil { |
|
log.Println(err) |
|
} |
|
|
|
if isJson { |
|
fmt.Print(w, "{}") |
|
} else { |
|
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 func() { |
|
if err := sess.Flush(); err != nil { |
|
log.Println(err) |
|
} |
|
}() |
|
|
|
if !sess.LoggedIn { |
|
http.Redirect(w, r, "/", http.StatusFound) |
|
return |
|
} |
|
|
|
page := r.URL.Path[6:] |
|
page = resolvePageName(page) |
|
|
|
mpPage := mp.Get(page) |
|
pageText := mpPage.Content |
|
|
|
if pageText == "" { |
|
pageText = "[]" |
|
} |
|
|
|
var rawMsg json.RawMessage |
|
err = json.NewDecoder(strings.NewReader(pageText)).Decode(&rawMsg) |
|
jsonEditor := pageText != "" && err == nil |
|
|
|
var editor template.HTML |
|
if jsonEditor { |
|
editor, err = renderEditor(page, pageText, "json", *baseurl) |
|
if err != nil { |
|
http.Error(w, err.Error(), 500) |
|
return |
|
} |
|
} else { |
|
editor = template.HTML(fmt.Sprintf(` |
|
<textarea name="content" rows="24" style="width:100%%">%s</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> |
|
`, html.EscapeString(pageText))) |
|
} |
|
|
|
curDate, err := ParseDatePageName(page) |
|
if err != nil { |
|
curDate = time.Now() |
|
} |
|
pageBase := getPageBase(mp, curDate) |
|
title := cleanTitle(mpPage.Title) |
|
if newTitle, err := PageTitle(pageText); err == nil { |
|
title = newTitle |
|
} |
|
|
|
var parent Parent |
|
parent.ID = mpPage.Parent |
|
parent.Text = mpPage.Blocks.Texts[parent.ID] |
|
|
|
var parents []Parent |
|
for _, p := range mpPage.Blocks.Parents { |
|
var parent Parent |
|
parent.ID = p |
|
parent.Text = mpPage.Blocks.Texts[p] |
|
parents = append(parents, parent) |
|
} |
|
|
|
for i, j := 0, len(parents)-1; i < j; i, j = i+1, j-1 { |
|
parents[i], parents[j] = parents[j], parents[i] |
|
} |
|
|
|
htmlTitle := link.FormatHtmlTitle(title) |
|
|
|
data := editPage{ |
|
pageBaseInfo: pageBase, |
|
Session: sess, |
|
Title: title, |
|
TitleHTML: htmlTitle, |
|
Content: pageText, |
|
Editor: editor, |
|
Name: page, |
|
Backrefs: mpPage.Refs, |
|
TodayPage: "Today", |
|
ShowGraph: page != "Daily_Notes", |
|
Parent: parent, |
|
Parents: parents, |
|
} |
|
templates := baseTemplate |
|
templates = append(templates, "templates/edit.html") |
|
t, err := template.ParseFiles(templates...) |
|
if err != nil { |
|
http.Error(w, err.Error(), 500) |
|
return |
|
} |
|
w.Header().Set("Content-Type", "text/html; charset=utf-8") |
|
w.Header().Set("Cache-Control", "public, max-age=600") |
|
err = t.Execute(w, data) |
|
if err != nil { |
|
log.Println(err) |
|
http.Error(w, err.Error(), 500) |
|
return |
|
} |
|
} |
|
|
|
func (h *graphHandler) 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 func() { |
|
if err := sess.Flush(); err != nil { |
|
log.Println(err) |
|
} |
|
}() |
|
|
|
if !sess.LoggedIn { |
|
http.Redirect(w, r, "/", http.StatusFound) |
|
return |
|
} |
|
|
|
gb, err := NewGraphBuilder(mp) |
|
if err != nil { |
|
http.Error(w, err.Error(), 500) |
|
return |
|
} |
|
|
|
gb.prepareNodeMap() |
|
|
|
gb.RemoveNode("DONE") |
|
gb.RemoveNode("TODO") |
|
gb.RemoveNode("Home") |
|
gb.RemoveNode("Projects") |
|
gb.RemoveNode("Notes") |
|
gb.RemoveNode("Books") |
|
gb.RemoveNode("Daily_Notes") |
|
gb.RemoveNode("NewJsonTest") |
|
gb.RemoveNodeWithSuffix("_2020") |
|
|
|
err = gb.prepareGraph() |
|
if err != nil { |
|
http.Error(w, err.Error(), 500) |
|
return |
|
} |
|
|
|
var nodesBuf bytes.Buffer |
|
var edgesBuf bytes.Buffer |
|
|
|
err = json.NewEncoder(&nodesBuf).Encode(&gb.nodes) |
|
if err != nil { |
|
http.Error(w, err.Error(), 500) |
|
return |
|
} |
|
|
|
err = json.NewEncoder(&edgesBuf).Encode(&gb.edges) |
|
if err != nil { |
|
http.Error(w, err.Error(), 500) |
|
return |
|
} |
|
|
|
pageBase := getPageBase(mp, time.Now()) |
|
data := graphPage{ |
|
pageBaseInfo: pageBase, |
|
Session: sess, |
|
Title: "Graph", |
|
Name: "Graph", |
|
Nodes: template.JS(nodesBuf.String()), |
|
Edges: template.JS(edgesBuf.String()), |
|
} |
|
templates := baseTemplate |
|
templates = append(templates, "templates/graph.html") |
|
t, err := template.ParseFiles(templates...) |
|
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() |
|
|
|
metaKV, err := regexp.Compile(`(\w+)::\s+(.*)`) |
|
if err != nil { |
|
log.Fatal(err) |
|
} |
|
|
|
sess, err := NewSession(w, r) |
|
if err != nil { |
|
http.Error(w, err.Error(), 500) |
|
return |
|
} |
|
defer func() { |
|
if err := sess.Flush(); err != nil { |
|
log.Println(err) |
|
} |
|
}() |
|
|
|
if !sess.LoggedIn { |
|
http.Redirect(w, r, "/auth/login", http.StatusFound) |
|
return |
|
} |
|
|
|
format := r.URL.Query().Get("format") |
|
if format == "" { |
|
format = "html" |
|
} |
|
|
|
if !(format == "html" || format == "markdown" || format == "hmarkdown" || format == "json" || format == "metakv") { |
|
http.Error(w, "unknown format", http.StatusBadRequest) |
|
} |
|
|
|
page := r.URL.Path[1:] |
|
if page == "favicon.ico" { |
|
http.Error(w, "Not Found", 404) |
|
return |
|
} |
|
page = resolvePageName(page) |
|
|
|
mpPage := mp.Get(page) |
|
pageText := mpPage.Content |
|
if (format == "" || format == "html") && pageText == "" { |
|
http.Redirect(w, r, "/edit/"+page, 302) |
|
return |
|
} |
|
|
|
if pageText == "" { |
|
pageText = "[]" |
|
} |
|
|
|
var rawMsg json.RawMessage |
|
err = json.NewDecoder(strings.NewReader(pageText)).Decode(&rawMsg) |
|
|
|
title := cleanTitle(mpPage.Title) |
|
|
|
jsonPage := pageText != "" && err == nil |
|
if jsonPage { |
|
if format == "json" { |
|
w.Header().Set("Content-Type", "application/json") |
|
w.Header().Set("Cache-Control", "no-store") |
|
// Shortcut for json output |
|
_, err := io.WriteString(w, pageText) |
|
if err != nil { |
|
http.Error(w, err.Error(), 500) |
|
} |
|
return |
|
} else if format == "metakv" { |
|
so, err := createStructuredFormat(mpPage) |
|
if err != nil { |
|
http.Error(w, err.Error(), 500) |
|
return |
|
} |
|
w.Header().Set("Content-Type", "application/json") |
|
w.Header().Set("Cache-Control", "no-store") |
|
enc := json.NewEncoder(w) |
|
enc.SetIndent("", " ") |
|
err = enc.Encode(so) |
|
if err != nil { |
|
http.Error(w, err.Error(), 500) |
|
} |
|
return |
|
} |
|
|
|
var listItems []struct { |
|
Indented int |
|
Text string |
|
} |
|
|
|
err = json.NewDecoder(strings.NewReader(pageText)).Decode(&listItems) |
|
if err != nil { |
|
http.Error(w, err.Error(), 500) |
|
return |
|
} |
|
|
|
builder := strings.Builder{} |
|
if format == "markdown" || format == "html" { |
|
for _, item := range listItems { |
|
lines := strings.Split(item.Text, "\n") |
|
if len(lines) > 1 { |
|
first := true |
|
for _, line := range lines { |
|
if first { |
|
builder.WriteString(strings.Repeat(" ", item.Indented)) |
|
builder.WriteString("* ") |
|
builder.WriteString(line) |
|
builder.WriteByte('\n') |
|
first = false |
|
} else { |
|
builder.WriteString(strings.Repeat(" ", item.Indented+1)) |
|
builder.WriteString(line) |
|
builder.WriteByte('\n') |
|
} |
|
} |
|
} else { |
|
if matches := metaKV.FindStringSubmatch(item.Text); matches != nil { |
|
if matches[1] == "Title" { |
|
title = matches[2] |
|
} |
|
} |
|
builder.WriteString(strings.Repeat(" ", item.Indented)) |
|
builder.WriteString("* ") |
|
builder.WriteString(item.Text) |
|
builder.WriteByte('\n') |
|
} |
|
} |
|
} else if format == "hmarkdown" { |
|
for _, item := range listItems { |
|
builder.WriteString(item.Text) |
|
builder.WriteByte('\n') |
|
builder.WriteByte('\n') |
|
} |
|
} |
|
pageText = builder.String() |
|
} |
|
|
|
if format == "html" { |
|
pageText = metaKV.ReplaceAllString(pageText, "**[[$1]]**: $2") |
|
|
|
pageText = renderLinks(pageText, false) |
|
pageText = renderMarkdown2(pageText) |
|
|
|
curDate, err := ParseDatePageName(page) |
|
if err != nil { |
|
curDate = time.Now() |
|
} |
|
pageBase := getPageBase(mp, curDate) |
|
data := indexPage{ |
|
pageBaseInfo: pageBase, |
|
Session: sess, |
|
Title: title, |
|
Content: template.HTML(pageText), |
|
Name: page, |
|
Backrefs: mpPage.Refs, |
|
ShowGraph: page != "Daily_Notes", |
|
TodayPage: "Today", |
|
} |
|
templates := baseTemplate |
|
templates = append(templates, "templates/view.html") |
|
t, err := template.ParseFiles(templates...) |
|
if err != nil { |
|
http.Error(w, err.Error(), 500) |
|
return |
|
} |
|
w.Header().Set("Content-Type", "text/html; charset=utf-8") |
|
w.Header().Set("Cache-Control", "public, max-age=600") |
|
err = t.Execute(w, data) |
|
if err != nil { |
|
log.Println(err) |
|
// http.Error(w, err.Error(), 500) |
|
return |
|
} |
|
} else if format == "markdown" || format == "hmarkdown" { |
|
w.Header().Set("Content-Type", "text/markdown") |
|
w.Header().Set("Cache-Control", "public, max-age=600") |
|
_, err = io.WriteString(w, "# ") |
|
_, err = io.WriteString(w, title) |
|
_, err = io.WriteString(w, "\n\n") |
|
_, err = io.WriteString(w, pageText) |
|
} |
|
} |
|
|
|
func renderLinks(pageText string, edit bool) string { |
|
hrefRE, err := regexp.Compile(`#?\[\[\s*([^\]]+)\s*\]\]`) |
|
if err != nil { |
|
log.Fatal(err) |
|
} |
|
|
|
pageText = hrefRE.ReplaceAllStringFunc(pageText, func(s string) string { |
|
tag := false |
|
if s[0] == '#' { |
|
s = strings.TrimPrefix(s, "#[[") |
|
tag = true |
|
} else { |
|
s = strings.TrimPrefix(s, "[[") |
|
} |
|
s = strings.TrimSuffix(s, "]]") |
|
s = strings.TrimSpace(s) |
|
if tag { |
|
return fmt.Sprintf(`<a href=%q class="tag">%s</a>`, cleanNameURL(s), s) |
|
} |
|
editPart := "" |
|
if edit { |
|
editPart = "edit/" |
|
} |
|
|
|
return fmt.Sprintf("[%s](/%s%s)", s, editPart, cleanNameURL(s)) |
|
}) |
|
return pageText |
|
} |
|
|
|
func (h *recentHandler) 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 func() { |
|
if err := sess.Flush(); err != nil { |
|
log.Println(err) |
|
} |
|
}() |
|
|
|
if !sess.LoggedIn { |
|
http.Redirect(w, r, "/", http.StatusFound) |
|
return |
|
} |
|
|
|
r.ParseForm() |
|
|
|
changes, err := mp.RecentChanges() |
|
if err != nil { |
|
http.Error(w, err.Error(), 500) |
|
return |
|
} |
|
|
|
// group recentchanges on Page |
|
if len(changes) > 0 { |
|
f := groupRecentChanges(changes) |
|
changes = changes[f:] |
|
} |
|
|
|
for i, c := range changes { |
|
if c.Count <= 1 { |
|
changes[i].DateSummary = c.Date.Format("Mon, 2 Jan 2006") |
|
changes[i].TimeSummary = c.Date.Format("15:04") |
|
} else { |
|
changes[i].DateSummary = c.Date.Format("Mon, 2 Jan 2006") |
|
changes[i].TimeSummary = fmt.Sprintf("%s - %s", c.Date.Format("15:04"), c.EndDate.Format("15:04")) |
|
} |
|
} |
|
|
|
t, err := template.New("layout.html").ParseFiles("templates/layout.html", "templates/recent.html") |
|
if err != nil { |
|
http.Error(w, err.Error(), 500) |
|
return |
|
} |
|
|
|
pageBase := getPageBase(mp, time.Now()) |
|
err = t.Execute(w, recentPage{ |
|
pageBaseInfo: pageBase, |
|
Session: sess, |
|
Title: "Recent changes", |
|
Name: "Recent changes", |
|
Recent: changes, |
|
}) |
|
if err != nil { |
|
http.Error(w, err.Error(), 500) |
|
return |
|
} |
|
} |
|
|
|
type LinkResponseMetaImage struct { |
|
Url string `json:"url"` |
|
} |
|
|
|
type LinkResponseMeta struct { |
|
Title string `json:"title"` |
|
Description string `json:"description"` |
|
Image LinkResponseMetaImage `json:"image"` |
|
} |
|
|
|
type LinkResponse struct { |
|
Success int `json:"success"` |
|
Link string `json:"link"` |
|
Meta LinkResponseMeta `json:"meta"` |
|
} |
|
|
|
func main() { |
|
flag.Parse() |
|
|
|
*baseurl = strings.TrimRight(*baseurl, "/") + "/" |
|
redirectURI = fmt.Sprintf("%sauth/callback", *baseurl) |
|
|
|
dataDir := "data" |
|
searchIndex, err := createSearchIndex(dataDir, "_page-index") |
|
if err != nil { |
|
log.Fatal(err) |
|
} |
|
defer searchIndex.Close() |
|
|
|
sh, err := NewSearchHandler(searchIndex) |
|
if err != nil { |
|
log.Fatal(err) |
|
} |
|
|
|
mp = NewFilePages(dataDir, searchIndex) |
|
|
|
http.Handle("/auth/", &authHandler{}) |
|
http.HandleFunc("/api/block/", wrapAuth(func(w http.ResponseWriter, r *http.Request) { |
|
defer r.Body.Close() |
|
|
|
if !r.Context().Value(authKey).(bool) { |
|
http.Error(w, "Unauthorized", 401) |
|
return |
|
} |
|
|
|
if r.Method != "GET" { |
|
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed) |
|
return |
|
} |
|
|
|
format := r.URL.Query().Get("format") |
|
if format == "" { |
|
format = "json" |
|
} |
|
|
|
if !(format == "json" || format == "metakv") { |
|
http.Error(w, "unknown format", http.StatusBadRequest) |
|
} |
|
|
|
page := r.URL.Path |
|
page = strings.TrimPrefix(page, "/api/block/") |
|
page = resolvePageName(page) |
|
|
|
mpPage := mp.Get(page) |
|
pageText := mpPage.Content |
|
if pageText == "" { |
|
http.NotFound(w, r) |
|
return |
|
} |
|
|
|
if format == "json" { |
|
w.Header().Set("Content-Type", "application/json") |
|
w.Header().Set("Cache-Control", "no-store") |
|
// Shortcut for json output |
|
_, err := io.WriteString(w, pageText) |
|
if err != nil { |
|
http.Error(w, err.Error(), 500) |
|
} |
|
} else if format == "metakv" { |
|
so, err := createStructuredFormat(mpPage) |
|
if err != nil { |
|
http.Error(w, err.Error(), 500) |
|
return |
|
} |
|
w.Header().Set("Content-Type", "application/json") |
|
w.Header().Set("Cache-Control", "no-store") |
|
enc := json.NewEncoder(w) |
|
enc.SetIndent("", " ") |
|
err = enc.Encode(so) |
|
if err != nil { |
|
http.Error(w, err.Error(), 500) |
|
} |
|
} |
|
})) |
|
http.HandleFunc("/api/block/append", wrapAuth(func(w http.ResponseWriter, r *http.Request) { |
|
err := r.ParseForm() |
|
if err != nil { |
|
http.Error(w, err.Error(), 400) |
|
return |
|
} |
|
id := r.Form.Get("id") |
|
if id == "" { |
|
http.Error(w, "missing id", 400) |
|
return |
|
} |
|
|
|
page := mp.Get(id) |
|
log.Println(page.Content) |
|
var listItems []ListItem |
|
id = page.Name // Use the name that was actually loaded |
|
|
|
err = json.NewDecoder(strings.NewReader(page.Content)).Decode(&listItems) |
|
if err != nil && err != io.EOF { |
|
http.Error(w, fmt.Sprintf("while decoding: %s", err.Error()), 500) |
|
return |
|
} |
|
|
|
newId := &ID{"1", true} |
|
generatedID := newId.NewID() |
|
listItems = append(listItems, ListItem{ |
|
ID: generatedID, |
|
Indented: 0, |
|
Text: r.Form.Get("text"), |
|
Fleeting: false, |
|
}) |
|
|
|
var buf bytes.Buffer |
|
|
|
err = json.NewEncoder(&buf).Encode(&listItems) |
|
if err != nil { |
|
http.Error(w, fmt.Sprintf("while encoding: %s", err.Error()), 500) |
|
return |
|
} |
|
|
|
page.Content = buf.String() |
|
page.Name = id |
|
page.Title = id |
|
|
|
err = mp.Save(id, page, "", "") |
|
if err != nil { |
|
http.Error(w, fmt.Sprintf("while saving: %s", err.Error()), 500) |
|
return |
|
} |
|
|
|
fmt.Println(generatedID) |
|
return |
|
})) |
|
http.HandleFunc("/links.json", func(w http.ResponseWriter, r *http.Request) { |
|
w.Header().Set("Content-Type", "application/json") |
|
http.ServeFile(w, r, filepath.Join(dataDir, LinksFile)) |
|
}) |
|
http.HandleFunc("/api/graph", func(w http.ResponseWriter, r *http.Request) { |
|
gb, err := NewGraphBuilder(mp) |
|
if err != nil { |
|
http.Error(w, err.Error(), 500) |
|
return |
|
} |
|
|
|
for _, name := range r.URL.Query()["name"] { |
|
err = gb.buildFromCenter(name) |
|
if err != nil { |
|
http.Error(w, err.Error(), 500) |
|
return |
|
} |
|
} |
|
|
|
// Keep a copy of the nodes, buildFromCenter appends to the nodeMap |
|
var nodes []string |
|
for k, _ := range gb.nodeMap { |
|
nodes = append(nodes, k) |
|
} |
|
|
|
for _, node := range nodes { |
|
err = gb.buildFromCenter(node) |
|
} |
|
|
|
err = gb.prepareGraph() |
|
if err != nil { |
|
http.Error(w, err.Error(), 500) |
|
return |
|
} |
|
|
|
type data struct { |
|
Nodes []Node `json:"nodes"` |
|
Edges []Edge `json:"edges"` |
|
} |
|
|
|
err = json.NewEncoder(w).Encode(&data{gb.nodes, gb.edges}) |
|
if err != nil { |
|
http.Error(w, err.Error(), 500) |
|
return |
|
} |
|
}) |
|
http.HandleFunc("/api/calendar/", func(w http.ResponseWriter, r *http.Request) { |
|
dateString := r.URL.Path[len("/api/calendar/"):] |
|
curDate, err := time.Parse("2006-01-02", dateString) |
|
if err != nil { |
|
curDate = time.Now() |
|
} |
|
pageBase := getPageBase(mp, curDate) |
|
|
|
t, err := template.ParseFiles("templates/calendar.html") |
|
if err != nil { |
|
http.Error(w, err.Error(), 500) |
|
return |
|
} |
|
w.Header().Set("Content-Type", "text/html; charset=utf-8") |
|
w.Header().Set("Cache-Control", "public, max-age=600") |
|
err = t.ExecuteTemplate(w, "calendar", pageBase) |
|
if err != nil { |
|
log.Println(err) |
|
http.Error(w, err.Error(), 500) |
|
return |
|
} |
|
}) |
|
|
|
http.Handle("/search/", sh) |
|
http.Handle("/public/", http.StripPrefix("/public/", http.FileServer(http.Dir("./editor/dist")))) |
|
http.Handle("/save/", &saveHandler{}) |
|
http.Handle("/edit/", &editHandler{}) |
|
http.Handle("/history/", &historyHandler{}) |
|
http.Handle("/recent/", &recentHandler{}) |
|
http.Handle("/graph/", &graphHandler{}) |
|
http.Handle("/", &indexHandler{}) |
|
|
|
fmt.Printf("Running on port %d\n", *port) |
|
log.Fatal(http.ListenAndServe(fmt.Sprintf(":%d", *port), nil)) |
|
} |
|
|
|
func wrapAuth(handler http.HandlerFunc) http.HandlerFunc { |
|
return func(w http.ResponseWriter, r *http.Request) { |
|
auth := r.Header.Get("Authorization") |
|
if auth == "" || (authToken != "" && (auth == "Token "+authToken || auth == "Bearer "+authToken)) { |
|
r = r.WithContext(context.WithValue(r.Context(), authKey, auth != "")) |
|
// auth == "", require cookie in handler |
|
handler.ServeHTTP(w, r) |
|
return |
|
} |
|
|
|
http.Error(w, "Authorization Required", 401) |
|
} |
|
} |
|
|
|
func createSearchIndex(dataDir, indexName string) (bleve.Index, error) { |
|
indexDir := filepath.Join(dataDir, indexName) |
|
if _, err := os.Stat(indexDir); os.IsNotExist(err) { |
|
indexMapping := bleve.NewIndexMapping() |
|
documentMapping := bleve.NewDocumentMapping() |
|
|
|
nameFieldMapping := bleve.NewTextFieldMapping() |
|
nameFieldMapping.Store = true |
|
documentMapping.AddFieldMappingsAt("name", nameFieldMapping) |
|
|
|
titleFieldMapping := bleve.NewTextFieldMapping() |
|
titleFieldMapping.Store = true |
|
documentMapping.AddFieldMappingsAt("title", titleFieldMapping) |
|
|
|
linkFieldMapping := bleve.NewTextFieldMapping() |
|
linkFieldMapping.Store = true |
|
documentMapping.AddFieldMappingsAt("link", linkFieldMapping) |
|
|
|
indexMapping.AddDocumentMapping("block", documentMapping) |
|
|
|
searchIndex, err := bleve.New(indexDir, indexMapping) |
|
if err != nil { |
|
return nil, err |
|
} |
|
|
|
fp := NewFilePages(dataDir, nil) |
|
pages, err := fp.AllPages() |
|
if err != nil { |
|
return nil, err |
|
} |
|
|
|
for _, page := range pages { |
|
searchObjects, err := createSearchObjects(page.Name) |
|
if err != nil { |
|
log.Println(err) |
|
continue |
|
} |
|
|
|
for _, so := range searchObjects { |
|
err = searchIndex.Index(so.ID, so) |
|
if err != nil { |
|
return nil, err |
|
} |
|
} |
|
} |
|
return searchIndex, nil |
|
} else { |
|
searchIndex, err := bleve.Open(indexDir) |
|
if err != nil { |
|
return nil, err |
|
} |
|
return searchIndex, nil |
|
} |
|
} |
|
|
|
func resolvePageName(name string) string { |
|
if name == "" { |
|
return "Home" |
|
} |
|
if name == "Today" { |
|
return todayPage() |
|
} |
|
return name |
|
}
|
|
|