183 lines
8.1 KiB
Nim
183 lines
8.1 KiB
Nim
import prologue
|
|
import prologue/middlewares/staticfile
|
|
import karax/[karaxdsl, vdom]
|
|
import prologue/middlewares/sessions/signedcookiesession
|
|
from uri import decodeUrl, encodeUrl
|
|
import tiny_sqlite
|
|
import options
|
|
import times
|
|
import sugar
|
|
import std/jsonutils
|
|
import strutils
|
|
import prologue/middlewares/csrf
|
|
|
|
from ./domain import nil
|
|
from ./md import nil
|
|
import util
|
|
|
|
let
|
|
env = loadPrologueEnv(".env")
|
|
settings = newSettings(
|
|
appName = "minoteaur",
|
|
debug = env.getOrDefault("debug", true),
|
|
port = Port(env.getOrDefault("port", 7600)),
|
|
secretKey = env.getOrDefault("secretKey", "")
|
|
)
|
|
|
|
func navButton(content: string, href: string, class: string): VNode = buildHtml(a(class="link-button " & class, href=href)): text content
|
|
|
|
func base(title: string, navItems: seq[VNode], bodyItems: VNode): string =
|
|
let vnode = buildHtml(html):
|
|
head:
|
|
link(rel="stylesheet", href="/static/style.css")
|
|
script(src="/static/client.js", `defer`="true")
|
|
meta(charset="utf8")
|
|
meta(name="viewport", content="width=device-width,initial-scale=1.0")
|
|
title: text title
|
|
body:
|
|
main:
|
|
nav:
|
|
a(class="link-button search", href=""): text "Search"
|
|
for n in navItems: n
|
|
tdiv(class="header"):
|
|
h1: text title
|
|
bodyItems
|
|
$vnode
|
|
|
|
block:
|
|
let db = openDatabase("./minoteaur.sqlite3")
|
|
domain.migrate(db)
|
|
close(db)
|
|
|
|
type
|
|
AppContext = ref object of Context
|
|
db: DbConn
|
|
|
|
# store thread's DB connection
|
|
var db {.threadvar.}: Option[DbConn]
|
|
|
|
proc dbMiddleware(): HandlerAsync =
|
|
# horrible accursed hack to make exitproc work
|
|
result = proc(ctx: AppContext) {.async.} =
|
|
# open new DB connection for thread if there isn't one
|
|
if db.isNone:
|
|
echo "Opening database connection"
|
|
var conn = openDatabase("./minoteaur.sqlite3")
|
|
conn.exec("PRAGMA foreign_keys = ON")
|
|
db = some conn
|
|
ctx.db = get db
|
|
await switch(ctx)
|
|
|
|
proc headersMiddleware(): HandlerAsync =
|
|
result = proc(ctx: AppContext) {.async.} =
|
|
await switch(ctx)
|
|
ctx.response.setHeader("X-Content-Type-Options", "nosniff")
|
|
# user-controlled inline JS/CSS is explicitly turned on
|
|
# this does partly defeat the point of a CSP, but this is still able to prevent connecting to other sites unwantedly
|
|
ctx.response.setHeader("Content-Security-Policy", "default-src 'self' 'unsafe-inline'; img-src * data:; media-src * data:; form-action 'self'; frame-ancestors 'self'")
|
|
ctx.response.setHeader("Referrer-Policy", "origin-when-cross-origin")
|
|
|
|
proc displayTime(t: Time): string = t.format("uuuu-MM-dd HH:mm:ss", utc())
|
|
|
|
func pageUrlFor(ctx: AppContext, route: string, page: string, query: openArray[(string, string)] = @[]): string = ctx.urlFor(route, { "page": encodeUrl(pageToSlug(page)) }, query)
|
|
func pageButton(ctx: AppContext, route: string, page: string, label: string, query: openArray[(string, string)] = @[]): VNode = navButton(label, pageUrlFor(ctx, route, page, query), route)
|
|
|
|
proc formCsrfToken(ctx: AppContext): VNode = verbatim csrfToken(ctx)
|
|
|
|
proc edit(ctx: AppContext) {.async.} =
|
|
let page = slugToPage(decodeUrl(ctx.getPathParams("page")))
|
|
let pageData = domain.fetchPage(ctx.db, page)
|
|
let html =
|
|
buildHtml(form(`method`="post", class="edit-form")):
|
|
input(`type`="submit", value="Save", name="action", class="save")
|
|
textarea(name="content"): text pageData.map(p => p.content).get("")
|
|
formCsrfToken(ctx)
|
|
let verb = if pageData.isSome: "Editing " else: "Creating "
|
|
resp base(verb & page, @[pageButton(ctx, "view-page", page, "View"), pageButton(ctx, "page-revisions", page, "Revisions")], html)
|
|
|
|
proc revisions(ctx: AppContext) {.async.} =
|
|
let page = slugToPage(decodeUrl(ctx.getPathParams("page")))
|
|
let revs = domain.fetchRevisions(ctx.db, page)
|
|
let html =
|
|
buildHtml(table(class="rev-table")):
|
|
tr:
|
|
th: text "Time"
|
|
th: text "Changes"
|
|
th: text "Size"
|
|
th: text "Words"
|
|
for rev in revs:
|
|
tr:
|
|
td(class="ts"):
|
|
a(href=ctx.urlFor("view-page", { "page": pageToSlug(encodeUrl(page)) }, { "ts": timestampToStr(rev.time) })):
|
|
text displayTime(rev.time)
|
|
td: text rev.meta.editDistance.map(x => $x).get("")
|
|
td: text rev.meta.size.map(x => formatSize(x)).get("")
|
|
td: text rev.meta.words.map(x => $x).get("")
|
|
resp base("Revisions of " & page, @[pageButton(ctx, "view-page", page, "View"), pageButton(ctx, "edit-page", page, "Edit")], html)
|
|
|
|
proc handleEdit(ctx: AppContext) {.async.} =
|
|
let page = slugToPage(decodeUrl(ctx.getPathParams("page")))
|
|
domain.updatePage(ctx.db, page, ctx.getFormParams("content"))
|
|
resp redirect(pageUrlFor(ctx, "view-page", page), Http303)
|
|
|
|
proc view(ctx: AppContext) {.async.} =
|
|
let page = slugToPage(decodeUrl(ctx.getPathParams("page")))
|
|
let rawRevision = ctx.getQueryParams("ts")
|
|
let viewSource = ctx.getQueryParams("source") != ""
|
|
let revisionTs = if rawRevision == "": none(Time) else: some timestampToTime(parseInt rawRevision)
|
|
let viewingOldRevision = revisionTs.isSome
|
|
|
|
let pageData = if viewingOldRevision: domain.fetchPage(ctx.db, page, get revisionTs) else: domain.fetchPage(ctx.db, page)
|
|
if pageData.isNone:
|
|
resp redirect(pageUrlFor(ctx, "edit-page", page), Http302)
|
|
else:
|
|
let pageData = get pageData
|
|
let mainBody = if viewSource: buildHtml(pre): text pageData.content else: verbatim md.renderToHtml(pageData.content)
|
|
if revisionTs.isNone:
|
|
let html =
|
|
buildHtml(tdiv):
|
|
tdiv(class="timestamp"):
|
|
text "Updated "
|
|
text displayTime(pageData.updated)
|
|
tdiv(class="timestamp"):
|
|
text "Created "
|
|
text displayTime(pageData.created)
|
|
tdiv(class="md"): mainBody
|
|
resp base(page, @[pageButton(ctx, "edit-page", page, "Edit"), pageButton(ctx, "page-revisions", page, "Revisions")], html)
|
|
else:
|
|
let rts = get revisionTs
|
|
let (next, prev) = domain.adjacentRevisions(ctx.db, page, rts)
|
|
let html =
|
|
buildHtml(tdiv):
|
|
tdiv(class="timestamp"):
|
|
text "As of "
|
|
text displayTime(rts)
|
|
tdiv(class="md"): mainBody
|
|
var buttons = @[pageButton(ctx, "edit-page", page, "Edit"), pageButton(ctx, "page-revisions", page, "Revisions"), pageButton(ctx, "view-page", page, "Latest")]
|
|
if next.isSome: buttons.add(pageButton(ctx, "next-page", page, "Next", { "ts": timestampToStr (get next).time }))
|
|
if prev.isSome: buttons.add(pageButton(ctx, "prev-page", page, "Previous", { "ts": timestampToStr (get prev).time }))
|
|
resp base(page, buttons, html)
|
|
|
|
proc search(ctx: AppContext) {.async.} =
|
|
let query = ctx.getQueryParams("q")
|
|
var results: seq[domain.SearchResult] = @[]
|
|
try:
|
|
if query != "": results = domain.search(ctx.db, query)
|
|
except SqliteError as e: # SQLite apparently treats FTS queries containing some things outside of quotes as syntax errors. These should probably be shown to the user.
|
|
resp jsonResponse toJson($e.msg)
|
|
return
|
|
resp jsonResponse toJson(results)
|
|
|
|
proc favicon(ctx: Context) {.async.} = resp error404()
|
|
proc index(ctx: Context) {.async.} = resp "bee(s)"
|
|
|
|
var app = newApp(settings = settings)
|
|
app.use(@[staticFileMiddleware("static"), sessionMiddleware(settings), extendContextMiddleware(AppContext), dbMiddleware(), headersMiddleware(), csrfMiddleware()])
|
|
app.get("/", index)
|
|
app.get("/favicon.ico", favicon)
|
|
app.get("/api/search", search, name="search")
|
|
app.get("/{page}/edit", edit, name="edit-page")
|
|
app.get("/{page}/revisions", revisions, name="page-revisions")
|
|
app.post("/{page}/edit", handleEdit, name="handle-edit")
|
|
app.get("/{page}/", view, name="view-page")
|
|
app.run() |