2021-01-06 16:09:48 +00:00
import prologue
import prologue / middlewares / staticfile
import karax / [ karaxdsl , vdom ]
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-03-13 22:36:41 +00:00
import logging
2021-01-06 16:09:48 +00:00
from . / domain import nil
from . / md import nil
2021-03-13 22:36:41 +00:00
import . / util
import . / sqlitesession
2021-01-06 16:09:48 +00:00
2021-03-13 22:36:41 +00:00
let env = loadPrologueEnv ( " .env " )
let settings = newSettings (
appName = " minoteaur " ,
debug = env . getOrDefault ( " debug " , true ) ,
port = Port ( env . getOrDefault ( " port " , 7600 ) ) ,
secretKey = env . get ( " secretKey " )
)
const dbPath = " ./minoteaur.sqlite3 " # TODO: work out gcsafety issues in making this runtime-configurable
2021-01-06 16:09:48 +00:00
func navButton ( content : string , href : string , class : string ) : VNode = buildHtml ( a ( class = " link-button " & class , href = href ) ) : text content
2021-03-13 22:36:41 +00:00
func searchButton ( ) : VNode = buildHtml ( a ( class = " link-button search " , href = " " ) ) : text " Search "
2021-01-06 16:09:48 +00:00
2021-02-17 23:37:42 +00:00
func base ( title : string , navItems : seq [ VNode ] , bodyItems : VNode , sidebar : Option [ VNode ] = none ( VNode ) ) : string =
let sidebarClass = if sidebar . isSome : " has-sidebar " else : " "
2021-01-06 16:09:48 +00:00
let vnode = buildHtml ( html ) :
head :
link ( rel = " stylesheet " , href = " /static/style.css " )
2021-03-13 22:36:41 +00:00
script ( src = " /static/client.js " , ` defer ` = " defer " )
meta ( charset = " utf-8 " )
2021-01-06 16:09:48 +00:00
meta ( name = " viewport " , content = " width=device-width,initial-scale=1.0 " )
title : text title
body :
2021-02-17 23:37:42 +00:00
main ( class = sidebarClass ) :
2021-01-06 16:09:48 +00:00
nav :
for n in navItems : n
tdiv ( class = " header " ) :
h1 : text title
bodyItems
2021-02-17 23:37:42 +00:00
if sidebar . isSome : tdiv ( class = " sidebar " ) : get sidebar
2021-03-13 22:36:41 +00:00
" <!DOCTYPE html> " & $ vnode
2021-01-06 16:09:48 +00:00
2021-03-13 22:36:41 +00:00
proc openDBConnection ( ) : DbConn =
logger ( ) . log ( lvlInfo , " Opening database connection " )
let conn = openDatabase ( dbPath )
conn . exec ( " PRAGMA foreign_keys = ON " )
return conn
2021-01-06 16:09:48 +00:00
2021-03-13 22:36:41 +00:00
autoInitializedThreadvar ( db , DbConn , openDBConnection ( ) )
2021-01-06 16:09:48 +00:00
2021-03-13 22:36:41 +00:00
block :
let db = openDatabase ( dbPath )
domain . migrate ( db )
close ( db ( ) )
2021-01-06 16:09:48 +00:00
proc dbMiddleware ( ) : HandlerAsync =
2021-03-13 22:36:41 +00:00
result = proc ( ctx : AppContext ) {. async . } =
ctx . db = db ( )
2021-01-06 16:09:48 +00:00
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-03-13 22:36:41 +00:00
proc requireLoginMiddleware ( ) : HandlerAsync =
result = proc ( ctx : AppContext ) {. async . } =
let loginURL = ctx . urlFor ( " login-page " )
let authed = ctx . session . getOrDefault ( " authed " , " f " )
let path = ctx . request . path
if authed = = " t " or path = = loginURL or path . startsWith ( " /static " ) :
await switch ( ctx )
else :
let loginRedirectURL = ctx . urlFor ( " login-page " , queryParams = { " redirect " : path } )
resp redirect ( loginRedirectURL , Http303 )
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 )
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 )
2021-02-17 23:37:42 +00:00
let html =
# autocomplete=off disables some sort of session history caching mechanism which interferes with draft handling
buildHtml ( form ( ` method ` = " post " , class = " edit-form " , id = " edit-form " , autocomplete = " off " ) ) :
2021-02-16 14:26:01 +00:00
textarea ( name = " content " ) : text pageData . map ( p = > p . content ) . get ( " " )
2021-03-13 22:36:41 +00:00
# pass inputs to JS-side editor code as hidden input fields
# TODO: this is somewhat horrible, do another thing
2021-02-17 23:37:42 +00:00
input ( ` type ` = " hidden " , value = pageData . map ( p = > timestampToStr ( p . updated ) ) . get ( " 0 " ) , name = " last-edit " )
2021-03-13 22:36:41 +00:00
input ( ` type ` = " hidden " , value = $ toJson ( domain . getPageFiles ( ctx . db , page ) ) , name = " associated-files " )
2021-02-17 23:37:42 +00:00
let sidebar = buildHtml ( tdiv ) :
input ( ` type ` = " submit " , value = " Save " , name = " action " , class = " save " , form = " edit-form " )
2021-01-06 16:09:48 +00:00
let verb = if pageData . isSome : " Editing " else : " Creating "
2021-03-13 22:36:41 +00:00
resp base ( verb & page , @ [ searchButton ( ) , pageButton ( ctx , " view-page " , page , " View " ) , pageButton ( ctx , " page-revisions " , page , " Revisions " ) ] , html , some ( sidebar ) )
2021-01-06 16:09:48 +00:00
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 ( " " )
2021-03-13 22:36:41 +00:00
resp base ( " Revisions of " & page , @ [ searchButton ( ) , pageButton ( ctx , " view-page " , page , " View " ) , pageButton ( ctx , " edit-page " , page , " Edit " ) ] , html )
2021-01-06 16:09:48 +00:00
proc handleEdit ( ctx : AppContext ) {. async . } =
2021-02-16 14:26:01 +00:00
let page = slugToPage ( decodeUrl ( ctx . getPathParams ( " page " ) ) )
2021-03-13 22:36:41 +00:00
# file upload instead of content change
if " file " in ctx . request . formParams . data :
let file = ctx . request . formParams [ " file " ]
echo $ file
await ctx . respond ( Http204 , " " )
else :
domain . updatePage ( ctx . db , page , ctx . getFormParams ( " content " ) )
resp redirect ( pageUrlFor ( ctx , " view-page " , page ) , Http303 )
2021-01-06 16:09:48 +00:00
2021-02-17 23:37:42 +00:00
proc sendAttachedFile ( ctx : AppContext ) {. async . } =
let page = slugToPage ( decodeUrl ( ctx . getPathParams ( " page " ) ) )
2021-03-13 22:36:41 +00:00
let filename = decodeUrl ( ctx . getPathParams ( " filename " ) )
let filedata = domain . getBasicFileInfo ( ctx . db , page , filename )
if filedata . isSome :
let ( path , mime ) = get filedata
await ctx . staticFileResponse ( path , " " , mimetype = mime )
else :
resp error404 ( )
2021-02-17 23:37:42 +00:00
2021-01-06 16:09:48 +00:00
proc view ( ctx : AppContext ) {. async . } =
2021-03-13 22:36:41 +00:00
try :
ctx . session [ " counter " ] = $ ( parseInt ( ctx . session [ " counter " ] ) + 1 )
except :
ctx . session [ " counter " ] = " 2 "
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 :
2021-02-17 23:37:42 +00:00
# current revision
let backlinks = domain . backlinks ( ctx . db , page )
2021-01-06 16:09:48 +00:00
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
2021-02-17 23:37:42 +00:00
if backlinks . len > 0 :
h2 : text " Backlinks "
ul ( class = " backlinks " ) :
for backlink in backlinks :
li :
tdiv : a ( class = " wikilink " , href = pageUrlFor ( ctx , " view-page " , backlink . fromPage ) ) : text backlink . fromPage
tdiv : text backlink . context
2021-03-13 22:36:41 +00:00
resp base ( page , @ [ searchButton ( ) , pageButton ( ctx , " edit-page " , page , " Edit " ) , pageButton ( ctx , " page-revisions " , page , " Revisions " ) ] , html )
2021-02-17 23:37:42 +00:00
2021-01-06 16:09:48 +00:00
else :
2021-02-17 23:37:42 +00:00
# old revision
2021-01-06 16:09:48 +00:00
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
2021-03-13 22:36:41 +00:00
var buttons = @ [ searchButton ( ) , 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-02-17 23:37:42 +00:00
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 )
2021-03-13 22:36:41 +00:00
proc loginPage ( ctx : AppContext ) {. async . } =
let options = @ [ " I forgot my password " , " I remembered it, but then bees stole it " , " I am actively unable to remember anything " , " I know it, but can ' t enter it " , " I know it, but won ' t enter it " ,
" I know it, but am not alive/existent enough to enter it " , " I remembered my password " , " I forgot my password " , " My password was retroactively erased/altered " , " All of the above " ,
" My password contains anomalous Unicode I cannot type " , " I forgot my keyboard " , " My password is unrepresentable within Unicode " , " My password is unrepresentable within reality " ,
" I am not actually the intended user and I don ' t know the password " , " I ' m bored and clicking on random options " , " Contingency λ-8288 is to be initiated " , " One of the above, but username instead " ,
" The box is too small " , " The box is too big " , " There ' s not even a username option " , " I cannot type (in general) " , " I want backdoor access " , " I forgot to forget my password " ,
" I forgot my username " , " I remembered a password, but the wrong one " , " My password cannot safely be entered " , " My password ' s length exceeds available memory " ,
" I don ' t know the password but can get it if I can log in now " , " I ' m bored and clicking on nondeterministic options " , " I dislike these options " , " I like these options " ,
" I remembered to forget my password " , " I don ' t like my password " , " My password cannot be reused due to linear types " , " I would like to forget my password but I am incapable of doing so " ,
" My password is sapient and refuses to be typed " , " My password anomalously causes refusal of authentication " , " I cannot legally provide my password " , " I lack required insurance " ,
" I am aware of all information in the universe except my password " , " I am unable to read " , " My password is infohazardous " , " My password might be infohazardous " , " I forgot your password " ,
" My password anomalously refuses changes " , " My password cannot be trusted " , " My password forgot me " , " My password has been garbage-collected " , " I don ' t trust the site with my password " ,
" My identity was forcefully separated from my password " , " My issue defies characterization " , " I cannot send HTTP POST requests " , " My password contains my password " ,
" I am legally required to enter my password but engaging in rebellion " , " My password is the nontrivial zeros of the Riemann zeta function " , " My password is the string of bytes required to crash this webserver " ,
" My password is my password only when preceded by my password " , " Someone is watching me enter my password " , " I am legally required to click this option " , " My password takes infinite time to evaluate " ,
" I neither remembered nor forgot my password " , " I forgot the concept of passwords " , " I reject the concept of passwords " ]
let html = buildHtml ( tdiv ) :
form ( ` method ` = " post " , class = " login " ) :
input ( ` type ` = " password " , placeholder = " Password " )
input ( ` type ` = " submit " , class = " login " , value = " Login " )
h2 : text " Extra login options "
ul :
for option in options :
li : a ( href = " " ) : text option
resp base ( " Login " , @ [ ] , html )
proc handleLogin ( ctx : AppContext ) {. async . } =
let success = true
# TODO: This does allow off-site redirects. Fix this.
# Also TODO: rate limiting
if success :
logger ( ) . log ( lvlInfo , " Successful login " )
ctx . session [ " authed " ] = " t "
resp redirect ( ctx . request . queryParams . getOrDefault ( " redirect " , " / " ) , Http303 )
else :
logger ( ) . log ( lvlInfo , " Unsuccessful login " )
resp redirect ( ctx . urlFor ( " login-page " ) , Http303 )
2021-02-16 14:26:01 +00:00
proc favicon ( ctx : Context ) {. async . } = resp error404 ( )
2021-03-13 22:36:41 +00:00
proc index ( ctx : Context ) {. async . } = resp " TODO "
2021-01-06 16:09:48 +00:00
var app = newApp ( settings = settings )
2021-03-13 22:36:41 +00:00
app . use ( @ [ staticFileMiddleware ( " static " ) , extendContextMiddleware ( AppContext ) , dbMiddleware ( ) , sessionMiddleware ( settings , db ) , requireLoginMiddleware ( ) , headersMiddleware ( ) ] )
2021-01-06 16:09:48 +00:00
app . get ( " / " , index )
app . get ( " /favicon.ico " , favicon )
2021-03-13 22:36:41 +00:00
app . get ( " /login " , loginPage , name = " login-page " )
app . post ( " /login " , handleLogin , name = " handle-login " )
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 " )
2021-02-17 23:37:42 +00:00
app . get ( " /{page}/file/{filename} " , sendAttachedFile , name = " send-attached-file " )
2021-01-06 16:09:48 +00:00
app . get ( " /{page}/ " , view , name = " view-page " )
app . run ( )