diff --git a/build.xml b/build.xml index 3679eda..047886e 100644 --- a/build.xml +++ b/build.xml @@ -40,4 +40,31 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/project.properties b/project.properties index c05993a..fedd082 100644 --- a/project.properties +++ b/project.properties @@ -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 diff --git a/resources/WEB-INF/web.xml b/resources/WEB-INF/web.xml new file mode 100644 index 0000000..2f34835 --- /dev/null +++ b/resources/WEB-INF/web.xml @@ -0,0 +1,21 @@ + + + + GTDServlet + com.jdblabs.gtd.servlet.GTDServlet + + + gtdRootDir + /home/jdbernard/Dropbox/gtd + + + + + GTDServlet + / + + + diff --git a/src/main/com/jdblabs/gtd/cli/Item.groovy b/src/main/com/jdblabs/gtd/Item.groovy similarity index 97% rename from src/main/com/jdblabs/gtd/cli/Item.groovy rename to src/main/com/jdblabs/gtd/Item.groovy index c0e4132..4d44569 100644 --- a/src/main/com/jdblabs/gtd/cli/Item.groovy +++ b/src/main/com/jdblabs/gtd/Item.groovy @@ -1,4 +1,4 @@ -package com.jdblabs.gtd.cli +package com.jdblabs.gtd public class Item { diff --git a/src/main/com/jdblabs/gtd/cli/PropertyHelp.groovy b/src/main/com/jdblabs/gtd/PropertyHelp.groovy similarity index 98% rename from src/main/com/jdblabs/gtd/cli/PropertyHelp.groovy rename to src/main/com/jdblabs/gtd/PropertyHelp.groovy index d1a80d8..33e94f4 100644 --- a/src/main/com/jdblabs/gtd/cli/PropertyHelp.groovy +++ b/src/main/com/jdblabs/gtd/PropertyHelp.groovy @@ -1,4 +1,4 @@ -package com.jdblabs.gtd.cli +package com.jdblabs.gtd import org.joda.time.DateMidnight import org.joda.time.DateTime diff --git a/src/main/com/jdblabs/gtd/Util.groovy b/src/main/com/jdblabs/gtd/Util.groovy new file mode 100644 index 0000000..9740636 --- /dev/null +++ b/src/main/com/jdblabs/gtd/Util.groovy @@ -0,0 +1,68 @@ +package com.jdblabs.gtd + +import java.security.MessageDigest + +public class Util { + + public static List 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.. dir.exists() && dir.isDirectory() }) { + gtdDirs.root = currentDir + return gtdDirs } + + currentDir = currentDir.parentFile } + + return [:] } +} diff --git a/src/main/com/jdblabs/gtd/cli/GTDCLI.groovy b/src/main/com/jdblabs/gtd/cli/GTDCLI.groovy index 34e733c..3d799bb 100644 --- a/src/main/com/jdblabs/gtd/cli/GTDCLI.groovy +++ b/src/main/com/jdblabs/gtd/cli/GTDCLI.groovy @@ -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 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.. 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() } - } diff --git a/src/main/com/jdblabs/gtd/servlet/GTDServlet.groovy b/src/main/com/jdblabs/gtd/servlet/GTDServlet.groovy new file mode 100644 index 0000000..ae93cb4 --- /dev/null +++ b/src/main/com/jdblabs/gtd/servlet/GTDServlet.groovy @@ -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/ 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/ 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..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) } +}