diff --git a/.gitignore b/.gitignore index bc353a7..c0fd8a7 100644 --- a/.gitignore +++ b/.gitignore @@ -8,4 +8,6 @@ bin .vscode .idea -Credentials.java \ No newline at end of file +Credentials.java + +.DS_Store \ No newline at end of file diff --git a/src/main/java/Main.java b/src/main/java/Main.java index 1a64b9b..85300c9 100644 --- a/src/main/java/Main.java +++ b/src/main/java/Main.java @@ -6,17 +6,18 @@ public class Main { private static Routes routes = new Routes(mongo, crypto); public static void main(String[] args) { - Spark.port(5003); - Spark.get("/db/auths", routes.routeAuth); - Spark.options("/db/auths", routes.routeOptions); - Spark.get("/db/auths/check", routes.routeAuthCheck); - Spark.options("/db/auths/check", routes.routeOptions); - Spark.get("/db/posts/:date", routes.routeDate); - Spark.get("/db/posts", routes.routeAll); - Spark.get("/db/images/:oidString", routes.routeImage); - Spark.post("/db/upload/images", routes.routeUploadImage); - Spark.options("/db/upload/images", routes.routeOptions); - Spark.post("/db/upload/posts", routes.routeUploadPost); - Spark.options("/db/upload/posts", routes.routeOptions); + Spark.port(5002); + Spark.get("/auths", routes.routeAuth); + Spark.options("/auths", routes.routeOptions); + Spark.get("/auths/check", routes.routeAuthCheck); + Spark.options("/auths/check", routes.routeOptions); + Spark.get("/posts/:date", routes.routeDate); + Spark.get("/posts", routes.routeAll); + Spark.get("/images/:oidString", routes.routeImage); + Spark.post("/images", routes.routeUploadImage); + Spark.options("/images", routes.routeOptions); + Spark.post("/posts", routes.routeUploadPost); + Spark.options("/posts", routes.routeOptions); + Spark.get("/endpoints", routes.routeEndpoints); } } \ No newline at end of file diff --git a/src/main/java/Mongo.java b/src/main/java/Mongo.java index 27fc4c9..6905a9b 100644 --- a/src/main/java/Mongo.java +++ b/src/main/java/Mongo.java @@ -32,6 +32,8 @@ import com.mongodb.client.model.Updates; import com.mongodb.client.result.InsertOneResult; +import httputils.Response; + public class Mongo { private ConnectionString connectionString = Credentials.connectionString; private MongoClientSettings clientSettings = MongoClientSettings.builder().applyConnectionString(connectionString) @@ -52,59 +54,70 @@ public Mongo(Crypto crypto) { /** * Gets all documents from the posts collection and puts them in an ArrayList. - * @return an ArrayList of JSON strings from MongoDB + * @return a new Respobse object containing all posts as JSON in the body. */ - public ArrayList getAllPosts() { + public Response getAllPosts() { try { FindIterable docs = db.getCollection("posts").find(); ArrayList docsJSON = new ArrayList(); docs.forEach(doc -> docsJSON.add(doc.toJson())); - return docsJSON; + + return new Response().withCode(200).withAllowGetMethodHeader().withBody("{\"posts\": " + docsJSON.toString() + "}"); } catch(Exception e) { - System.out.println(e); - return new ArrayList(); + e.printStackTrace(); + return new Response().withBody("{\"error\": " + e + "}"); } } /** * Gets a post that was posted on the given datestring * @param dateString The datestring to search for formatted MMDDYY, i.e. 092123 - * @return The JSON string returned from the MongoDB posts collection + * @return a new Response object where the body is the JSON string returned + * from the MongoDB posts collection */ - public String getPost(String dateString) { + public Response getPost(String dateString) { try { - MongoCollection posts = db.getCollection("posts"); - String doc = posts.find(Filters.eq("dateString", dateString)).first().toJson(); - return doc; + Document doc = posts.find(Filters.eq("dateString", dateString)).first(); + if(doc == null) { + return new Response().withCode(400).withAllowGetMethodHeader().withBody("{\"error\": \"A post with the dateString " + dateString + " could not be found.\"}"); + } + + return new Response().withCode(200).withAllowGetMethodHeader().withBody(doc.toJson()); } catch(Exception e) { - System.out.println(e); - return ""; + e.printStackTrace(); + return new Response().withBody("{\"error\": " + e + "}"); } } /** * Gets an image in base64 from the GridFS bucket stored in the MongoDB database * @param oid the hex string _id of the image to get - * @return a base64 encoded string image (WITH leading data URL format, i.e. "data:image/[format];base64,") + * @return a new Response object with a base64 encoded string image (WITH leading data URL format, + * i.e. "data:image/[format];base64,") as the body */ - public String getImage(String oid) { + public Response getImage(String oid) { try { - GridFSBucket bucket = GridFSBuckets.create(db, "images"); ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); GridFSFile fileToDownload = bucket.find(Filters.eq("_id", new BsonObjectId(new ObjectId(oid)))).first(); - bucket.downloadToStream(new ObjectId(oid), outputStream); + + if(fileToDownload == null) { + return new Response().withCode(400).withAllowGetMethodHeader().withBody("{\"error\": \"An image with the oid " + oid + " could not be found.\"}"); + } + bucket.downloadToStream(new ObjectId(oid), outputStream); String imageb64 = outputStream.toString(); - return "{\"fileName\": \"" + fileToDownload.getFilename() + "\", \"featured\":" + fileToDownload.getMetadata().get("featured") + ", \"data\": \"" + imageb64 + "\"}"; + String body = "{\"fileName\": \"" + fileToDownload.getFilename() + "\", \"featured\": " + fileToDownload.getMetadata().get("featured") + ", \"data\": \"" + imageb64 + "\"}"; + outputStream.close(); + + return new Response().withCode(200).withAllowGetMethodHeader().withBody(body); } catch (Exception e) { e.printStackTrace(); - System.out.println(e); - return ""; + return new Response().withBody("{\"error\": " + e + "}"); } } @@ -112,11 +125,10 @@ public String getImage(String oid) { * Uploads an image from a base64 string with a given filename to the GridFS bucket stored in the MongoDB database * @param imageb64 the base64 encoded image (WITHOUT leading data URL format, i.e. "data:image/[format];base64,") * @param fileName the filename to associate with the file, i.e. "file.jpeg" - * @return the hex string _id of the inserted image + * @return a new Response object with the hex string _id of the inserted image as the body */ - public String putImage(String imageb64, String fileName, Boolean featured) { + public Response postImage(String imageb64, String fileName, Boolean featured) { try { - GridFSBucket bucket = GridFSBuckets.create(db, "images"); MongoCollection imagesCollection = db.getCollection("images.files"); GridFSUploadOptions options = new GridFSUploadOptions().metadata(new Document("featured", featured)); @@ -133,12 +145,12 @@ public String putImage(String imageb64, String fileName, Boolean featured) { bucket.delete(id); } id = bucket.find(query).first().getObjectId(); - return id.toHexString(); + + return new Response().withCode(200).withAllowPostMethodHeader().withBody("{\"uploadedID\": \"" + id.toHexString() + "\"}"); } catch(Exception e) { - System.out.println(e); - // TODO: better error handling - return "Error" + e; + e.printStackTrace(); + return new Response().withBody("{\"error\": " + e + "}"); } } @@ -147,9 +159,9 @@ public String putImage(String imageb64, String fileName, Boolean featured) { * @param dateString a datestring formatted mmddyy, i.e. 092123 for September 21, 2023 * @param imageIDs a JSON array of hex string _ids, i.e. ["65128e7bf44ec02f9eac0f66", "65128e7bf44ec02f9eac0f67", "65128e7bf44ec02f9eac0f68"] * @param description a description of the day's meeting - * @return the hex string _id of the inserted post document + * @return a new Response object with the hex string _id of the inserted post document as the body */ - public String putPost(String dateString, JSONArray imageIDs, String description) { + public Response putPost(String dateString, JSONArray imageIDs, String description) { try { ArrayList ids = new ArrayList(); for(int i = 0; i < imageIDs.length(); i++) { @@ -158,11 +170,12 @@ public String putPost(String dateString, JSONArray imageIDs, String description) MongoCollection posts = db.getCollection("posts"); InsertOneResult result = posts.insertOne(new Document("dateString", dateString).append("images", new BsonArray(ids)).append("description", description)); - return result.getInsertedId().asObjectId().getValue().toHexString(); + + return new Response().withCode(200).withAllowPostMethodHeader().withBody("{\"uploadedID\": \"" + result.getInsertedId().asObjectId().getValue().toHexString() + "\"}"); } catch(Exception e) { - System.out.println(e); - return "Error" + e; + e.printStackTrace(); + return new Response().withBody("{\"error\": " + e + "}"); } } @@ -175,7 +188,6 @@ public String putPost(String dateString, JSONArray imageIDs, String description) public boolean checkCredentials(String uname, String pword) { boolean valid = false; try { - MongoCollection auth = db.getCollection("auth"); Document creds = auth.find().first(); Set keys = creds.keySet(); @@ -201,9 +213,9 @@ public boolean checkCredentials(String uname, String pword) { /** * Creates a random eight-character string that will serve as an access token for verified users. - * @return the random eight-character access token. + * @return a Response object containing the random eight-character access token as the body. */ - public String createToken(String username) { + public Response createToken(String username) { Random generator = new Random(); long seed = generator.nextInt(10000) * username.hashCode(); generator.setSeed(seed); @@ -212,15 +224,16 @@ public String createToken(String username) { .collect(StringBuilder::new, StringBuilder::appendCodePoint, StringBuilder::append) .toString(); try { - MongoCollection auth = db.getCollection("auth"); auth.findOneAndUpdate(Filters.empty(), Updates.push("activeSessions", new Document("token", generatedString).append("dateTime", new BsonDateTime(new Date().getTime())).append("username", username))); + + return new Response().withCode(200).withAllowGetMethodHeader().withBody("{\"token\": \"" + generatedString + "\"}"); } catch(Exception e) { e.printStackTrace(); + return new Response().withBody("{\"error\": " + e + "}"); } - return generatedString; } /** @@ -231,10 +244,12 @@ public String createToken(String username) { public boolean checkToken(String token) { boolean valid = false; try { - MongoCollection auth = db.getCollection("auth"); Document creds = auth.find().first(); + + @SuppressWarnings("unchecked") ArrayList active = (ArrayList) creds.get("activeSessions"); + for(Document session : active) { if(session.get("token").equals(token)) { valid = true; @@ -252,7 +267,6 @@ public boolean checkToken(String token) { */ public void deleteExpired() { try { - MongoCollection auth = db.getCollection("auth"); auth.findOneAndUpdate(Filters.empty(), Updates.pull("activeSessions", Filters.lt("dateTime", new BsonDateTime(new Date().getTime() - 3 * 60 * 60 * 1000)))); // 3 * 60 * 60 * 1000 = 3 hours diff --git a/src/main/java/Routes.java b/src/main/java/Routes.java index 46ef8e1..bc0a711 100644 --- a/src/main/java/Routes.java +++ b/src/main/java/Routes.java @@ -1,20 +1,20 @@ +import org.json.JSONArray; import org.json.JSONObject; import spark.Request; import spark.Response; import spark.Route; +import spark.Spark; +import spark.routematch.RouteMatch; public class Routes { - public final Route routeDate, routeAll, routeImage, routeUploadImage, routeUploadPost, routeOptions, routeAuth, routeAuthCheck; + public final Route routeDate, routeAll, routeImage, routeUploadImage, routeUploadPost, routeOptions, routeAuth, routeAuthCheck, routeEndpoints; public Routes(Mongo mongo, Crypto auth) { // Returns the post that corresponds with the given date. this.routeDate = new Route() { @Override public Object handle(Request request, Response response) { - response.status(200); - response.body(mongo.getPost(request.params("date"))); - response.header("Access-Control-Allow-Origin", "*"); - response.header("Access-Control-Allow-Methods", "GET"); + mongo.getPost(request.params("date")).dumpToSparkResponse(response); return response.body(); } }; @@ -22,10 +22,7 @@ public Object handle(Request request, Response response) { this.routeAll = new Route() { @Override public Object handle(Request request, Response response) { - response.status(200); - response.body("{\"posts\": " + mongo.getAllPosts().toString() + "}"); - response.header("Access-Control-Allow-Origin", "*"); - response.header("Access-Control-Allow-Methods", "GET"); + mongo.getAllPosts().dumpToSparkResponse(response); return response.body(); } }; @@ -33,10 +30,7 @@ public Object handle(Request request, Response response) { this.routeImage = new Route() { @Override public Object handle(Request request, Response response) { - response.status(200); - response.body(mongo.getImage(request.params("oidString"))); - response.header("Access-Control-Allow-Origin", "*"); - response.header("Access-Control-Allow-Methods", "GET"); + mongo.getImage(request.params("oidString")).dumpToSparkResponse(response); return response.body(); } }; @@ -46,16 +40,11 @@ public Object handle(Request request, Response response) { public Object handle(Request request, Response response) { if(mongo.checkToken(request.headers("Authorization").substring("Bearer ".length()))) { JSONObject json = new JSONObject(request.body()); - String id = mongo.putImage(json.get("data").toString(), json.get("fileName").toString(), json.getBoolean("featured")); - response.status(200); - response.body("{\"uploadedID\": \"" + id + "\"}"); + mongo.postImage(json.get("data").toString(), json.get("fileName").toString(), json.getBoolean("featured")).dumpToSparkResponse(response); } else { - response.status(401); - response.body("{\"error\": \"401: You do not have authorization to view this information.\"}"); + httputils.Response.unauthorizedError().dumpToSparkResponse(response); } - response.header("Access-Control-Allow-Origin", "*"); - response.header("Access-Control-Allow-Methods", "GET"); return response.body(); } }; @@ -63,24 +52,19 @@ public Object handle(Request request, Response response) { this.routeUploadPost = new Route() { @Override public Object handle(Request request, Response response) { - response.header("Access-Control-Allow-Origin", "*"); mongo.deleteExpired(); try { if(mongo.checkToken(request.headers("Authorization").substring("Bearer ".length()))) { - response.status(200); - JSONObject json = new JSONObject(request.body()); - String id = mongo.putPost(json.getString("dateString"), json.getJSONArray("images"), json.getString("description")); - response.body("{\"uploadedID\": \"" + id + "\"}"); + mongo.putPost(json.getString("dateString"), json.getJSONArray("images"), json.getString("description")).dumpToSparkResponse(response); } else { - response.status(401); - response.body("{\"error\": \"401: You do not have authorization to view this information.\"}"); + httputils.Response.unauthorizedError().dumpToSparkResponse(response); } } catch(Exception e) { e.printStackTrace(); - response.body("{\"error\": \"" + e.getMessage() + "\"}"); + httputils.Response.defaultServerError(e); } return response.body(); } @@ -89,12 +73,11 @@ public Object handle(Request request, Response response) { this.routeOptions = new Route() { @Override public Object handle(Request request, Response response) { - response.header("Access-Control-Allow-Origin", "*"); - response.header("Access-Control-Allow-Methods", "*"); - response.header("Access-Control-Allow-Headers", "Authorization"); - response.header("Access-Control-Allow-Credentials", "true"); - response.status(200); - return ""; + new httputils.Response().withCode(200) + .withAllowAllMethodsHeader() + .withHeader("Access-Control-Allow-Headers", "Authorization") + .withHeader("Access-Control-Allow-Credentials", "true").dumpToSparkResponse(response); + return response.body(); } }; // Checks whether the given encrypted username and password correspond with a user in the database. @@ -103,24 +86,24 @@ public Object handle(Request request, Response response) { @Override public Object handle(Request request, Response response) { mongo.deleteExpired(); - response.header("Access-Control-Allow-Origin", "*"); try { String creds = auth.decrypt(request.headers("authorization").substring("Basic ".length())); String uname = creds.split(":")[0]; String pword = creds.split(":")[1]; if(mongo.checkCredentials(uname, pword) || mongo.checkCredentials(uname, pword)) { - response.status(200); - return "{\"token\": \"" + mongo.createToken(uname) + "\"}"; + mongo.createToken(uname).dumpToSparkResponse(response); } else { - response.status(401); - return "{\"error\": \"401: Wrong username or password.\"}"; + httputils.Response.unauthorizedError() + .withBody(new JSONObject().put("error", "Username and/or password are incorrect.")) + .dumpToSparkResponse(response); } + return response.body(); } catch(Exception e) { e.printStackTrace(); - response.status(500); - return "{\"error\": \"" + e.getMessage() + "\"}"; + httputils.Response.defaultServerError(e).dumpToSparkResponse(response); + return response.body(); } } }; @@ -129,22 +112,33 @@ public Object handle(Request request, Response response) { @Override public Object handle(Request request, Response response) { mongo.deleteExpired(); - response.header("Access-Control-Allow-Origin", "*"); try { if(mongo.checkToken(request.headers("Authorization").substring("Bearer ".length()))) { - response.status(200); - return "{\"status\": \"ok\"}"; + new httputils.Response().withCode(200).dumpToSparkResponse(response); + return response.body(); } else { - response.status(401); - return "{\"error\": \"401: Wrong username or password.\"}"; + httputils.Response.unauthorizedError() + .withBody(new JSONObject().put("error", "Your session is invalid. Please log in again.")) + .dumpToSparkResponse(response); + return response.body(); } } catch(Exception e) { e.printStackTrace(); - response.status(500); - return "{\"error\": \"" + e.getMessage() + "\"}"; + httputils.Response.defaultServerError(e).dumpToSparkResponse(response); + return response.body(); + } + } + }; + this.routeEndpoints = new Route() { + public Object handle(Request request, Response response) { + JSONArray json = new JSONArray(); + for(RouteMatch route : Spark.routes()) { + json.put(new JSONObject().put("url", route.getMatchUri()).put("method", route.getHttpMethod())); } + new httputils.Response().withCode(200).withAllowGetMethodHeader().withBody(json).dumpToSparkResponse(response); + return response.body(); } }; } diff --git a/src/main/java/httputils/Response.java b/src/main/java/httputils/Response.java new file mode 100644 index 0000000..4bae3e6 --- /dev/null +++ b/src/main/java/httputils/Response.java @@ -0,0 +1,258 @@ +package httputils; +import java.util.Hashtable; + +import org.json.JSONArray; +import org.json.JSONObject; + +public class Response { + private int code; + private String body; + private Hashtable headers; + + /** + * Creates a default response with an empty body, no headers, and a code 500. + * The default code being 500 is intended to not only make error handling easier, + * but also to ensure that HTTP codes are being set and used with intention rather + * than ignoring or being lazy with them. + */ + public Response() { + this(500, "", new Hashtable<>()); + } + + /** + * The full constructor for a response with a code, body, and headers. + *

+ * Note that the desired structure in this codebase is a fluent interface architecture, + * so the no-parameter constructor is preferred heavily. + * For the same reason, there are no other options for constructors with different parameters. + * @param code an HTTP response code (ex. 200 or 401) + * @param body the body of the HTTP response, preferably as a String containing JSON. + * @param headers a hashtable containing key-value pairs of HTTP headers. + */ + public Response(int code, String body, Hashtable headers) { + this.code = code; + this.body = body; + this.headers = headers; + this.withAllowOriginAllHeader().withContentTypeJSONHeader(); + } + + /** + * Sets the response code to the given response code and returns this. + * @param code the desired response code. + * @return the Response object with the new response code. + */ + public Response withCode(int code) { + this.setCode(code); + return this; + } + + /** + * Sets the body to the given String and returns this. + * @param body the desired body (preferrably as a String containing JSON). + * @return the Response object with the new body. + */ + public Response withBody(String body) { + this.setBody(body); + return this; + } + + /** + * Sets the body to the string representation of the given JSON and returns + * this. + * @param code the desired body as a JSONObject + * @return the Response object with the new body. + */ + public Response withBody(JSONObject body) { + this.setBody(body.toString()); + return this; + } + + /** + * Sets the body to the string representation of the given JSON and returns + * this. + * @param code the desired body as a JSONArray + * @return the Response object with the new body. + */ + public Response withBody(JSONArray body) { + this.setBody(body.toString()); + return this; + } + + /** + * Sets the body to json containing error text. + * @param userFriendlyErrorMessage + * @param logMessage + * @return + */ + public Response withErrorBody(String userFriendlyErrorMessage, String logMessage) { + this.setBody(new JSONObject() + .put("error", userFriendlyErrorMessage) + .put("fullError", logMessage) + .toString() + ); + return this; + } + + public Response withErrorBody(String userFriendlyErrorMessage) { + this.setBody(new JSONObject() + .put("error", userFriendlyErrorMessage) + .put("fullError", "") + .toString() + ); + return this; + } + + /** + * Sets the headers table to the given hashtable and returns this. + * This hashtable should contain only key-value pairs of HTTP headers. + * @param headers the desired headers table. + * @return the Response object with the new headers. + */ + public Response withHeaders(Hashtable headers) { + this.setHeaders(headers); + return this; + } + + /** + * Sets the specified header in the headers table to the given value + * and returns this. + * @param key the name of the header to add or modify. + * @param value the new value of the header. + * @return the Response object with the new header. + */ + public Response withHeader(String key, Object value) { + this.setHeader(key, value); + return this; + } + + /** + * Adds the Access-Control-Allow-Methods header to the headers table with + * the value "GET", then returns this. + * @return the Response object with the Access-Control-Allow-Methods header set to "GET". + */ + public Response withAllowGetMethodHeader() { + this.setHeader("Access-Control-Allow-Methods", "GET"); + return this; + } + + /** + * Adds the Access-Control-Allow-Methods header to the headers table with + * the value "POST", then returns this. + * @return the Response object with the Access-Control-Allow-Methods header set to "POST". + */ + public Response withAllowPostMethodHeader() { + this.setHeader("Access-Control-Allow-Methods", "POST"); + return this; + } + + /** + * Adds the Access-Control-Allow-Methods header to the headers table with + * the value "*", then returns this. + * @return the Response object with the Access-Control-Allow-Methods header set to "*". + */ + public Response withAllowAllMethodsHeader() { + this.setHeader("Access-Control-Allow-Methods", "*"); + return this; + } + + /** + * Adds the Access-Control-Allow-Origin header to the headers table with + * the value "*", then returns this. + * @return the Response object with the Access-Control-Allow-Origin header set to "*" + */ + public Response withAllowOriginAllHeader() { + this.setHeader("Access-Control-Allow-Origin", "*"); + return this; + } + + /** + * Adds the Content-Type header to the headers table with the value + * "application/json", then returns this. + * @return the Response object with the Content-Type header set to "application/json". + */ + public Response withContentTypeJSONHeader() { + this.setHeader("Content-Type", "application/json"); + return this; + } + + /** + * @param code the new code for this Response to use. + */ + public void setCode(int code) { + this.code = code; + } + + /** + * @param body the new body for this Response to use, preferrably as + * a String containing JSON. + */ + public void setBody(String body) { + this.body = body; + } + + /** + * @param headers a hashtable containing only key-value pairs of HTTP headers. + */ + public void setHeaders(Hashtable headers) { + this.headers = headers; + } + + /** + * Sets the header to the desired value. + * @param key the name of the HTTP header. + * @param value the new value of the HTTP header. + */ + public void setHeader(String key, Object value) { + this.headers.put(key, value); + } + + public int getCode() { + return this.code; + } + + public String getBody() { + return this.body; + } + + public Hashtable getHeaders() { + return this.headers; + } + + public Object getHeader(String key) { + return this.headers.get(key); + } + + /** + * Copies the body, headers, and response code to the given Spark response. + * The responseShell is modified by this method, so the return value is not actually + * necessary, but it is returned for your convenience. + * @param responseShell A spark response object to modify. This is needed + * because the new spark.Response() + * constructor is not visible. + * @return the {@link spark.Response} object with all of the data from this object. + */ + public spark.Response dumpToSparkResponse(spark.Response responseShell) { + responseShell.status(this.code); + headers.forEach((key, value) -> { + responseShell.header(key, value.toString()); + }); + responseShell.body(body); + return responseShell; + } + + /** + * @param e the exception that caused the 500 error. + * @return a generic 500 error with the Java exception message as the body. + */ + public static Response defaultServerError(Exception e) { + return new Response().withBody(new JSONObject().put("error", e.getMessage())); + } + + /** + * @return a generic 401 unauthorized error (user does not have a valid session + * or logged in with invalid credentials) + */ + public static Response unauthorizedError() { + return new Response().withCode(401).withBody(new JSONObject().put("error", "401: You do not have authorization to view or edit this information.")); + } +}