unfinished file upload, sessions/login

This commit is contained in:
osmarks 2021-03-13 22:36:41 +00:00
parent 0331628a1e
commit 6138a9ecb7
15 changed files with 354 additions and 96 deletions

View File

@ -18,5 +18,4 @@ requires "https://github.com/osmarks/nim-cmark-gfm"
#requires "cmark >= 0.1.0"
requires "regex >= 0.18.0"
# seemingly much faster than standard library Levenshtein distance module
requires "nimlevenshtein >= 0.1.0"
#requires "gara >= 0.2.0"
requires "nimlevenshtein >= 0.1.0"

22
package-lock.json generated
View File

@ -1,6 +1,8 @@
{
"requires": true,
"name": "minoteaur-nim",
"version": "1.0.0",
"lockfileVersion": 1,
"requires": true,
"dependencies": {
"anymatch": {
"version": "3.1.1",
@ -45,9 +47,9 @@
"integrity": "sha512-ZEhqxUtEZeGgg9eHNSOAJ8O9xqSgiJdrL0lzSSfMF54x6KXWJiOH/xntSJ9YomJPrYH/p08t6gWjGWq1SDJlSA=="
},
"esbuild": {
"version": "0.8.39",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.8.39.tgz",
"integrity": "sha512-/do5H74a5ChyeKRWfkDh3EpICXpsz6dWTtFFbotb7BlIHvWqnRrZYDb8IBubOHdEtKzuiksilRO19aBtp3/HHQ=="
"version": "0.8.53",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.8.53.tgz",
"integrity": "sha512-GIaYGdMukH58hu+lf07XWAeESBYFAsz8fXnrylHDCbBXKOSNtFmoYA8PhSeSF+3/qzeJ0VjzV9AkLURo5yfu3g=="
},
"fill-range": {
"version": "7.0.1",
@ -58,9 +60,9 @@
}
},
"fsevents": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.1.tgz",
"integrity": "sha512-YR47Eg4hChJGAB1O3yEAOkGO+rlzutoICGqGo9EZ4lKWokzZRSyIW1QmTzqjtw8MJdj9srP869CuWw/hyzSiBw==",
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"optional": true
},
"glob-parent": {
@ -126,9 +128,9 @@
}
},
"sass": {
"version": "1.32.6",
"resolved": "https://registry.npmjs.org/sass/-/sass-1.32.6.tgz",
"integrity": "sha512-1bcDHDcSqeFtMr0JXI3xc/CXX6c4p0wHHivJdru8W7waM7a1WjKMm4m/Z5sY7CbVw4Whi2Chpcw6DFfSWwGLzQ==",
"version": "1.32.8",
"resolved": "https://registry.npmjs.org/sass/-/sass-1.32.8.tgz",
"integrity": "sha512-Sl6mIeGpzjIUZqvKnKETfMf0iDAswD9TNlv13A7aAF3XZlRPMq4VvJWBC2N2DXbp94MQVdNSFG6LfF/iOXrPHQ==",
"requires": {
"chokidar": ">=2.0.0 <4.0.0"
}

11
package.json Normal file
View File

@ -0,0 +1,11 @@
{
"name": "minoteaur-nim",
"version": "1.0.0",
"dependencies": {
"date-fns": "^2.17.0",
"esbuild": "^0.8.53",
"idb": "^6.0.0",
"mithril": "^2.0.4",
"sass": "^1.32.8"
}
}

View File

@ -1,3 +1,6 @@
// Minoteaur clientside code (runs editor enhancements, file upload, search, keyboard shortcuts)
// It may be worth rewriting this in Nim for codesharing, but I think the interaction with e.g. IndexedDB may be trickier
import m from "mithril"
import { openDB } from "idb"
import { lightFormat } from "date-fns"
@ -11,6 +14,14 @@ const dbPromise = openDB("minoteaur", 1, {
// debugging thing
dbPromise.then(x => { window.idb = x })
const debounce = (fn, timeout = 250) => {
let timer;
return (...args) => {
clearTimeout(timer)
timer = setTimeout(fn, timeout, ...args)
}
}
const searchState = {
showingSearchDialog: false,
searchResults: [],
@ -37,6 +48,7 @@ const handleHTTPError = e => {
searchState.searchError = x
}
// handle input in search box
const onsearch = ev => {
const query = ev.target.value
searchState.searchQuery = query
@ -44,7 +56,7 @@ const onsearch = ev => {
url: "/api/search",
params: { q: query }
}).then(x => {
if (typeof x === "string") { // error from server
if (typeof x === "string") { // in case of error from server, show it but keep the existing results shown
console.warn(x)
searchState.searchError = x
} else {
@ -55,10 +67,11 @@ const onsearch = ev => {
}
const currentPage = slugToPage(decodeURIComponent(/^\/([^/]+)/.exec(location.pathname)[1]).replace(/\+/g, " "))
if (currentPage === "Login") { return }
const searchKeyHandler = ev => {
if (ev.code === "Enter") { // enter key
// not very useful to just navigate to the same page
// navigate to the first page (excluding the current one) in results
const otherResults = searchState.searchResults.filter(r => r.page !== currentPage)
if (otherResults[0]) { location.href = urlForPage(otherResults[0].page) }
}
@ -109,14 +122,6 @@ document.body.addEventListener("keydown", e => {
}
})
const debounce = (fn, timeout = 250) => {
let timer;
return () => {
clearTimeout(timer)
timer = setTimeout(fn, timeout)
}
}
const dispDateTime = dt => lightFormat(dt, "yyyy-MM-dd HH:mm:ss")
const wordCount = s => {
@ -128,11 +133,15 @@ const wordCount = s => {
}
const lineCount = s => s.split("\n").length
// edit-page UI
const editor = document.querySelector(".edit-form textarea")
if (editor) {
// most of the state for the edit page UI
// this excludes the text area autoresize logic, as well as the actual editor textarea content
const editorUIState = {
keypresses: 0,
draftSelected: false
draftSelected: false,
pendingFiles: new Map()
}
const mountpoint = document.createElement("div")
document.querySelector(".sidebar").appendChild(mountpoint)
@ -143,9 +152,10 @@ if (editor) {
const resize = () => {
const scrolltop = document.body.scrollTop
const targetHeight = editor.scrollHeight + 2
//console.log(targetHeight, editor.scrollHeight)
if (targetHeight != editor.style.height.slice(0, -2) || lengthWas > editor.value.length) {
editor.style.height = 0
editor.style.height = editor.scrollHeight + 2
editor.style.height = `0px`
editor.style.height = `${Math.max(editor.scrollHeight + 2, 100)}px`
document.body.scrollTop = scrolltop
}
lengthWas = editor.value.length
@ -154,6 +164,8 @@ if (editor) {
// retrieve last edit timestamp from field
const lastEditTime = parseInt(document.querySelector("input[name=last-edit]").value)
const serverValue = editor.value
const associatedFiles = JSON.parse(document.querySelector("input[name=associated-files]").value)
.map(({ filename, mimeType }) => ({ file: { name: filename, type: mimeType, state: "uploaded" }, state: "preexisting" }))
// load in the initially loaded draft
const swapInDraft = () => {
@ -173,9 +185,9 @@ if (editor) {
dbPromise.then(db => db.get("drafts", currentPage)).then(draft => {
editorUIState.initialDraft = draft
console.log("loaded memetic/beemetic entity ", draft)
console.log("found draft ", draft)
// if the draft is newer than the server page, load it in (the user can override this)
if (draft.ts > lastEditTime) {
if (draft && draft.ts > lastEditTime) {
swapInDraft()
}
m.redraw()
@ -188,8 +200,34 @@ if (editor) {
]
}
// Prompt the user to upload file(s)
const uploadFile = () => {
// create a fake <input type=file> and click it, because JavaScript
const input = document.createElement("input")
input.type = "file"
input.multiple = true
input.click()
input.oninput = ev => {
for (const file of ev.target.files) {
editorUIState.pendingFiles.set(file.name, { file, state: "pending" })
window.file = file
}
if (ev.target.files.length > 0) { m.redraw() }
}
}
const File = {
view: ({ attrs }) => m("li.file", [
m("", attrs.file.name),
m("", attrs.state)
])
}
const EditorUIApp = {
view: () => [
m("button.upload", { onclick: uploadFile }, "Upload"),
m("ul.files", associatedFiles.concat(Array.from(editorUIState.pendingFiles.values()))
.map(file => m(File, { ...file, key: file.file.name }))),
m("", `${editorUIState.chars} chars`),
m("", `${editorUIState.words} words`),
m("", `${editorUIState.lines} lines`),
@ -198,6 +236,7 @@ if (editor) {
]
}
// Update the sidebar word/line/char counts
const updateCounts = text => {
editorUIState.words = wordCount(text)
editorUIState.lines = lineCount(text)

View File

@ -13,7 +13,7 @@ import sugar
import unicode
import math
import util
import ./util
from ./md import parsePage
let migrations = @[
@ -72,12 +72,19 @@ CREATE TABLE files (
metadata TEXT NOT NULL,
uploadedTime INTEGER NOT NULL,
UNIQUE (page, filename)
);
""",
"""
CREATE TABLE sessions (
sid INTEGER PRIMARY KEY,
timestamp INTEGER NOT NULL,
data TEXT NOT NULL
);
"""
]
type
Encoding* {.pure} = enum
Encoding* {.pure.} = enum
Plain = 0, Zstd = 1
RevisionType* {.pure.} = enum
NewContent = 0
@ -101,18 +108,20 @@ type
uid*: int64
Backlink* = object
fromPage*, text*, context*: string
var logger = newConsoleLogger()
FileInfo* = object
filename*, mimeType*: string
uploadedTime*: Time
metadata*: JsonNode
proc migrate*(db: DbConn) =
let currentVersion = fromDbValue(get db.value("PRAGMA user_version"), int)
for mid in (currentVersion + 1) .. migrations.len:
db.transaction:
logger.log(lvlInfo, "Migrating to schema " & $mid)
logger().log(lvlInfo, "Migrating to schema " & $mid)
db.execScript migrations[mid - 1]
# for some reason this pragma does not work using normal parameter binding
db.exec("PRAGMA user_version = " & $mid)
logger.log(lvlDebug, "DB ready")
logger().log(lvlDebug, "DB ready")
proc parse*(s: string, T: typedesc): T = fromJson(result, parseJSON(s), Joptions(allowExtraKeys: true, allowMissingKeys: true))
@ -235,4 +244,12 @@ proc processSearchRow(row: ResultRow): SearchResult =
SearchResult(page: page, rank: log10(-rank * 1e7), snippet: snips.filter(x => len(x[1]) > 0))
proc search*(db: DbConn, query: string): seq[SearchResult] =
db.all("SELECT page, rank, snippet(pages_fts, 1, '<hlstart>', '<hlend>', ' ... ', 32) FROM pages_fts WHERE pages_fts MATCH ? AND rank MATCH 'bm25(5.0, 1.0)' ORDER BY rank", query).map(processSearchRow)
db.all("SELECT page, rank, snippet(pages_fts, 1, '<hlstart>', '<hlend>', ' ... ', 32) FROM pages_fts WHERE pages_fts MATCH ? AND rank MATCH 'bm25(5.0, 1.0)' ORDER BY rank", query).map(processSearchRow)
proc getBasicFileInfo*(db: DbConn, page, filename: string): Option[(string, string)] =
db.one("SELECT storagePath, mimeType FROM files WHERE page = ? AND filename = ?", page, filename).map(proc (r: ResultRow): (string, string) = r.unpack((string, string)))
proc getPageFiles*(db: DbConn, page: string): seq[FileInfo] =
db.all("SELECT filename, mimeType, uploadedTime, metadata FROM files WHERE page = ?", page).map(proc (r: ResultRow): FileInfo =
let (filename, mime, upload, meta) = r.unpack((string, string, Time, string))
FileInfo(filename: filename, mimetype: mime, uploadedTime: upload, metadata: parse(meta, JsonNode)))

6
src/generate_password_hash.py Executable file
View File

@ -0,0 +1,6 @@
#!/usr/bin/env python3
import getpass
import argon2
print(argon2.hash_password(getpass.getpass().encode("utf-8")).decode("utf-8"))

View File

@ -171,7 +171,7 @@ proc linkContext(str: string, startPos: int, endPos: int, lookaround: int): stri
earlierToks[max(earlierToks.len - bdlook + laterToks.len, 0)..^1].join(" ") & linkText & laterToks.join(" ")
# mirrored version of previous case
elif earlierToks.len < lookaround and laterToks.len >= lookaround:
earlierToks.join(" ") & linkText & laterToks[0..<(bdlook - earlierToks.len)].join(" ")
earlierToks.join(" ") & linkText & laterToks[0..<min(bdlook - earlierToks.len, laterToks.len)].join(" ")
# both too short, use all of both
else: earlierToks.join(" ") & linkText & laterToks.join(" ")

View File

@ -1,7 +1,6 @@
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
@ -9,64 +8,60 @@ import times
import sugar
import std/jsonutils
import strutils
import logging
from ./domain import nil
from ./md import nil
import util
import ./util
import ./sqlitesession
let
env = loadPrologueEnv(".env")
settings = newSettings(
appName = "minoteaur",
debug = env.getOrDefault("debug", true),
port = Port(env.getOrDefault("port", 7600)),
secretKey = env.getOrDefault("secretKey", "")
)
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
func navButton(content: string, href: string, class: string): VNode = buildHtml(a(class="link-button " & class, href=href)): text content
func searchButton(): VNode = buildHtml(a(class="link-button search", href="")): text "Search"
func base(title: string, navItems: seq[VNode], bodyItems: VNode, sidebar: Option[VNode] = none(VNode)): string =
let sidebarClass = if sidebar.isSome: "has-sidebar" else: ""
let vnode = buildHtml(html):
head:
link(rel="stylesheet", href="/static/style.css")
script(src="/static/client.js", `defer`="true")
meta(charset="utf8")
script(src="/static/client.js", `defer`="defer")
meta(charset="utf-8")
meta(name="viewport", content="width=device-width,initial-scale=1.0")
title: text title
body:
main(class=sidebarClass):
nav:
a(class="link-button search", href=""): text "Search"
for n in navItems: n
tdiv(class="header"):
h1: text title
bodyItems
if sidebar.isSome: tdiv(class="sidebar"): get sidebar
$vnode
"<!DOCTYPE html>" & $vnode
proc openDBConnection(): DbConn =
logger().log(lvlInfo, "Opening database connection")
let conn = openDatabase(dbPath)
conn.exec("PRAGMA foreign_keys = ON")
return conn
autoInitializedThreadvar(db, DbConn, openDBConnection())
block:
let db = openDatabase("./minoteaur.sqlite3")
let db = openDatabase(dbPath)
domain.migrate(db)
close(db)
type
AppContext = ref object of Context
db: DbConn
# store thread's DB connection
var db {.threadvar.}: Option[DbConn]
close(db())
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
result = proc(ctx: AppContext) {.async.} =
ctx.db = db()
await switch(ctx)
proc headersMiddleware(): HandlerAsync =
@ -78,6 +73,17 @@ proc headersMiddleware(): HandlerAsync =
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 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)
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)
@ -90,11 +96,14 @@ proc edit(ctx: AppContext) {.async.} =
# 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")):
textarea(name="content"): text pageData.map(p => p.content).get("")
# pass inputs to JS-side editor code as hidden input fields
# TODO: this is somewhat horrible, do another thing
input(`type`="hidden", value=pageData.map(p => timestampToStr(p.updated)).get("0"), name="last-edit")
input(`type`="hidden", value= $toJson(domain.getPageFiles(ctx.db, page)), name="associated-files")
let sidebar = buildHtml(tdiv):
input(`type`="submit", value="Save", name="action", class="save", form="edit-form")
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, some(sidebar))
resp base(verb & page, @[searchButton(), pageButton(ctx, "view-page", page, "View"), pageButton(ctx, "page-revisions", page, "Revisions")], html, some(sidebar))
proc revisions(ctx: AppContext) {.async.} =
let page = slugToPage(decodeUrl(ctx.getPathParams("page")))
@ -114,19 +123,34 @@ proc revisions(ctx: AppContext) {.async.} =
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)
resp base("Revisions of " & page, @[searchButton(), 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)
# 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)
proc sendAttachedFile(ctx: AppContext) {.async.} =
let page = slugToPage(decodeUrl(ctx.getPathParams("page")))
echo "orbital bee strike → you"
resp "TODO"
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()
proc view(ctx: AppContext) {.async.} =
try:
ctx.session["counter"] = $(parseInt(ctx.session["counter"]) + 1)
except:
ctx.session["counter"] = "2"
let page = slugToPage(decodeUrl(ctx.getPathParams("page")))
let rawRevision = ctx.getQueryParams("ts")
let viewSource = ctx.getQueryParams("source") != ""
@ -159,7 +183,7 @@ proc view(ctx: AppContext) {.async.} =
tdiv: a(class="wikilink", href=pageUrlFor(ctx, "view-page", backlink.fromPage)): text backlink.fromPage
tdiv: text backlink.context
resp base(page, @[pageButton(ctx, "edit-page", page, "Edit"), pageButton(ctx, "page-revisions", page, "Revisions")], html)
resp base(page, @[searchButton(), pageButton(ctx, "edit-page", page, "Edit"), pageButton(ctx, "page-revisions", page, "Revisions")], html)
else:
# old revision
@ -171,7 +195,7 @@ proc view(ctx: AppContext) {.async.} =
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")]
var buttons = @[searchButton(), 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 }))
@ -187,13 +211,54 @@ proc search(ctx: AppContext) {.async.} =
return
resp jsonResponse toJson(results)
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)
proc favicon(ctx: Context) {.async.} = resp error404()
proc index(ctx: Context) {.async.} = resp "bee(s)"
proc index(ctx: Context) {.async.} = resp "TODO"
var app = newApp(settings = settings)
app.use(@[staticFileMiddleware("static"), sessionMiddleware(settings), extendContextMiddleware(AppContext), dbMiddleware(), headersMiddleware()])
app.use(@[staticFileMiddleware("static"), extendContextMiddleware(AppContext), dbMiddleware(), sessionMiddleware(settings, db), requireLoginMiddleware(), headersMiddleware()])
app.get("/", index)
app.get("/favicon.ico", favicon)
app.get("/login", loginPage, name="login-page")
app.post("/login", handleLogin, name="handle-login")
app.get("/api/search", search, name="search")
app.get("/{page}/edit", edit, name="edit-page")
app.get("/{page}/revisions", revisions, name="page-revisions")

77
src/sqlitesession.nim Normal file
View File

@ -0,0 +1,77 @@
# based on https://github.com/planety/prologue/blob/devel/src/prologue/middlewares/sessions/redissession.nim
# SQLite session storage adapter
import std/[options, strtabs, asyncdispatch]
from prologue/core/types import len, Session, newSession, loads, dumps
from prologue/core/context import Context, HandlerAsync, getCookie, setCookie, deleteCookie
from prologue/core/response import addHeader
from prologue/core/middlewaresbase import switch
from prologue/core/nativesettings import Settings
from cookiejar import SameSite
from strutils import parseBiggestInt
import tiny_sqlite
import times
import ./util
proc sessionMiddleware*(
settings: Settings,
getDB: (proc(): DbConn),
sessionName = "session",
maxAge: int = 14 * 24 * 60 * 60, # 14 days, in seconds
domain = "",
sameSite = Lax,
httpOnly = false
): HandlerAsync =
return proc(ctx: Context) {.async.} =
# TODO: this may be invoking dark gods slightly (fix?)
# the proc only accesses the DB threadvar, but it itself is GC memory
{.gcsafe.}:
let db = getDB()
var sessionIDString = ctx.getCookie(sessionName)
# no session ID results in sessionIDString being empty, which will also result in a parse failure
# this will cause a new session ID to be generated
var sessionID: int64 = -1
try:
sessionID = int64(parseBiggestInt sessionIDString)
except ValueError: discard
var sessionTS = getTime()
if sessionID != -1:
# fetch session from database
let info = db.one("SELECT * FROM sessions WHERE sid = ?", sessionID)
if info.isSome:
let (sid, ts, data) = info.get().unpack((int64, Time, string))
sessionTS = ts
ctx.session = newSession(data = newStringTable(modeCaseSensitive))
ctx.session.loads(data)
else:
ctx.session = newSession(data = newStringTable(modeCaseSensitive))
else:
ctx.session = newSession(data = newStringTable(modeCaseSensitive))
sessionID = snowflake()
ctx.setCookie(sessionName, $sessionID, maxAge = some(maxAge), domain = domain, sameSite = sameSite, httpOnly = httpOnly)
await switch(ctx)
if ctx.session.len == 0: # empty or modified (del or clear)
if ctx.session.modified: # modified
db.exec("DELETE FROM sessions WHERE sid = ?", sessionID)
ctx.deleteCookie(sessionName, domain = domain) # delete session data in cookie
return
if ctx.session.accessed:
ctx.response.addHeader("Vary", "Cookie")
if ctx.session.modified:
let serializedSessionData = ctx.session.dumps()
db.exec("INSERT OR REPLACE INTO sessions VALUES (?, ?, ?)", sessionID, sessionTS, serializedSessionData)
# garbage collect old sessions
# TODO: consider checking elsewhere, as not doing so leads to a bit of an exploit where
# old session IDs can be used for a while
let oldSessionThreshold = getTime() + initDuration(seconds = -maxAge)
db.exec("DELETE FROM sessions WHERE timestamp < ?", oldSessionThreshold)

View File

@ -7,8 +7,7 @@ html
body
font-family: "Fira Sans", "Noto Sans", "Segoe UI", Verdana, sans-serif
font-weight: 300
margin: 0 0 1em 0
//min-height: 100vh
margin: 0 0 0 0
display: flex
flex-wrap: wrap
justify-content: space-around
@ -98,6 +97,7 @@ a.wikilink
.link-button, button, input[type=submit]
border: none
border-radius: 0
padding: 0.75em
background: gray
text-align: center
@ -123,11 +123,16 @@ a.wikilink
background-color: #bc13fe
&.search
background-color: #fac205
&.upload
background-color: #49759c
&.login
background-color: #02ab2e
input[type=search], input[type=text]
input[type=search], input[type=text], input[type=password]
border: 1px solid gray
padding: 0.75em
width: 100%
border-radius: 0
.edit-form
textarea
@ -148,4 +153,7 @@ input[type=search], input[type=text]
.flex-space
display: flex
justify-content: space-between
justify-content: space-between
.login
display: flex

View File

@ -7,6 +7,11 @@ import random
import math
import times
import options
import json
from os import `/`, existsOrCreateDir
import md5
import prologue
import logging
func lowercaseFirstLetter(s: string): string =
if len(s) == 0:
@ -22,6 +27,7 @@ func slugToPage*(slug: string): string = slug.split({'_', ' '}).map(capitalize).
func timeToTimestamp*(t: Time): int64 = toUnix(t) * 1000 + (nanosecond(t) div 1000000)
func timestampToTime*(ts: int64): Time = initTime(ts div 1000, (ts mod 1000) * 1000000)
func timestampToStr*(t: Time): string = intToStr(int(timeToTimestamp(t)))
proc toJsonHook(t: Time): JsonNode = newJInt(timeToTimestamp(t))
# store time as milliseconds
proc toDbValue*(t: Time): DbValue = DbValue(kind: sqliteInteger, intVal: timeToTimestamp(t))
@ -29,7 +35,7 @@ proc fromDbValue*(value: DbValue, T: typedesc[Time]): Time = timestampToTime(val
template autoInitializedThreadvar*(name: untyped, typ: typedesc, initialize: typed): untyped =
var data* {.threadvar.}: Option[typ]
proc `name`(): typ =
proc `name`*(): typ =
if isSome(data): result = get data
else:
result = initialize
@ -53,4 +59,32 @@ proc snowflake*(): int64 =
var ts = int64((now - SIMPLEFLAKE_EPOCH) * 1000)
let randomBits = int64(rng.rand(2 ^ SIMPLEFLAKE_RANDOM_LENGTH))
return ts shl SIMPLEFLAKE_RANDOM_LENGTH or randomBits
return ts shl SIMPLEFLAKE_RANDOM_LENGTH or randomBits
# remove any unsafe characters from a filename, by only allowing bytes [a-zA-Z._ -]
proc normalizeFilename(s: string): string =
for byte in s:
if byte in {' ', 'a'..'z', 'A'..'Z', '-', '_', '.'}:
result.add(byte)
else:
result.add('_')
proc makeFilePath*(basepath, page, filename: string): string =
# putting tons of things into one directory may cause issues, so "shard" it into 256 subdirs deterministically
let pageHash = getMD5(page)
let hashdir = pageHash[0..1]
# it is possible that for some horrible reason someone could make two files/pages which normalize to the same thing
# but are nevertheless different files
# thus, put the hash of the ORIGINAL file/pagename before the normalized version
let pagedir = pageHash[2..31] & "-" & normalizeFilename(page)
let filenameHash = getMD5(filename)
discard existsOrCreateDir(basepath / hashdir)
discard existsOrCreateDir(basepath / hashdir / pagedir)
# saved file path should not include the basedir for file storage, as this may be moved around/reconfigured
hashdir / pagedir / (filenameHash & "-" & normalizeFilename(filename))
autoInitializedThreadvar(logger, ConsoleLogger, newConsoleLogger())
type
AppContext* = ref object of Context
db*: DbConn

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1 +1 @@
html{scrollbar-color:#000 #d3d3d3}*{box-sizing:border-box}body{font-family:"Fira Sans","Noto Sans","Segoe UI",Verdana,sans-serif;font-weight:300;margin:0 0 1em 0;display:flex;flex-wrap:wrap;justify-content:space-around}main{max-width:50em;width:100%;padding:0 1em 0 1em;margin-left:auto;margin-right:auto}main.has-sidebar{margin-right:0}.sidebar{padding:1em 1em 1em 1em;margin-right:auto;min-width:16em}strong{font-weight:600}h1,h2,h3,h4,h5,h6{margin:0 0 .5em 0;font-weight:500}h1:first-of-type,h2:first-of-type,h3:first-of-type,h4:first-of-type,h5:first-of-type,h6:first-of-type{border-bottom:1px solid gray}h1 a,h2 a,h3 a,h4 a,h5 a,h6 a{color:inherit}:not(pre)>code{background:#000;color:#fff;padding:.3em}ul,ol{padding-left:1em}ul{list-style-type:square}blockquote{border-left:.3em solid #000;padding-left:.3em}table{border-collapse:collapse}table th{background:#000;color:#fff;font-weight:normal}table td,table th{padding:.2em .5em}table td{border:1px solid gray}.rev-table{width:100%}.rev-table td{border:none}.rev-table td.ts{white-space:nowrap}.md{margin-top:.5em}.md>*,.md p{margin:0 0 .5em 0}nav{margin-bottom:.5em}.error{color:red}img{max-width:100%}.timestamp{color:gray}.selected{font-style:italic}a.wikilink{text-decoration:none;color:#0165fc;font-style:italic}a.wikilink:hover{text-decoration:underline}.link-button,button,input[type=submit]{border:none;padding:.75em;background:gray;text-align:center;text-decoration:none;color:#000;display:inline-block;font-size:1rem}.link-button:hover,button:hover,input[type=submit]:hover{background-image:linear-gradient(rgba(0, 0, 0, 0.2), rgba(0, 0, 0, 0.2))}.link-button.view-page,button.view-page,input[type=submit].view-page{background-color:#76cd26}.link-button.edit-page,button.edit-page,input[type=submit].edit-page{background-color:#75bbfd}.link-button.page-revisions,button.page-revisions,input[type=submit].page-revisions{background-color:#f97306}.link-button.save,button.save,input[type=submit].save{background-color:#06c2ac}.link-button.next-page,button.next-page,input[type=submit].next-page{background-color:#5170d7}.link-button.prev-page,button.prev-page,input[type=submit].prev-page{background-color:#bc13fe}.link-button.search,button.search,input[type=submit].search{background-color:#fac205}input[type=search],input[type=text]{border:1px solid gray;padding:.75em;width:100%}.edit-form{margin-bottom:0}.edit-form textarea{resize:vertical;width:100%;height:70vh;border:1px solid gray}.highlight{background-color:#ff0}.dialog{width:100%;background:#fff;padding:1em;border:1px solid gray}.flex-space{display:flex;justify-content:space-between}/*# sourceMappingURL=style.css.map */
html{scrollbar-color:#000 #d3d3d3}*{box-sizing:border-box}body{font-family:"Fira Sans","Noto Sans","Segoe UI",Verdana,sans-serif;font-weight:300;margin:0 0 0 0;display:flex;flex-wrap:wrap;justify-content:space-around}main{max-width:50em;width:100%;padding:0 1em 0 1em;margin-left:auto;margin-right:auto}main.has-sidebar{margin-right:0}.sidebar{padding:1em 1em 1em 1em;margin-right:auto;min-width:16em}strong{font-weight:600}h1,h2,h3,h4,h5,h6{margin:0 0 .5em 0;font-weight:500}h1:first-of-type,h2:first-of-type,h3:first-of-type,h4:first-of-type,h5:first-of-type,h6:first-of-type{border-bottom:1px solid gray}h1 a,h2 a,h3 a,h4 a,h5 a,h6 a{color:inherit}:not(pre)>code{background:#000;color:#fff;padding:.3em}ul,ol{padding-left:1em}ul{list-style-type:square}blockquote{border-left:.3em solid #000;padding-left:.3em}table{border-collapse:collapse}table th{background:#000;color:#fff;font-weight:normal}table td,table th{padding:.2em .5em}table td{border:1px solid gray}.rev-table{width:100%}.rev-table td{border:none}.rev-table td.ts{white-space:nowrap}.md{margin-top:.5em}.md>*,.md p{margin:0 0 .5em 0}nav{margin-bottom:.5em}.error{color:red}img{max-width:100%}.timestamp{color:gray}.selected{font-style:italic}a.wikilink{text-decoration:none;color:#0165fc;font-style:italic}a.wikilink:hover{text-decoration:underline}.link-button,button,input[type=submit]{border:none;border-radius:0;padding:.75em;background:gray;text-align:center;text-decoration:none;color:#000;display:inline-block;font-size:1rem}.link-button:hover,button:hover,input[type=submit]:hover{background-image:linear-gradient(rgba(0, 0, 0, 0.2), rgba(0, 0, 0, 0.2))}.link-button.view-page,button.view-page,input[type=submit].view-page{background-color:#76cd26}.link-button.edit-page,button.edit-page,input[type=submit].edit-page{background-color:#75bbfd}.link-button.page-revisions,button.page-revisions,input[type=submit].page-revisions{background-color:#f97306}.link-button.save,button.save,input[type=submit].save{background-color:#06c2ac}.link-button.next-page,button.next-page,input[type=submit].next-page{background-color:#5170d7}.link-button.prev-page,button.prev-page,input[type=submit].prev-page{background-color:#bc13fe}.link-button.search,button.search,input[type=submit].search{background-color:#fac205}.link-button.upload,button.upload,input[type=submit].upload{background-color:#49759c}.link-button.login,button.login,input[type=submit].login{background-color:#02ab2e}input[type=search],input[type=text],input[type=password]{border:1px solid gray;padding:.75em;width:100%;border-radius:0}.edit-form{margin-bottom:0}.edit-form textarea{resize:vertical;width:100%;height:70vh;border:1px solid gray}.highlight{background-color:#ff0}.dialog{width:100%;background:#fff;padding:1em;border:1px solid gray}.flex-space{display:flex;justify-content:space-between}.login{display:flex}/*# sourceMappingURL=style.css.map */

View File

@ -1 +1 @@
{"version":3,"sourceRoot":"","sources":["../src/style.sass"],"names":[],"mappings":"AAAA,KACI,6BAEJ,EACI,sBAEJ,KACI,kEACA,gBACA,iBAEA,aACA,eACA,6BAEJ,KACI,eACA,WACA,oBACA,iBACA,kBACA,iBACI,eAER,SACI,wBACA,kBACA,eAEJ,OACI,gBAEJ,kBAGI,kBACA,gBAHA,sGACI,6BAGJ,8BACI,cAER,eACI,gBACA,WACA,aAEJ,MACI,iBACJ,GACI,uBACJ,WACI,4BACA,kBACJ,MACI,yBAEA,SACI,gBACA,WACA,mBACJ,kBACI,kBACJ,SACI,sBAER,WACI,WACA,cACI,YACA,iBACI,mBAEZ,IACI,gBACA,YACI,kBAER,IACI,mBAEJ,OACI,UAEJ,IACI,eAEJ,WACI,WAEJ,UACI,kBAEJ,WACI,qBACA,cACA,kBACA,iBACI,0BAER,uCACI,YACA,cACA,gBACA,kBACA,qBACA,WACA,qBACA,eACA,yDAEI,yEAEJ,qEACI,yBACJ,qEACI,yBACJ,oFACI,yBACJ,sDACI,yBACJ,qEACI,yBACJ,qEACI,yBACJ,4DACI,yBAER,oCACI,sBACA,cACA,WAEJ,WAMI,gBALA,oBACI,gBACA,WACA,YACA,sBAGR,WACI,sBAEJ,QACI,WACA,gBACA,YACA,sBAEJ,YACI,aACA","file":"style.css"}
{"version":3,"sourceRoot":"","sources":["../src/style.sass"],"names":[],"mappings":"AAAA,KACI,6BAEJ,EACI,sBAEJ,KACI,kEACA,gBACA,eACA,aACA,eACA,6BAEJ,KACI,eACA,WACA,oBACA,iBACA,kBACA,iBACI,eAER,SACI,wBACA,kBACA,eAEJ,OACI,gBAEJ,kBAGI,kBACA,gBAHA,sGACI,6BAGJ,8BACI,cAER,eACI,gBACA,WACA,aAEJ,MACI,iBACJ,GACI,uBACJ,WACI,4BACA,kBACJ,MACI,yBAEA,SACI,gBACA,WACA,mBACJ,kBACI,kBACJ,SACI,sBAER,WACI,WACA,cACI,YACA,iBACI,mBAEZ,IACI,gBACA,YACI,kBAER,IACI,mBAEJ,OACI,UAEJ,IACI,eAEJ,WACI,WAEJ,UACI,kBAEJ,WACI,qBACA,cACA,kBACA,iBACI,0BAER,uCACI,YACA,gBACA,cACA,gBACA,kBACA,qBACA,WACA,qBACA,eACA,yDAEI,yEAEJ,qEACI,yBACJ,qEACI,yBACJ,oFACI,yBACJ,sDACI,yBACJ,qEACI,yBACJ,qEACI,yBACJ,4DACI,yBACJ,4DACI,yBACJ,yDACI,yBAER,yDACI,sBACA,cACA,WACA,gBAEJ,WAMI,gBALA,oBACI,gBACA,WACA,YACA,sBAGR,WACI,sBAEJ,QACI,WACA,gBACA,YACA,sBAEJ,YACI,aACA,8BAEJ,OACI","file":"style.css"}