139 lines
4.3 KiB
Nim
139 lines
4.3 KiB
Nim
## Personal Time Keeping API Interface
|
|
## ===================================
|
|
|
|
import asyncdispatch, base64, bcrypt, cliutils, docopt, jester, json, logging,
|
|
ospaths, sequtils, strutils, os
|
|
import nre except toSeq
|
|
|
|
import ./models
|
|
|
|
type
|
|
PtkUser* = object
|
|
username*, salt*, pwdhash*, timelinePath: string
|
|
isAdmin*: bool
|
|
|
|
PtkApiCfg* = object
|
|
users*: seq[PtkUser]
|
|
port*: int
|
|
dataDir*: string
|
|
|
|
const TXT = "text/plain"
|
|
const JSON = "application/json"
|
|
|
|
proc raiseEx(reason: string): void = raise newException(Exception, reason)
|
|
|
|
template checkAuth(cfg: PtkApiCfg) =
|
|
## Check this request for authentication and authorization information.
|
|
## If the request is not authorized, this template sets up the 401 response
|
|
## correctly. The calling context needs only to return from the route.
|
|
|
|
var authed {.inject.} = false
|
|
var user {.inject.}: PtkUser = PtkUser()
|
|
|
|
try:
|
|
if not request.headers.hasKey("Authorization"):
|
|
raiseEx "No auth token."
|
|
|
|
let headerVal = request.headers["Authorization"]
|
|
if not headerVal.startsWith("Basic "):
|
|
raiseEx "Invalid Authorization type (only 'Basic' is supported)."
|
|
|
|
let authVals = headerVal[6..^1].decode().split(":")
|
|
|
|
let candidates = cfg.users.filterIt(it.username.compare(authVals[0]))
|
|
if candidates.len != 1: raiseEx "Invalid Authorization: unknown username/password combination."
|
|
|
|
let foundUser: PtkUser = candidates[0]
|
|
if not compare(foundUser.pwdhash, hash(authVals[1], foundUser.salt)):
|
|
raiseEx "Invalid Authorization: unknown username/password combination."
|
|
|
|
user = foundUser
|
|
authed = true
|
|
|
|
except:
|
|
stderr.writeLine "Auth failed: " & getCurrentExceptionMsg()
|
|
response.data[0] = CallbackAction.TCActionSend
|
|
response.data[1] = Http401
|
|
response.data[2]["WWW_Authenticate"] = "Basic"
|
|
response.data[2]["Content-Type"] = TXT
|
|
response.data[3] = getCurrentExceptionMsg()
|
|
|
|
proc parseAndRun(user: PtkUser, cmd: string, params: StringTableRef): string =
|
|
|
|
var args = queryParamsToCliArgs(params)
|
|
args = @[cmd, "--file", user.timelinePath] & args
|
|
|
|
info "args: \n" & args.join(" ")
|
|
|
|
let execResult = execWithOutput("ptk", ".", args)
|
|
if execResult[2] != 0: raiseEx(stripAnsi($execResult[0] & "\n" & $execResult[1]))
|
|
else: return stripAnsi(execResult[0])
|
|
|
|
proc getOrFail(n: JsonNode, key: string, objName: string = ""): JsonNode =
|
|
## convenience method to get a key from a JObject or raise an exception
|
|
if not n.hasKey(key): raiseEx objName & " missing key '" & key & "'"
|
|
return n[key]
|
|
|
|
proc getIfExists(n: JsonNode, key: string): JsonNode =
|
|
## convenience method to get a key from a JObject or return null
|
|
result = if n.hasKey(key): n[key]
|
|
else: newJNull()
|
|
|
|
proc parseUser(json: JsonNode): PtkUser =
|
|
let salt = gensalt(12)
|
|
|
|
return PtkUser(
|
|
username: json.getOrFail("username").getStr,
|
|
pwdhash: json.getOrFail("password").getStr.hash(salt),
|
|
salt: salt,
|
|
timelinePath: json.getIfExists("timelinePath").getStr(""),
|
|
isAdmin: false)
|
|
|
|
proc start*(cfg: PtkApiCfg) =
|
|
|
|
var stopFuture = newFuture[void]()
|
|
|
|
settings:
|
|
port = Port(cfg.port)
|
|
appName = "/api"
|
|
|
|
routes:
|
|
|
|
get "/ping": resp("pong", TXT)
|
|
|
|
get "/marks":
|
|
checkAuth(cfg); if not authed: return true
|
|
|
|
try: resp(parseAndRun(user, "list", request.params), TXT)
|
|
except: resp(Http500, getCurrentExceptionMsg(), TXT)
|
|
|
|
post "/mark":
|
|
checkAuth(cfg); if not authed: return true
|
|
|
|
var newMark: Mark
|
|
try: newMark = parseMark(parseJson(request.body))
|
|
except: resp(Http400, getCurrentExceptionMsg(), TXT)
|
|
|
|
var params = newStringTable
|
|
|
|
post "/users":
|
|
checkAuth(cfg); if not authed: return true
|
|
if not user.isAdmin: resp(Http403, "insufficient permission", TXT)
|
|
|
|
var newUser: PtkUser
|
|
try: newUser = parseUser(parseJson(request.body))
|
|
except: resp(Http400, getCurrentExceptionMsg(), TXT)
|
|
|
|
if cfg.users.anyIt(it.username == newUser.username):
|
|
resp(Http409, "user already exists", TXT)
|
|
|
|
newUser.timelinePath = cfg.dataDir / newUser.username & ".timeline.json"
|
|
|
|
try:
|
|
discard parseAndRun(newUser, "init", newStringTable())
|
|
# TODO: save updated config!
|
|
# cfg.users.add(newUser)
|
|
resp(Http200, "ok", TXT)
|
|
|
|
except: resp(Http500, "could not init new user timeline", TXT)
|