2021-01-06 16:09:48 +00:00
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
2021-02-16 14:26:01 +00:00
import std / jsonutils
2021-01-06 16:09:48 +00:00
import strutils
2021-02-16 14:26:01 +00:00
import prologue / middlewares / csrf
2021-01-06 16:09:48 +00:00
from . / domain import nil
from . / md import nil
2021-02-16 14:26:01 +00:00
import util
2021-01-06 16:09:48 +00:00
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 " )
2021-02-16 14:26:01 +00:00
script ( src = " /static/client.js " , ` defer ` = " true " )
2021-01-06 16:09:48 +00:00
meta ( charset = " utf8 " )
meta ( name = " viewport " , content = " width=device-width,initial-scale=1.0 " )
title : text title
body :
main :
nav :
2021-02-16 14:26:01 +00:00
a ( class = " link-button search " , href = " " ) : text " Search "
2021-01-06 16:09:48 +00:00
for n in navItems : n
tdiv ( class = " header " ) :
h1 : text title
bodyItems
$ vnode
2021-02-16 14:26:01 +00:00
block :
let db = openDatabase ( " ./minoteaur.sqlite3 " )
domain . migrate ( db )
close ( db )
2021-01-06 16:09:48 +00:00
type
AppContext = ref object of Context
db : DbConn
# store thread's DB connection
var db {. threadvar . } : Option [ DbConn ]
proc dbMiddleware ( ) : HandlerAsync =
2021-02-16 14:26:01 +00:00
# horrible accursed hack to make exitproc work
2021-01-06 16:09:48 +00:00
result = proc ( ctx : AppContext ) {. async . } =
# open new DB connection for thread if there isn't one
if db . isNone :
2021-02-16 14:26:01 +00:00
echo " Opening database connection "
var conn = openDatabase ( " ./minoteaur.sqlite3 " )
conn . exec ( " PRAGMA foreign_keys = ON " )
db = some conn
2021-01-06 16:09:48 +00:00
ctx . db = get db
await switch ( ctx )
2021-02-16 14:26:01 +00:00
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 " )
2021-01-06 16:09:48 +00:00
proc displayTime ( t : Time ) : string = t . format ( " uuuu-MM-dd HH:mm:ss " , utc ( ) )
2021-02-16 14:26:01 +00:00
func pageUrlFor ( ctx : AppContext , route : string , page : string , query : openArray [ ( string , string ) ] = @ [ ] ) : string = ctx . urlFor ( route , { " page " : encodeUrl ( pageToSlug ( page ) ) } , query )
2021-01-06 16:09:48 +00:00
func pageButton ( ctx : AppContext , route : string , page : string , label : string , query : openArray [ ( string , string ) ] = @ [ ] ) : VNode = navButton ( label , pageUrlFor ( ctx , route , page , query ) , route )
2021-02-16 14:26:01 +00:00
proc formCsrfToken ( ctx : AppContext ) : VNode = verbatim csrfToken ( ctx )
2021-01-06 16:09:48 +00:00
proc edit ( ctx : AppContext ) {. async . } =
2021-02-16 14:26:01 +00:00
let page = slugToPage ( decodeUrl ( ctx . getPathParams ( " page " ) ) )
2021-01-06 16:09:48 +00:00
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 " )
2021-02-16 14:26:01 +00:00
textarea ( name = " content " ) : text pageData . map ( p = > p . content ) . get ( " " )
formCsrfToken ( ctx )
2021-01-06 16:09:48 +00:00
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 . } =
2021-02-16 14:26:01 +00:00
let page = slugToPage ( decodeUrl ( ctx . getPathParams ( " page " ) ) )
2021-01-06 16:09:48 +00:00
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 " ) :
2021-02-16 14:26:01 +00:00
a ( href = ctx . urlFor ( " view-page " , { " page " : pageToSlug ( encodeUrl ( page ) ) } , { " ts " : timestampToStr ( rev . time ) } ) ) :
2021-01-06 16:09:48 +00:00
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 . } =
2021-02-16 14:26:01 +00:00
let page = slugToPage ( decodeUrl ( ctx . getPathParams ( " page " ) ) )
2021-01-06 16:09:48 +00:00
domain . updatePage ( ctx . db , page , ctx . getFormParams ( " content " ) )
2021-02-16 14:26:01 +00:00
resp redirect ( pageUrlFor ( ctx , " view-page " , page ) , Http303 )
2021-01-06 16:09:48 +00:00
proc view ( ctx : AppContext ) {. async . } =
2021-02-16 14:26:01 +00:00
let page = slugToPage ( decodeUrl ( ctx . getPathParams ( " page " ) ) )
2021-01-06 16:09:48 +00:00
let rawRevision = ctx . getQueryParams ( " ts " )
let viewSource = ctx . getQueryParams ( " source " ) ! = " "
2021-02-16 14:26:01 +00:00
let revisionTs = if rawRevision = = " " : none ( Time ) else : some timestampToTime ( parseInt rawRevision )
2021-01-06 16:09:48 +00:00
let viewingOldRevision = revisionTs . isSome
2021-02-16 14:26:01 +00:00
let pageData = if viewingOldRevision : domain . fetchPage ( ctx . db , page , get revisionTs ) else : domain . fetchPage ( ctx . db , page )
2021-01-06 16:09:48 +00:00
if pageData . isNone :
2021-02-16 14:26:01 +00:00
resp redirect ( pageUrlFor ( ctx , " edit-page " , page ) , Http302 )
2021-01-06 16:09:48 +00:00
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 " ) ]
2021-02-16 14:26:01 +00:00
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 } ) )
2021-01-06 16:09:48 +00:00
resp base ( page , buttons , html )
2021-02-16 14:26:01 +00:00
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 ( )
2021-01-06 16:09:48 +00:00
proc index ( ctx : Context ) {. async . } = resp " bee(s) "
var app = newApp ( settings = settings )
2021-02-16 14:26:01 +00:00
app . use ( @ [ staticFileMiddleware ( " static " ) , sessionMiddleware ( settings ) , extendContextMiddleware ( AppContext ) , dbMiddleware ( ) , headersMiddleware ( ) , csrfMiddleware ( ) ] )
2021-01-06 16:09:48 +00:00
app . get ( " / " , index )
app . get ( " /favicon.ico " , favicon )
2021-02-16 14:26:01 +00:00
app . get ( " /api/search " , search , name = " search " )
2021-01-06 16:09:48 +00:00
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 ( )