Created the GTDServlet to serve the GTD repository via a REST API.

* Moved Item, PropertyHelp to the com.jdblabs.gtd namespace.
* Broke out common functions from GTCLI to a new Util class.
* Created a GTDServlet class which responds to the following endpoints:

  * `/login` (POST): Expects JSON input in the request body in the form of
    `{"username": "joe_user", "password": "password1234" }`. The username and
    password are validated against the values listed in the GTD root directory
    .properties file.
  * `/contexts` (GET): Returns all the GTD contexts the current user has `read`
    access to.
  * `/contexts/<contextId>` (GET): Returns a single context. The ID is the GTD
    context directory name.
  * `/projects` (GET): Returns all the GTD projects the current user has `read`
    access to.
  * `/projects/<projectId>` (GET): Returns a single project. The ID is the GTD
    project directory name.
  * `/next-actions/<categoryNames>` (GET): Returns all the next actions for a
    list of contexts or projects. The categoryNames value is expected to be a
    comma-delimited list of project and context names.
This commit is contained in:
Jonathan Bernard 2013-09-22 16:08:04 -05:00
parent a2f8b7b7a6
commit 58026c83ab
8 changed files with 466 additions and 72 deletions

View File

@ -40,4 +40,31 @@
<exec executable="ng-start" os="Linux"/>
</target>
<target name="servlet" depends="compile,increment-build-number">
<mkdir dir="${build.dir}/servlet/WEB-INF/classes"/>
<mkdir dir="${build.dir}/servlet/WEB-INF/lib"/>
<mkdir dir="${build.dir}/servlet/META-INF"/>
<copy todir="${build.dir}/servlet/WEB-INF/classes">
<fileset dir="${build.dir}/main/classes"/>
</copy>
<copy todir="${build.dir}/servlet/WEB-INF/lib">
<fileset dir="${build.dir}/lib/runtime/jar"/>
</copy>
<copy todir="${build.dir}/servlet/WEB-INF">
<fileset dir="${resources.dir}/WEB-INF"/>
</copy>
<copy todir="${build.dir}/servlet/META-INF">
<fileset dir="${resources.dir}/META-INF"/>
</copy>
<!--<jar
destfile="${build.dir}/${name}-servlet-${version}.${build.number}.war"
basedir="${build.dir}/servlet"/> -->
<jar destfile="${build.dir}/gtd.war" basedir="${build.dir}/servlet"/>
</target>
</project>

View File

@ -1,8 +1,8 @@
#Mon, 05 Aug 2013 10:16:09 -0500
#Sun, 22 Sep 2013 16:05:26 -0500
lib.local=true
name=jdb-gtd
version=1.1
version=1.2
nailgun.classpath.dir=/home/jdbernard/programs/nailgun/classpath
executable.jar=true
main.class=com.jdblabs.gtd.cli.GTDCLI
build.number=2
build.number=49

21
resources/WEB-INF/web.xml Normal file
View File

@ -0,0 +1,21 @@
<web-app xmlns="http://java.sun.com/xml/ns/javaee"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://java.sun.com/xml/ns/javaee http://java.sun.com/xml/ns/javaee/web-app_3_0.xsd"
version="3.0">
<servlet>
<servlet-name>GTDServlet</servlet-name>
<servlet-class>com.jdblabs.gtd.servlet.GTDServlet</servlet-class>
<init-param>
<param-name>gtdRootDir</param-name>
<param-value>/home/jdbernard/Dropbox/gtd</param-value>
</init-param>
</servlet>
<servlet-mapping>
<servlet-name>GTDServlet</servlet-name>
<url-pattern>/</url-pattern>
</servlet-mapping>
</web-app>

View File

@ -1,4 +1,4 @@
package com.jdblabs.gtd.cli
package com.jdblabs.gtd
public class Item {

View File

@ -1,4 +1,4 @@
package com.jdblabs.gtd.cli
package com.jdblabs.gtd
import org.joda.time.DateMidnight
import org.joda.time.DateTime

View File

@ -0,0 +1,68 @@
package com.jdblabs.gtd
import java.security.MessageDigest
public class Util {
public static List<File> findAllCopies(File original, File inDir) {
MessageDigest md5 = MessageDigest.getInstance("MD5")
def copies = []
def originalMD5 = md5.digest(original.bytes)
inDir.eachFileRecurse { file ->
if (file.isFile() && md5.digest(file.bytes) == originalMD5)
copies << file }
return copies }
public static boolean inPath(File parent, File child) {
def parentPath = parent.canonicalPath.split("/")
def childPath = child.canonicalPath.split("/")
// If the parent path is longer than the child path it cannot contain
// the child path.
if (parentPath.length > childPath.length) return false;
// If the parent and child paths do not match at any point, the parent
// path does not contain the child path.
for (int i = 0; i < parentPath.length; i++)
if (childPath[i] != parentPath[i])
return false;
// The parent path is at least as long as the child path, and the child
// path matches the parent path (up until the end of the parent path).
// The child path either is the parent path or is contained by the
// parent path.
return true }
public static String getRelativePath(File parent, File child) {
def parentPath = parent.canonicalPath.split("/")
def childPath = child.canonicalPath.split("/")
if (parentPath.length > childPath.length) return ""
int b = 0
while (b < parentPath.length && parentPath[b] == childPath[b] ) b++;
if (b != parentPath.length) return ""
return (['.'] + childPath[b..<childPath.length]).join('/') }
public static Map findGtdRootDir(File givenDir) {
def gtdDirs = [:]
File currentDir = givenDir
while (currentDir != null) {
gtdDirs = ["in", "incubate", "done", "next-actions", "projects",
"tickler", "waiting"].
collectEntries { [it, new File(currentDir, it)] }
if (gtdDirs.values().every { dir -> dir.exists() && dir.isDirectory() }) {
gtdDirs.root = currentDir
return gtdDirs }
currentDir = currentDir.parentFile }
return [:] }
}

View File

@ -1,5 +1,7 @@
package com.jdblabs.gtd.cli
import com.jdblabs.gtd.Item
import com.jdblabs.gtd.PropertyHelp
import com.jdbernard.util.LightOptionParser
import com.martiansoftware.nailgun.NGContext
import java.security.MessageDigest
@ -8,13 +10,14 @@ import org.joda.time.DateTime
//import org.slf4j.Logger
//import org.slf4j.LoggerFactory
import static com.jdblabs.gtd.Util.*
public class GTDCLI {
public static final String VERSION = "1.1"
public static final String VERSION = "1.2"
private static String EOL = System.getProperty("line.separator")
private static GTDCLI nailgunInst
private MessageDigest md5 = MessageDigest.getInstance("MD5")
private int terminalWidth
private Scanner stdin
private File workingDir
@ -249,8 +252,6 @@ public class GTDCLI {
if (selectedFile.isAbsolute()) item = new Item(selectedFile)
else item = new Item(new File(workingDir, selectedFilePath))
def itemMd5 = md5.digest(item.file.bytes)
// Move to the done folder.
def oldFile = item.file
def date = new DateMidnight().toString("YYYY-MM-dd")
@ -290,6 +291,8 @@ public class GTDCLI {
protected void calendar(LinkedList args) {
def itemsOnCalendar = []
MessageDigest md5 = MessageDigest.getInstance("MD5")
def addCalendarItems = { file ->
if (!file.isFile()) return
def item = new Item(file)
@ -540,66 +543,6 @@ context or project is named, all contexts are listed."""
}
}
protected List<File> findAllCopies(File original, File inDir) {
def copies = []
def originalMD5 = md5.digest(original.bytes)
inDir.eachFileRecurse { file ->
if (file.isFile() && md5.digest(file.bytes) == originalMD5)
copies << file }
return copies }
protected boolean inPath(File parent, File child) {
def parentPath = parent.canonicalPath.split("/")
def childPath = child.canonicalPath.split("/")
// If the parent path is longer than the child path it cannot contain
// the child path.
if (parentPath.length > childPath.length) return false;
// If the parent and child paths do not match at any point, the parent
// path does not contain the child path.
for (int i = 0; i < parentPath.length; i++)
if (childPath[i] != parentPath[i])
return false;
// The parent path is at least as long as the child path, and the child
// path matches the parent path (up until the end of the parent path).
// The child path either is the parent path or is contained by the
// parent path.
return true }
protected static String getRelativePath(File parent, File child) {
def parentPath = parent.canonicalPath.split("/")
def childPath = child.canonicalPath.split("/")
if (parentPath.length > childPath.length) return ""
int b = 0
while (b < parentPath.length && parentPath[b] == childPath[b] ) b++;
if (b != parentPath.length) return ""
return (['.'] + childPath[b..<childPath.length]).join('/') }
protected Map findGtdRootDir(File givenDir) {
def gtdDirs = [:]
File currentDir = givenDir
while (currentDir != null) {
gtdDirs = ["in", "incubate", "done", "next-actions", "projects",
"tickler", "waiting"].
collectEntries { [it, new File(currentDir, it)] }
if (gtdDirs.values().every { dir -> dir.exists() && dir.isDirectory() }) {
gtdDirs.root = currentDir
return gtdDirs }
currentDir = currentDir.parentFile }
return [:] }
protected String prompt(def msg) {
if (msg instanceof List) msg = msg.join(EOL)
msg += "> "
@ -610,13 +553,12 @@ context or project is named, all contexts are listed."""
return line }
static String filenameToString(File f) {
public static String filenameToString(File f) {
return f.name.replaceAll(/[-_]/, " ").capitalize() }
static String stringToFilename(String s) {
public static String stringToFilename(String s) {
return s.replaceAll(/\s/, '-').
replaceAll(/[';:(\.$)]/, '').
toLowerCase() }
}

View File

@ -0,0 +1,336 @@
package com.jdblabs.gtd.servlet
import com.jdblabs.gtd.Item
import com.jdblabs.gtd.PropertyHelp
import com.jdblabs.gtd.Util
import com.jdbernard.util.SmartConfig
import groovy.json.JsonBuilder
import groovy.json.JsonException
import groovy.json.JsonSlurper
import java.util.regex.Matcher
import javax.servlet.ServletConfig
import javax.servlet.ServletException
import javax.servlet.http.HttpServlet
import javax.servlet.http.HttpServletRequest
import javax.servlet.http.HttpServletResponse
import javax.servlet.http.HttpSession
import static javax.servlet.http.HttpServletResponse.*
public class GTDServlet extends HttpServlet {
protected Map gtdDirs
private SmartConfig config
private class TempRequestData {
public String username
public def defaultPermissions
}
void init(ServletConfig config) {
String gtdDirName = config.getInitParameter("gtdRootDir")
this.gtdDirs = Util.findGtdRootDir(new File(gtdDirName))
if (!gtdDirs) throw new ServletException(
"Unable to initialize GTD servlet: no GTD root dir found in the " +
"configured path (${gtdDirName}).")
def cfgFile = new File(gtdDirs.root, '.properties')
if (!cfgFile.isFile() || !cfgFile.exists()) throw new ServletException(
"Unable to find the GTD/.properties configuration file. " +
"Expected to find it at '${cfgFile.canonicalPath}'.")
this.config = new SmartConfig(cfgFile) }
void doOptions(HttpServletRequest request, HttpServletResponse response) {
response.addHeader("Access-Control-Allow-Origin",
request.getHeader("Origin") ?: "*")
response.addHeader("Access-Control-Allow-Credentials", "true")
response.status = SC_OK
switch (request.servletPath) {
case '/login':
response.addHeader("Allow", "POST")
response.addHeader("Access-Control-Allow-Methods", "POST")
break
case ~'/contexts.*':
case ~'/projects.*':
case ~'/next-actions/.+':
response.addHeader("Allow", "GET")
response.addHeader("Access-Control-Allow-Methods", "GET")
break
default:
response.status = SC_NOT_FOUND }
}
void doPost(HttpServletRequest request, HttpServletResponse response) {
response.addHeader("Content-Type", "application/json")
response.addHeader("Access-Control-Allow-Origin",
request.getHeader("Origin") ?: "*")
response.addHeader("Access-Control-Allow-Credentials", "true")
HttpSession session = request.getSession(true);
// If the user is posting to /gtd/login then let's try to authenticate
// them.
if (request.servletPath == '/login') {
// Parse the username/password from the request.
def requestBody
try { requestBody = new JsonSlurper().parse(request.reader) }
catch (JsonException jsone) {
response.status = SC_BAD_REQUEST
return }
// Build our list of known users.
def users = config.accountNames.split(/,/).collect { it.trim() }
// Lookup the user's password in the configuration (will be null if
// we are given an invalid username).
String expectedPwd = config."account.${requestBody.username}.password"
// Reject the login request if the user is not defined by our
// configuration. Note: timing attack possible due to string
// comparison.
if (!users.contains(requestBody.username) ||
requestBody.password != expectedPwd) {
response.status = SC_UNAUTHORIZED
response.writer.flush()
return }
response.status = SC_OK
session.setAttribute('authenticated', true)
session.setAttribute('username', requestBody.username)
writeJSON([status: "ok"], response)
return }
// If the user is not authenticated return a 401 Unauthorized.
else if (!((boolean)session.getAttribute('authenticated'))) {
response.status = SC_UNAUTHORIZED
return }
// Right now there is no other endpoint that supports POST, so return
// 404 Not Found or 405 Method Not Allowed
switch (request.servletPath) {
case ~/\/gtd\/contexts.*/:
case ~/\/gtd\/projects.*/:
response.status = SC_METHOD_NOT_ALLOWED
return
default:
response.status = SC_NOT_FOUND
return
}
}
void doGet(HttpServletRequest request, HttpServletResponse response) {
response.status = SC_OK
response.addHeader("Content-Type", "application/json")
response.addHeader("Access-Control-Allow-Origin",
request.getHeader("Origin") ?: "*")
response.addHeader("Access-Control-Allow-Credentials", "true")
HttpSession session = request.getSession(true);
// If the user is not authenticated return 401 Unauthorized.
if (!((boolean)session.getAttribute('authenticated'))) {
response.status = SC_UNAUTHORIZED
return }
def curData = new TempRequestData()
// Read the username from the session object.
curData.username = session.getAttribute('username')
// Determine the user's default permissions.
curData.defaultPermissions =
(config."account.${curData.username}.defaultPermissions" ?: "")
.split(/,/).collect { it.trim() }
switch(request.servletPath) {
// If they are invoking /gtd/logout then invalidate their session
// and return 200 OK
case "/logout":
session.removeAttribute("authenticated")
session.invalidate()
break
// If they are GET'ing /gtd/contexts then return the list of
// contexts that are readable by this user.
case "/contexts":
// Filter the directories to find the ones that the user has
// read access to.
def selectedContexts = findAllowedDirs("read", curData,
gtdDirs['next-actions'].listFiles())
def returnData = selectedContexts.collect { entry ->
[id: entry.dir.name, title: entry.props.title] }
// Now format our response as JSON and write it to the response
writeJSON(returnData, response)
break
// If they are GET'ing /gtd/contexts/<contextId> then return data
// for the requested context, assuming it is readable for this
// user.
case ~'/contexts/(.+)':
String contextId = Matcher.lastMatcher[0][1]
// Try to find the named context.
File ctxDir = new File(gtdDirs['next-actions'], contextId)
// Check that they have read permission on this directory.
def filteredList = findAllowedDirs("read", curData, [ctxDir])
if (filteredList.size() == 0) {
response.status = SC_NOT_FOUND
writeJSON([status: "not found"], response)
break }
def entry = filteredList[0]
def returnData = [id: entry.dir.name, title: entry.props.title]
writeJSON(returnData, response)
break
// If they are GET'ing /gtd/projects then return the list of
// projects that are readable for this user.
case "/projects":
// Filter the directories to find the ones that the user has
// read access to.
def selectedProjects = findAllowedDirs("read", curData,
gtdDirs['projects'].listFiles())
def returnData = selectedProjects.collect { entry ->
[id: entry.dir.name, title: entry.props.title] }
writeJSON(returnData, response)
break
// If they are GET'ing /gtd/projects/<projectId> then return the
// list of projects that are readable for this user.
case ~'/projects/(.+)':
String projectId = Matcher.lastMatcher[0][1]
// Try to find the named project.
File projectDir = new File(gtdDirs['projects'], contextId)
// Check that they have read permission on this directory.
def filteredList = findAllowedDirs("read", curData, [projectDir])
if (filteredList.size() == 0) {
response.status = SC_NOT_FOUND
writeJSON([status: "not found"], response)
break }
def entry = filteredList[0]
def returnData = [id: entry.dir.name, title: entry.props.title]
writeJSON(returnData, response)
break
case ~'/next-actions/(.+)':
// Parse out the list of contexts/projects
List ids = Matcher.lastMatcher[0][1].split(/,/) as List
List searchDirs = []
// Look for each id in our list of contexts
searchDirs.addAll(ids.collect { id ->
new File(gtdDirs['next-actions'], id) })
// And look for each id in our list of projects
searchDirs.addAll(ids.collect { id ->
new File(gtdDirs['projects'], id) })
// Filter the directories to find the ones that exist and are
// readable by our user.
def actualDirs = findAllowedDirs("read", curData, searchDirs)
// Collect all the items.
def items = [], itemFiles = [], uniqueItemFiles = []
// Collect all the items across all the actual directories.
itemFiles = actualDirs.collectMany { entry ->
entry.dir.listFiles({ f -> !f.isHidden() } as FileFilter) as List }
// De-duplicate the items using the GTD findAllCopies utility
// method to remove items that are listed in a chosen context
// and project. We are going to do this by identifying
// duplicate items, removing all of them from the itemFiles
// list and adding only the first to our new uniqueItemFiles
// list.
while (itemFiles.size() > 0) {
def item = itemFiles.remove(0)
// Find all duplicates.
def dupes = Util.findAllCopies(item, gtdDirs.root)
// Remove them from the source collection.
itemFiles.removeAll { f1 -> dupes.any { f2 ->
f1.canonicalPath == f2.canonicalPath }}
// Add the first one to the destination collection.
uniqueItemFiles << item }
// Create Item objects for each item.
items = uniqueItemFiles.collect { new Item(it) }
// Return all the items.
def returnData = items.collect { item ->
def m = [id: item.file.name]
item.gtdProperties.each { k, v ->
m[k] = PropertyHelp.format(v) }
return m }
writeJSON(returnData, response)
break
// Otherwise return a 404 Not Found
default:
response.status = SC_NOT_FOUND
break
}
response.writer.flush()
}
protected Collection findAllowedDirs(String permission,
TempRequestData curData, def dirs) {
return findAllowedDirs([permission], curData, dirs) }
protected Collection findAllowedDirs(List requiredPermissions,
TempRequestData curData, def dirs) {
return dirs.collectMany { dir ->
println ">> Considering ${dir.canonicalPath}"
// Only directories can be contexts and projects.
if (!dir.exists() || !dir.isDirectory()) { return [] }
// Check for a .properties file in this directory.
def propFile = new File(dir, '.properties')
// If it does not exist, defer to the defaults.
if (!propFile.exists() &&
!curData.defaultPermissions.containsAll(requiredPermissions)) {
return [] }
// Look for the account.<curData.username>.Permissions property.
def itemProps = new SmartConfig(propFile)
def actualPermissions = itemProps.getProperty(
"account.${curData.username}.permissions", "default").
split(/,/).collect { it.trim() }
// If the user has the correct permission on this context, or
// if this context inherits their default permissions, and
// they have the correct permission by default, then we show
// this context. If this is not the case (tested in the
// following conditional) we do not show this context.
if (!actualPermissions.containsAll(requiredPermissions) &&
!(actualPermissions.containsAll('default') &&
curData.defaultPermissions.containsAll(requiredPermissions))) {
return [] }
// At this point we know the context exists, and the user
// has permission to read it.
return [[ dir: dir, props: itemProps ]] } }
protected void writeJSON(def data, def response) {
new JsonBuilder(data).writeTo(response.writer) }
}