//
// Copyright (c) 2011, Brian Frank and Andy Frank
// Licensed under the Academic Free License version 3.0
//
// History:
// 11 May 11 Brian Frank Creation
//
using web
using util
**
** WebRepoMod implements basic server side functionality for
** publishing a repo over HTTP to be used by 'WebRepo'. URI
** namespace:
**
** Method Uri Operation
** ------ -------------------- ---------
** GET {base}/ping ping meta-data
** GET {base}/find/{name} pod find current
** GET {base}/find/{name}/{ver} pod find
** GET {base}/query?{query} pod query
** POST {base}/query pod query
** GET {base}/pod/{name}/{ver} pod download
** POST {base}/publish publish pod
** GET {base}/auth?{username} authentication info
**
** See [Web Repos]`docFanr::WebRepos`.
**
**
const class WebRepoMod : WebMod
{
** Constructor, must set `repo`.
new make(|This|? f := null) { if (f != null) f(this) }
** Repository to publish on the web, typically a local FileRepo.
const Repo repo
** Authentication and authorization plug-in.
** Default is to make everything completely public.
const WebRepoAuth auth := PublicWebRepoAuth()
** Meta-data to include in ping requests. If customized,
** then be sure to include standard props defined by `Repo.ping`.
const Str:Str pingMeta :=
[
"fanr.type": WebRepo#.toStr,
"fanr.version": WebRepoMod#.pod.version.toStr
]
** Dir to store temp files, defaults to 'Env.tempDir'
const File tempDir := Env.cur.tempDir
//////////////////////////////////////////////////////////////////////////
// Service Routing
//////////////////////////////////////////////////////////////////////////
** Service
override Void onService()
{
try
{
// if user was specified, then authenticate to user object
user := authenticate
if (res.isDone) return
// route to correct command
path := req.modRel.path
cmd := path.getSafe(0) ?: "?"
if (cmd == "find" && path.size == 2) { onFind(path[1], null, user); return }
if (cmd == "find" && path.size == 3) { onFind(path[1], path[2], user); return }
if (cmd == "query" && path.size == 1) { onQuery(user); return }
if (cmd == "pod" && path.size == 3) { onPod(path[1], path[2], user); return }
if (cmd == "publish" && path.size == 1) { onPublish(user); return }
if (cmd == "ping" && path.size == 1) { onPing(user); return }
if (cmd == "auth" && path.size == 1) { onAuth(user); return }
sendNotFoundErr
}
catch (Err e)
{
if (!res.isCommitted) sendErr(500, e.toStr)
else throw e
}
}
private Obj? authenticate()
{
// if username header wasn't specified, then assume public request
username := req.headers["Fanr-Username"]
if (username == null) return null
// check that user name is valid
user := auth.user(username)
if (user == null)
{
sendUnauthErr("Invalid username: $username")
return null
}
// get signature headers
signAlgorithm := getRequiredHeader("Fanr-SignatureAlgorithm")
secretAlgorithm := getRequiredHeader("Fanr-SecretAlgorithm").upper
signature := getRequiredHeader("Fanr-Signature")
ts := DateTime.fromStr(getRequiredHeader("Fanr-Ts"))
// check timestamp is in ball-park of now to prevent replay
// attacks, but give some fudge since clocks are never in sync
if ((now - ts).abs > 15min)
{
sendUnauthErr("Invalid timestamp window for signature: $ts != $now")
return null
}
// verify signature algorithm (we currently only support one algorithm)
if (signAlgorithm != "HMAC-SHA1")
{
sendUnauthErr("Unsupported signature algorithm: $signAlgorithm")
return null
}
// verify signature which in effect is the password verification
s := WebRepo.toSignatureBody(req.method, req.absUri, req.headers)
secret := auth.secret(user, secretAlgorithm)
expectedSignature := s.hmac("SHA-1", secret).toBase64
if (expectedSignature != signature)
{
sendUnauthErr("Invalid password (invalid signature)")
return null
}
// at this point we have authenticated the user
return user
}
//////////////////////////////////////////////////////////////////////////
// Ping
//////////////////////////////////////////////////////////////////////////
private Void onPing(Obj? user)
{
// add "ts" to configured meta
props := pingMeta.dup
props["ts"] = DateTime.now.toStr
res.headers["Content-Type"] = "text/plain"
JsonOutStream(res.out).writeJson(props).flush
}
//////////////////////////////////////////////////////////////////////////
// Find
//////////////////////////////////////////////////////////////////////////
private Void onFind(Str podName, Str? verStr, Obj? user)
{
// if user can't read any pods, immediately bail
if (!auth.allowQuery(user, null)) { sendForbiddenErr(user); return }
// lookup pod that matches name/version
Version? ver := null
if (verStr != null)
{
ver = Version.fromStr(verStr, false)
if (ver == null) { sendErr(404, "Invalid version: $verStr"); return }
}
spec := repo.find(podName, ver, false)
if (spec == null) { sendErr(404, "Pod not found: $podName-$ver"); return }
// verify permissions
if (!auth.allowQuery(user, spec)) { sendForbiddenErr(user); return }
// return result
res.headers["Content-Type"] = "text/plain"
printPodSpecJson(res.out, spec, false)
}
//////////////////////////////////////////////////////////////////////////
// Query
//////////////////////////////////////////////////////////////////////////
private Void onQuery(Obj? user)
{
// if user can't query any pods, immediately bail
if (!auth.allowQuery(user, null)) { sendForbiddenErr(user); return }
// query can be GET query part or POST body
Str? query
switch (req.method)
{
case "GET": query = req.uri.queryStr ?: throw Err("Missing '?query' in URI")
case "POST": query = req.in.readAllStr
default: sendBadMethodErr
}
// get options
numVersions := Int.fromStr(req.headers["Fanr-NumVersions"] ?: "3", 10, false) ?: 3
// do the query
PodSpec[]? pods := null
try
{
pods = repo.query(query, numVersions)
}
catch (ParseErr e)
{
sendErr(400, e.toStr)
return
}
// filter out any pods the user is not allowed to query
pods = pods.findAll |pod| { auth.allowQuery(user, pod) }
// print results in json format
res.headers["Content-Type"] = "text/plain"
out := res.out
out.printLine("""{"pods":[""")
pods.each |pod, i| { printPodSpecJson(out, pod, i+1 < pods.size) }
out.printLine("]}")
}
//////////////////////////////////////////////////////////////////////////
// Read Pod
//////////////////////////////////////////////////////////////////////////
private Void onPod(Str podName, Str podVer, Obj? user)
{
// if user can't read any pods, immediately bail
if (!auth.allowRead(user, null)) { sendForbiddenErr(user); return }
// lookup pod that matches name/version
query := "$podName $podVer"
spec := repo.query(query, 100).find |p| { p.version.toStr == podVer }
if (spec == null) { sendErr(404, "No pod match: $query"); return }
// check permissions
if (!auth.allowRead(user, spec)) { sendForbiddenErr(user); return }
// pipe repo stream to response stream
res.headers["Content-Type"] = "application/zip"
if (spec.size != null) res.headers["Content-Length"] = spec.size.toStr
repo.read(spec).pipe(res.out, spec.size)
}
//////////////////////////////////////////////////////////////////////////
// Publish
//////////////////////////////////////////////////////////////////////////
private Void onPublish(Obj? user)
{
if (req.method != "POST") { sendBadMethodErr; return }
// if user can't publish any pods, immediately bail
if (!auth.allowPublish(user, null)) { sendForbiddenErr(user); return }
// allocate temp file
tempName := "fanr-" + DateTime.now.toLocale("YYMMDDhhmmss") + "-" + Buf.random(4).toHex + ".pod"
tempFile := tempDir + tempName.toUri
try
{
// read input to temp file
tempOut := tempFile.out
len := req.headers["Content-Length"]?.toInt ?: null
try
req.in.pipe(tempOut, len)
finally
tempOut.close
// check if user can publish this specific pod
spec := PodSpec.load(tempFile)
if (!auth.allowPublish(user, spec)) { sendForbiddenErr(user); return }
// publish to local repo
spec = repo.publish(tempFile)
// return JSON response
res.headers["Content-Type"] = "text/plain"
out := res.out
out.printLine("""{"published":""")
printPodSpecJson(out, spec, false)
out.printLine("""}""")
}
finally
{
try { tempFile.delete } catch {}
}
}
//////////////////////////////////////////////////////////////////////////
// Auth
//////////////////////////////////////////////////////////////////////////
private Void onAuth(Obj? reqUser)
{
if (req.method != "GET") { sendBadMethodErr; return }
username := req.uri.queryStr ?: "*"
user := auth.user(username)
salt := auth.salt(user)
secrets := auth.secretAlgorithms.join(",")
signatures := auth.signatureAlgorithms.join(",")
res.headers["Content-Type"] = "text/plain"
out := res.out
out.printLine("""{""")
out.printLine(""" "username":$username.toCode,""")
if (salt != null) out.printLine(""" "salt":$salt.toCode,""")
out.printLine(""" "secretAlgorithms":$secrets.toCode,""")
out.printLine(""" "signatureAlgorithms":$signatures.toCode,""")
out.printLine(""" "ts":$now.toStr.toCode""")
out.printLine("""}""")
}
//////////////////////////////////////////////////////////////////////////
// Response/Error Handling
//////////////////////////////////////////////////////////////////////////
private Void printPodSpecJson(OutStream out, PodSpec pod, Bool comma)
{
out.printLine("{")
keys := pod.meta.keys
keys.moveTo("pod.name", 0)
keys.moveTo("pod.version", 1)
keys.each |k, j|
{
v := pod.meta[k]
out.print(k.toCode).print(":").print(v.toCode).printLine(j+1<keys.size?",":"")
}
out.printLine(comma ? "}," : "}")
}
private Str getRequiredHeader(Str key)
{
req.headers[key] ?: throw Err("Missing required header $key.toCode")
}
private Void sendUnauthErr(Str msg)
{
sendErr(401, msg)
}
private Void sendForbiddenErr(Obj? user)
{
if (user == null) sendErr(401, "Authentication required")
else sendErr(403, "Not allowed")
}
private Void sendNotFoundErr()
{
sendErr(404, "Resource not found: $req.modRel")
}
private Void sendBadMethodErr()
{
sendErr(501, "Method not implemented: $req.method")
}
private Void sendErr(Int code, Str msg)
{
res.statusCode = code
res.headers["Content-Type"] = "text/plain"
res.out.printLine("""{"err":$msg.toCode}""").close
res.done
}
private DateTime now() { DateTime.nowUtc(null) }
}