unfinished file upload, sessions/login
This commit is contained in:
parent
0331628a1e
commit
6138a9ecb7
@ -18,5 +18,4 @@ requires "https://github.com/osmarks/nim-cmark-gfm"
|
|||||||
#requires "cmark >= 0.1.0"
|
#requires "cmark >= 0.1.0"
|
||||||
requires "regex >= 0.18.0"
|
requires "regex >= 0.18.0"
|
||||||
# seemingly much faster than standard library Levenshtein distance module
|
# seemingly much faster than standard library Levenshtein distance module
|
||||||
requires "nimlevenshtein >= 0.1.0"
|
requires "nimlevenshtein >= 0.1.0"
|
||||||
#requires "gara >= 0.2.0"
|
|
22
package-lock.json
generated
22
package-lock.json
generated
@ -1,6 +1,8 @@
|
|||||||
{
|
{
|
||||||
"requires": true,
|
"name": "minoteaur-nim",
|
||||||
|
"version": "1.0.0",
|
||||||
"lockfileVersion": 1,
|
"lockfileVersion": 1,
|
||||||
|
"requires": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"anymatch": {
|
"anymatch": {
|
||||||
"version": "3.1.1",
|
"version": "3.1.1",
|
||||||
@ -45,9 +47,9 @@
|
|||||||
"integrity": "sha512-ZEhqxUtEZeGgg9eHNSOAJ8O9xqSgiJdrL0lzSSfMF54x6KXWJiOH/xntSJ9YomJPrYH/p08t6gWjGWq1SDJlSA=="
|
"integrity": "sha512-ZEhqxUtEZeGgg9eHNSOAJ8O9xqSgiJdrL0lzSSfMF54x6KXWJiOH/xntSJ9YomJPrYH/p08t6gWjGWq1SDJlSA=="
|
||||||
},
|
},
|
||||||
"esbuild": {
|
"esbuild": {
|
||||||
"version": "0.8.39",
|
"version": "0.8.53",
|
||||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.8.39.tgz",
|
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.8.53.tgz",
|
||||||
"integrity": "sha512-/do5H74a5ChyeKRWfkDh3EpICXpsz6dWTtFFbotb7BlIHvWqnRrZYDb8IBubOHdEtKzuiksilRO19aBtp3/HHQ=="
|
"integrity": "sha512-GIaYGdMukH58hu+lf07XWAeESBYFAsz8fXnrylHDCbBXKOSNtFmoYA8PhSeSF+3/qzeJ0VjzV9AkLURo5yfu3g=="
|
||||||
},
|
},
|
||||||
"fill-range": {
|
"fill-range": {
|
||||||
"version": "7.0.1",
|
"version": "7.0.1",
|
||||||
@ -58,9 +60,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"fsevents": {
|
"fsevents": {
|
||||||
"version": "2.3.1",
|
"version": "2.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||||
"integrity": "sha512-YR47Eg4hChJGAB1O3yEAOkGO+rlzutoICGqGo9EZ4lKWokzZRSyIW1QmTzqjtw8MJdj9srP869CuWw/hyzSiBw==",
|
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||||
"optional": true
|
"optional": true
|
||||||
},
|
},
|
||||||
"glob-parent": {
|
"glob-parent": {
|
||||||
@ -126,9 +128,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"sass": {
|
"sass": {
|
||||||
"version": "1.32.6",
|
"version": "1.32.8",
|
||||||
"resolved": "https://registry.npmjs.org/sass/-/sass-1.32.6.tgz",
|
"resolved": "https://registry.npmjs.org/sass/-/sass-1.32.8.tgz",
|
||||||
"integrity": "sha512-1bcDHDcSqeFtMr0JXI3xc/CXX6c4p0wHHivJdru8W7waM7a1WjKMm4m/Z5sY7CbVw4Whi2Chpcw6DFfSWwGLzQ==",
|
"integrity": "sha512-Sl6mIeGpzjIUZqvKnKETfMf0iDAswD9TNlv13A7aAF3XZlRPMq4VvJWBC2N2DXbp94MQVdNSFG6LfF/iOXrPHQ==",
|
||||||
"requires": {
|
"requires": {
|
||||||
"chokidar": ">=2.0.0 <4.0.0"
|
"chokidar": ">=2.0.0 <4.0.0"
|
||||||
}
|
}
|
||||||
|
11
package.json
Normal file
11
package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
@ -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 m from "mithril"
|
||||||
import { openDB } from "idb"
|
import { openDB } from "idb"
|
||||||
import { lightFormat } from "date-fns"
|
import { lightFormat } from "date-fns"
|
||||||
@ -11,6 +14,14 @@ const dbPromise = openDB("minoteaur", 1, {
|
|||||||
// debugging thing
|
// debugging thing
|
||||||
dbPromise.then(x => { window.idb = x })
|
dbPromise.then(x => { window.idb = x })
|
||||||
|
|
||||||
|
const debounce = (fn, timeout = 250) => {
|
||||||
|
let timer;
|
||||||
|
return (...args) => {
|
||||||
|
clearTimeout(timer)
|
||||||
|
timer = setTimeout(fn, timeout, ...args)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const searchState = {
|
const searchState = {
|
||||||
showingSearchDialog: false,
|
showingSearchDialog: false,
|
||||||
searchResults: [],
|
searchResults: [],
|
||||||
@ -37,6 +48,7 @@ const handleHTTPError = e => {
|
|||||||
searchState.searchError = x
|
searchState.searchError = x
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handle input in search box
|
||||||
const onsearch = ev => {
|
const onsearch = ev => {
|
||||||
const query = ev.target.value
|
const query = ev.target.value
|
||||||
searchState.searchQuery = query
|
searchState.searchQuery = query
|
||||||
@ -44,7 +56,7 @@ const onsearch = ev => {
|
|||||||
url: "/api/search",
|
url: "/api/search",
|
||||||
params: { q: query }
|
params: { q: query }
|
||||||
}).then(x => {
|
}).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)
|
console.warn(x)
|
||||||
searchState.searchError = x
|
searchState.searchError = x
|
||||||
} else {
|
} else {
|
||||||
@ -55,10 +67,11 @@ const onsearch = ev => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const currentPage = slugToPage(decodeURIComponent(/^\/([^/]+)/.exec(location.pathname)[1]).replace(/\+/g, " "))
|
const currentPage = slugToPage(decodeURIComponent(/^\/([^/]+)/.exec(location.pathname)[1]).replace(/\+/g, " "))
|
||||||
|
if (currentPage === "Login") { return }
|
||||||
|
|
||||||
const searchKeyHandler = ev => {
|
const searchKeyHandler = ev => {
|
||||||
if (ev.code === "Enter") { // enter key
|
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)
|
const otherResults = searchState.searchResults.filter(r => r.page !== currentPage)
|
||||||
if (otherResults[0]) { location.href = urlForPage(otherResults[0].page) }
|
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 dispDateTime = dt => lightFormat(dt, "yyyy-MM-dd HH:mm:ss")
|
||||||
|
|
||||||
const wordCount = s => {
|
const wordCount = s => {
|
||||||
@ -128,11 +133,15 @@ const wordCount = s => {
|
|||||||
}
|
}
|
||||||
const lineCount = s => s.split("\n").length
|
const lineCount = s => s.split("\n").length
|
||||||
|
|
||||||
|
// edit-page UI
|
||||||
const editor = document.querySelector(".edit-form textarea")
|
const editor = document.querySelector(".edit-form textarea")
|
||||||
if (editor) {
|
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 = {
|
const editorUIState = {
|
||||||
keypresses: 0,
|
keypresses: 0,
|
||||||
draftSelected: false
|
draftSelected: false,
|
||||||
|
pendingFiles: new Map()
|
||||||
}
|
}
|
||||||
const mountpoint = document.createElement("div")
|
const mountpoint = document.createElement("div")
|
||||||
document.querySelector(".sidebar").appendChild(mountpoint)
|
document.querySelector(".sidebar").appendChild(mountpoint)
|
||||||
@ -143,9 +152,10 @@ if (editor) {
|
|||||||
const resize = () => {
|
const resize = () => {
|
||||||
const scrolltop = document.body.scrollTop
|
const scrolltop = document.body.scrollTop
|
||||||
const targetHeight = editor.scrollHeight + 2
|
const targetHeight = editor.scrollHeight + 2
|
||||||
|
//console.log(targetHeight, editor.scrollHeight)
|
||||||
if (targetHeight != editor.style.height.slice(0, -2) || lengthWas > editor.value.length) {
|
if (targetHeight != editor.style.height.slice(0, -2) || lengthWas > editor.value.length) {
|
||||||
editor.style.height = 0
|
editor.style.height = `0px`
|
||||||
editor.style.height = editor.scrollHeight + 2
|
editor.style.height = `${Math.max(editor.scrollHeight + 2, 100)}px`
|
||||||
document.body.scrollTop = scrolltop
|
document.body.scrollTop = scrolltop
|
||||||
}
|
}
|
||||||
lengthWas = editor.value.length
|
lengthWas = editor.value.length
|
||||||
@ -154,6 +164,8 @@ if (editor) {
|
|||||||
// retrieve last edit timestamp from field
|
// retrieve last edit timestamp from field
|
||||||
const lastEditTime = parseInt(document.querySelector("input[name=last-edit]").value)
|
const lastEditTime = parseInt(document.querySelector("input[name=last-edit]").value)
|
||||||
const serverValue = editor.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
|
// load in the initially loaded draft
|
||||||
const swapInDraft = () => {
|
const swapInDraft = () => {
|
||||||
@ -173,9 +185,9 @@ if (editor) {
|
|||||||
|
|
||||||
dbPromise.then(db => db.get("drafts", currentPage)).then(draft => {
|
dbPromise.then(db => db.get("drafts", currentPage)).then(draft => {
|
||||||
editorUIState.initialDraft = 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 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()
|
swapInDraft()
|
||||||
}
|
}
|
||||||
m.redraw()
|
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 = {
|
const EditorUIApp = {
|
||||||
view: () => [
|
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.chars} chars`),
|
||||||
m("", `${editorUIState.words} words`),
|
m("", `${editorUIState.words} words`),
|
||||||
m("", `${editorUIState.lines} lines`),
|
m("", `${editorUIState.lines} lines`),
|
||||||
@ -198,6 +236,7 @@ if (editor) {
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Update the sidebar word/line/char counts
|
||||||
const updateCounts = text => {
|
const updateCounts = text => {
|
||||||
editorUIState.words = wordCount(text)
|
editorUIState.words = wordCount(text)
|
||||||
editorUIState.lines = lineCount(text)
|
editorUIState.lines = lineCount(text)
|
||||||
|
@ -13,7 +13,7 @@ import sugar
|
|||||||
import unicode
|
import unicode
|
||||||
import math
|
import math
|
||||||
|
|
||||||
import util
|
import ./util
|
||||||
from ./md import parsePage
|
from ./md import parsePage
|
||||||
|
|
||||||
let migrations = @[
|
let migrations = @[
|
||||||
@ -72,12 +72,19 @@ CREATE TABLE files (
|
|||||||
metadata TEXT NOT NULL,
|
metadata TEXT NOT NULL,
|
||||||
uploadedTime INTEGER NOT NULL,
|
uploadedTime INTEGER NOT NULL,
|
||||||
UNIQUE (page, filename)
|
UNIQUE (page, filename)
|
||||||
|
);
|
||||||
|
""",
|
||||||
|
"""
|
||||||
|
CREATE TABLE sessions (
|
||||||
|
sid INTEGER PRIMARY KEY,
|
||||||
|
timestamp INTEGER NOT NULL,
|
||||||
|
data TEXT NOT NULL
|
||||||
);
|
);
|
||||||
"""
|
"""
|
||||||
]
|
]
|
||||||
|
|
||||||
type
|
type
|
||||||
Encoding* {.pure} = enum
|
Encoding* {.pure.} = enum
|
||||||
Plain = 0, Zstd = 1
|
Plain = 0, Zstd = 1
|
||||||
RevisionType* {.pure.} = enum
|
RevisionType* {.pure.} = enum
|
||||||
NewContent = 0
|
NewContent = 0
|
||||||
@ -101,18 +108,20 @@ type
|
|||||||
uid*: int64
|
uid*: int64
|
||||||
Backlink* = object
|
Backlink* = object
|
||||||
fromPage*, text*, context*: string
|
fromPage*, text*, context*: string
|
||||||
|
FileInfo* = object
|
||||||
var logger = newConsoleLogger()
|
filename*, mimeType*: string
|
||||||
|
uploadedTime*: Time
|
||||||
|
metadata*: JsonNode
|
||||||
|
|
||||||
proc migrate*(db: DbConn) =
|
proc migrate*(db: DbConn) =
|
||||||
let currentVersion = fromDbValue(get db.value("PRAGMA user_version"), int)
|
let currentVersion = fromDbValue(get db.value("PRAGMA user_version"), int)
|
||||||
for mid in (currentVersion + 1) .. migrations.len:
|
for mid in (currentVersion + 1) .. migrations.len:
|
||||||
db.transaction:
|
db.transaction:
|
||||||
logger.log(lvlInfo, "Migrating to schema " & $mid)
|
logger().log(lvlInfo, "Migrating to schema " & $mid)
|
||||||
db.execScript migrations[mid - 1]
|
db.execScript migrations[mid - 1]
|
||||||
# for some reason this pragma does not work using normal parameter binding
|
# for some reason this pragma does not work using normal parameter binding
|
||||||
db.exec("PRAGMA user_version = " & $mid)
|
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))
|
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))
|
SearchResult(page: page, rank: log10(-rank * 1e7), snippet: snips.filter(x => len(x[1]) > 0))
|
||||||
|
|
||||||
proc search*(db: DbConn, query: string): seq[SearchResult] =
|
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
6
src/generate_password_hash.py
Executable 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"))
|
@ -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(" ")
|
earlierToks[max(earlierToks.len - bdlook + laterToks.len, 0)..^1].join(" ") & linkText & laterToks.join(" ")
|
||||||
# mirrored version of previous case
|
# mirrored version of previous case
|
||||||
elif earlierToks.len < lookaround and laterToks.len >= lookaround:
|
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
|
# both too short, use all of both
|
||||||
else: earlierToks.join(" ") & linkText & laterToks.join(" ")
|
else: earlierToks.join(" ") & linkText & laterToks.join(" ")
|
||||||
|
|
||||||
|
@ -1,7 +1,6 @@
|
|||||||
import prologue
|
import prologue
|
||||||
import prologue/middlewares/staticfile
|
import prologue/middlewares/staticfile
|
||||||
import karax/[karaxdsl, vdom]
|
import karax/[karaxdsl, vdom]
|
||||||
import prologue/middlewares/sessions/signedcookiesession
|
|
||||||
from uri import decodeUrl, encodeUrl
|
from uri import decodeUrl, encodeUrl
|
||||||
import tiny_sqlite
|
import tiny_sqlite
|
||||||
import options
|
import options
|
||||||
@ -9,64 +8,60 @@ import times
|
|||||||
import sugar
|
import sugar
|
||||||
import std/jsonutils
|
import std/jsonutils
|
||||||
import strutils
|
import strutils
|
||||||
|
import logging
|
||||||
|
|
||||||
from ./domain import nil
|
from ./domain import nil
|
||||||
from ./md import nil
|
from ./md import nil
|
||||||
import util
|
import ./util
|
||||||
|
import ./sqlitesession
|
||||||
|
|
||||||
let
|
let env = loadPrologueEnv(".env")
|
||||||
env = loadPrologueEnv(".env")
|
let settings = newSettings(
|
||||||
settings = newSettings(
|
appName = "minoteaur",
|
||||||
appName = "minoteaur",
|
debug = env.getOrDefault("debug", true),
|
||||||
debug = env.getOrDefault("debug", true),
|
port = Port(env.getOrDefault("port", 7600)),
|
||||||
port = Port(env.getOrDefault("port", 7600)),
|
secretKey = env.get("secretKey")
|
||||||
secretKey = env.getOrDefault("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 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 =
|
func base(title: string, navItems: seq[VNode], bodyItems: VNode, sidebar: Option[VNode] = none(VNode)): string =
|
||||||
let sidebarClass = if sidebar.isSome: "has-sidebar" else: ""
|
let sidebarClass = if sidebar.isSome: "has-sidebar" else: ""
|
||||||
let vnode = buildHtml(html):
|
let vnode = buildHtml(html):
|
||||||
head:
|
head:
|
||||||
link(rel="stylesheet", href="/static/style.css")
|
link(rel="stylesheet", href="/static/style.css")
|
||||||
script(src="/static/client.js", `defer`="true")
|
script(src="/static/client.js", `defer`="defer")
|
||||||
meta(charset="utf8")
|
meta(charset="utf-8")
|
||||||
meta(name="viewport", content="width=device-width,initial-scale=1.0")
|
meta(name="viewport", content="width=device-width,initial-scale=1.0")
|
||||||
title: text title
|
title: text title
|
||||||
body:
|
body:
|
||||||
main(class=sidebarClass):
|
main(class=sidebarClass):
|
||||||
nav:
|
nav:
|
||||||
a(class="link-button search", href=""): text "Search"
|
|
||||||
for n in navItems: n
|
for n in navItems: n
|
||||||
tdiv(class="header"):
|
tdiv(class="header"):
|
||||||
h1: text title
|
h1: text title
|
||||||
bodyItems
|
bodyItems
|
||||||
if sidebar.isSome: tdiv(class="sidebar"): get sidebar
|
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:
|
block:
|
||||||
let db = openDatabase("./minoteaur.sqlite3")
|
let db = openDatabase(dbPath)
|
||||||
domain.migrate(db)
|
domain.migrate(db)
|
||||||
close(db)
|
close(db())
|
||||||
|
|
||||||
type
|
|
||||||
AppContext = ref object of Context
|
|
||||||
db: DbConn
|
|
||||||
|
|
||||||
# store thread's DB connection
|
|
||||||
var db {.threadvar.}: Option[DbConn]
|
|
||||||
|
|
||||||
proc dbMiddleware(): HandlerAsync =
|
proc dbMiddleware(): HandlerAsync =
|
||||||
# horrible accursed hack to make exitproc work
|
result = proc(ctx: AppContext) {.async.} =
|
||||||
result = proc(ctx: AppContext) {.async.} =
|
ctx.db = db()
|
||||||
# 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)
|
await switch(ctx)
|
||||||
|
|
||||||
proc headersMiddleware(): HandlerAsync =
|
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("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")
|
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())
|
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 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
|
# 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")):
|
buildHtml(form(`method`="post", class="edit-form", id="edit-form", autocomplete="off")):
|
||||||
textarea(name="content"): text pageData.map(p => p.content).get("")
|
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=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):
|
let sidebar = buildHtml(tdiv):
|
||||||
input(`type`="submit", value="Save", name="action", class="save", form="edit-form")
|
input(`type`="submit", value="Save", name="action", class="save", form="edit-form")
|
||||||
let verb = if pageData.isSome: "Editing " else: "Creating "
|
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.} =
|
proc revisions(ctx: AppContext) {.async.} =
|
||||||
let page = slugToPage(decodeUrl(ctx.getPathParams("page")))
|
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.editDistance.map(x => $x).get("")
|
||||||
td: text rev.meta.size.map(x => formatSize(x)).get("")
|
td: text rev.meta.size.map(x => formatSize(x)).get("")
|
||||||
td: text rev.meta.words.map(x => $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.} =
|
proc handleEdit(ctx: AppContext) {.async.} =
|
||||||
let page = slugToPage(decodeUrl(ctx.getPathParams("page")))
|
let page = slugToPage(decodeUrl(ctx.getPathParams("page")))
|
||||||
domain.updatePage(ctx.db, page, ctx.getFormParams("content"))
|
# file upload instead of content change
|
||||||
resp redirect(pageUrlFor(ctx, "view-page", page), Http303)
|
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.} =
|
proc sendAttachedFile(ctx: AppContext) {.async.} =
|
||||||
let page = slugToPage(decodeUrl(ctx.getPathParams("page")))
|
let page = slugToPage(decodeUrl(ctx.getPathParams("page")))
|
||||||
echo "orbital bee strike → you"
|
let filename = decodeUrl(ctx.getPathParams("filename"))
|
||||||
resp "TODO"
|
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.} =
|
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 page = slugToPage(decodeUrl(ctx.getPathParams("page")))
|
||||||
let rawRevision = ctx.getQueryParams("ts")
|
let rawRevision = ctx.getQueryParams("ts")
|
||||||
let viewSource = ctx.getQueryParams("source") != ""
|
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: a(class="wikilink", href=pageUrlFor(ctx, "view-page", backlink.fromPage)): text backlink.fromPage
|
||||||
tdiv: text backlink.context
|
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:
|
else:
|
||||||
# old revision
|
# old revision
|
||||||
@ -171,7 +195,7 @@ proc view(ctx: AppContext) {.async.} =
|
|||||||
text "As of "
|
text "As of "
|
||||||
text displayTime(rts)
|
text displayTime(rts)
|
||||||
tdiv(class="md"): mainBody
|
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 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 }))
|
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
|
return
|
||||||
resp jsonResponse toJson(results)
|
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 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)
|
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("/", index)
|
||||||
app.get("/favicon.ico", favicon)
|
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("/api/search", search, name="search")
|
||||||
app.get("/{page}/edit", edit, name="edit-page")
|
app.get("/{page}/edit", edit, name="edit-page")
|
||||||
app.get("/{page}/revisions", revisions, name="page-revisions")
|
app.get("/{page}/revisions", revisions, name="page-revisions")
|
||||||
|
77
src/sqlitesession.nim
Normal file
77
src/sqlitesession.nim
Normal 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)
|
@ -7,8 +7,7 @@ html
|
|||||||
body
|
body
|
||||||
font-family: "Fira Sans", "Noto Sans", "Segoe UI", Verdana, sans-serif
|
font-family: "Fira Sans", "Noto Sans", "Segoe UI", Verdana, sans-serif
|
||||||
font-weight: 300
|
font-weight: 300
|
||||||
margin: 0 0 1em 0
|
margin: 0 0 0 0
|
||||||
//min-height: 100vh
|
|
||||||
display: flex
|
display: flex
|
||||||
flex-wrap: wrap
|
flex-wrap: wrap
|
||||||
justify-content: space-around
|
justify-content: space-around
|
||||||
@ -98,6 +97,7 @@ a.wikilink
|
|||||||
|
|
||||||
.link-button, button, input[type=submit]
|
.link-button, button, input[type=submit]
|
||||||
border: none
|
border: none
|
||||||
|
border-radius: 0
|
||||||
padding: 0.75em
|
padding: 0.75em
|
||||||
background: gray
|
background: gray
|
||||||
text-align: center
|
text-align: center
|
||||||
@ -123,11 +123,16 @@ a.wikilink
|
|||||||
background-color: #bc13fe
|
background-color: #bc13fe
|
||||||
&.search
|
&.search
|
||||||
background-color: #fac205
|
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
|
border: 1px solid gray
|
||||||
padding: 0.75em
|
padding: 0.75em
|
||||||
width: 100%
|
width: 100%
|
||||||
|
border-radius: 0
|
||||||
|
|
||||||
.edit-form
|
.edit-form
|
||||||
textarea
|
textarea
|
||||||
@ -148,4 +153,7 @@ input[type=search], input[type=text]
|
|||||||
|
|
||||||
.flex-space
|
.flex-space
|
||||||
display: flex
|
display: flex
|
||||||
justify-content: space-between
|
justify-content: space-between
|
||||||
|
|
||||||
|
.login
|
||||||
|
display: flex
|
38
src/util.nim
38
src/util.nim
@ -7,6 +7,11 @@ import random
|
|||||||
import math
|
import math
|
||||||
import times
|
import times
|
||||||
import options
|
import options
|
||||||
|
import json
|
||||||
|
from os import `/`, existsOrCreateDir
|
||||||
|
import md5
|
||||||
|
import prologue
|
||||||
|
import logging
|
||||||
|
|
||||||
func lowercaseFirstLetter(s: string): string =
|
func lowercaseFirstLetter(s: string): string =
|
||||||
if len(s) == 0:
|
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 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 timestampToTime*(ts: int64): Time = initTime(ts div 1000, (ts mod 1000) * 1000000)
|
||||||
func timestampToStr*(t: Time): string = intToStr(int(timeToTimestamp(t)))
|
func timestampToStr*(t: Time): string = intToStr(int(timeToTimestamp(t)))
|
||||||
|
proc toJsonHook(t: Time): JsonNode = newJInt(timeToTimestamp(t))
|
||||||
|
|
||||||
# store time as milliseconds
|
# store time as milliseconds
|
||||||
proc toDbValue*(t: Time): DbValue = DbValue(kind: sqliteInteger, intVal: timeToTimestamp(t))
|
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 =
|
template autoInitializedThreadvar*(name: untyped, typ: typedesc, initialize: typed): untyped =
|
||||||
var data* {.threadvar.}: Option[typ]
|
var data* {.threadvar.}: Option[typ]
|
||||||
proc `name`(): typ =
|
proc `name`*(): typ =
|
||||||
if isSome(data): result = get data
|
if isSome(data): result = get data
|
||||||
else:
|
else:
|
||||||
result = initialize
|
result = initialize
|
||||||
@ -53,4 +59,32 @@ proc snowflake*(): int64 =
|
|||||||
var ts = int64((now - SIMPLEFLAKE_EPOCH) * 1000)
|
var ts = int64((now - SIMPLEFLAKE_EPOCH) * 1000)
|
||||||
let randomBits = int64(rng.rand(2 ^ SIMPLEFLAKE_RANDOM_LENGTH))
|
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
@ -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 */
|
||||||
|
@ -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"}
|
Loading…
x
Reference in New Issue
Block a user