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 "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
View File

@ -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
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 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)

View File

@ -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
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(" ") 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(" ")

View File

@ -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
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 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

View File

@ -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

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"}