package elw.web; import com.google.common.base.Strings; import com.google.common.io.ByteStreams; import com.google.common.io.InputSupplier; import elw.dao.Auth; import elw.dao.Queries; import elw.dao.QueriesSecure; import elw.dao.ctx.CtxSlot; import elw.dao.ctx.CtxSolution; import elw.dao.rest.RestEnrollment; import elw.dao.rest.RestEnrollmentSummary; import elw.dao.rest.RestSolution; import elw.miniweb.Message; import elw.miniweb.ViewJackson; import elw.vo.*; import elw.web.core.Core; import elw.web.core.W; import org.akraievoy.couch.Squab; import org.apache.commons.fileupload.FileItemIterator; import org.apache.commons.fileupload.FileItemStream; import org.apache.commons.fileupload.FileUploadException; import org.apache.commons.fileupload.disk.DiskFileItemFactory; import org.apache.commons.fileupload.servlet.ServletFileUpload; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.stereotype.Controller; import org.springframework.web.bind.WebDataBinder; import org.springframework.web.bind.annotation.InitBinder; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMethod; import org.springframework.web.servlet.ModelAndView; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import javax.servlet.http.HttpSession; import java.io.IOException; import java.io.InputStream; import java.util.*; /* * /auth // LATER specify PUT for admin impersonation * /auths // LATER specify * * /challenges // LATER specify * * * // TODO auth * /challenge * GET /iasa@akraievoy.org/13989823443 * -> 200 {"status": "response sent", "challenge": "SHA512", "expiry": 13989823443} (response sent to email) * GET /iasa@akraievoy.org/13989823443 * -> 400 {"status": "failed to send response"} * GET /iasa@akraievoy.org/13989823443 * -> 400 {"status": "flood/bruteforce protection", "expiry": 139898298989} * POST /iasa@akraievoy.org {"challenge": "SHA512", "expiry": 13989823443, "response": "SHA512"} * -> 200 {"status": "logged in"} * -> 400 {"status": "challenge failed"} * challenge = SHA512(salt, email, millis) * response = SHA512(salt, email, sessionID, request.sourceAddr, expiry, challenge) * * * TODO: SCORES * get last score for solution * get all scores for solution * list solutions of the same version across the sustem * get reference to next checked solution: switching strategies * * LATER: COMMENTS */ @Controller @RequestMapping("/rest/**/*") public class ControllerRest extends ControllerElw { public static enum ListStyle{IDS, MAP} private static final Logger log = LoggerFactory.getLogger(ControllerRest.class); private static final DiskFileItemFactory fileItemFactory = createFileItemFactory(); private final boolean devMode; private long testAuthSwitch = 0; public ControllerRest( final Core core, final ElwServerConfig elwServerConfig ) { super(core, elwServerConfig); // LATER devMode inferencing booster devMode = "w".equals(System.getProperty("user.name")); } private static DiskFileItemFactory createFileItemFactory() { final DiskFileItemFactory fileItemFactory = new DiskFileItemFactory(); fileItemFactory.setRepository( new java.io.File(System.getProperty("java.io.tmpdir")) ); fileItemFactory.setSizeThreshold(1 * 1024 * 1024); return fileItemFactory; } @InitBinder public void initBinder(WebDataBinder binder) { binder.registerCustomEditor( ListStyle.class, new EnumPropertyEditor(ListStyle.values()) ); } @RequestMapping( value = "SessionMessage", method = RequestMethod.GET ) public ModelAndView do_SessionMessageGet( final HttpServletRequest req, final HttpServletResponse resp ) throws IOException { return new ModelAndView(ViewJackson.data(Message.getMessages(req))); } @RequestMapping( value = "SessionMessage/{stamp}", method = RequestMethod.DELETE ) public ModelAndView do_SessionMessageDelete( final HttpServletRequest req, final HttpServletResponse resp, @PathVariable("stamp") final String stamp ) throws IOException { Message.delete(req, stamp); return null; } @RequestMapping( value = "auth", method = RequestMethod.GET ) public ModelAndView do_authGet( final HttpServletRequest req, final HttpServletResponse resp ) throws IOException { final HashMap<String, Object> model = auth(req, resp, false, false); if (model == null) { return null; } return new ModelAndView(ViewJackson.data(model.get(Auth.MODEL_KEY))); } // TODO this quite likely is ok to be admin-only // TODO otherwise it should be proxied via clone and filtered for students @RequestMapping( value = "courses/{listStyle}", method = RequestMethod.GET ) public ModelAndView do_coursesGet( final HttpServletRequest req, final HttpServletResponse resp, @PathVariable("listStyle") final ListStyle listStyle ) throws IOException { final HashMap<String, Object> model = auth(req, resp, false, false); if (model == null) { return null; } final Queries queries = (Queries) model.get(QueriesSecure.MODEL_KEY); final List<String> courseIds = queries.courseIds(); if (ListStyle.IDS == listStyle) { return new ModelAndView(ViewJackson.data(courseIds)); } final Map<String, Course> courses = new TreeMap<String, Course>(); for (String courseId : courseIds) { courses.put(courseId, queries.course(courseId)); } return new ModelAndView(ViewJackson.data(courses)); } @RequestMapping( value = "course/{courseId}", method = RequestMethod.GET ) public ModelAndView do_courseGet( final HttpServletRequest req, final HttpServletResponse resp, @PathVariable("courseId") final String courseId ) throws IOException { final HashMap<String, Object> model = auth(req, resp, false, false); if (model == null) { return null; } final Queries queries = (Queries) model.get(QueriesSecure.MODEL_KEY); final Course course = queries.course(courseId); if (course == null) { resp.sendError(HttpServletResponse.SC_NOT_FOUND); return null; } return new ModelAndView(ViewJackson.data(course)); } @RequestMapping( value = "groups/{listStyle}", method = RequestMethod.GET ) public ModelAndView do_groupsGet( final HttpServletRequest req, final HttpServletResponse resp, @PathVariable("listStyle") final ListStyle listStyle ) throws IOException { final HashMap<String, Object> model = auth(req, resp, false, false); if (model == null) { return null; } final Queries queries = (Queries) model.get(QueriesSecure.MODEL_KEY); final List<String> groupIds = queries.groupIds(); if (ListStyle.IDS == listStyle) { return new ModelAndView(ViewJackson.data(groupIds)); } final Map<String, Group> groups = new TreeMap<String, Group>(); for (String groupId : groupIds) { groups.put(groupId, queries.group(groupId)); } return new ModelAndView(ViewJackson.data(groups)); } @RequestMapping( value = "group/{groupId}", method = RequestMethod.GET ) public ModelAndView do_groupGet( final HttpServletRequest req, final HttpServletResponse resp, @PathVariable("groupId") final String groupId ) throws IOException { final HashMap<String, Object> model = auth(req, resp, false, false); if (model == null) { return null; } final Queries queries = (Queries) model.get(QueriesSecure.MODEL_KEY); final Group group = queries.group(groupId); if (group == null) { resp.sendError(HttpServletResponse.SC_NOT_FOUND); return null; } return new ModelAndView(ViewJackson.data(group)); } @RequestMapping( value = "enrollments/{listStyle}", method = RequestMethod.GET ) public ModelAndView do_enrollmentsGet( final HttpServletRequest req, final HttpServletResponse resp, @PathVariable("listStyle") final ListStyle listStyle ) throws IOException { final HashMap<String, Object> model = auth(req, resp, false, false); if (model == null) { return null; } final Queries queries = (Queries) model.get(QueriesSecure.MODEL_KEY); final List<String> enrIds = queries.enrollmentIds(); if (ListStyle.IDS == listStyle) { return new ModelAndView(ViewJackson.data(enrIds)); } final Map<String, RestEnrollment> enrollments = new TreeMap<String, RestEnrollment>(); for (String enrId : enrIds) { final RestEnrollment restEnrollment = queries.restEnrollment(enrId, W.resolveRemoteAddress(req)); enrollments.put(enrId, restEnrollment); } return new ModelAndView(ViewJackson.data(enrollments)); } @RequestMapping( value = "enrollment/{enrId}", method = RequestMethod.GET ) public ModelAndView do_enrollmentGet( final HttpServletRequest req, final HttpServletResponse resp, @PathVariable("enrId") final String enrId ) throws IOException { final HashMap<String, Object> model = auth(req, resp, false, false); if (model == null) { return null; } final Queries queries = (Queries) model.get(QueriesSecure.MODEL_KEY); final RestEnrollment restEnrollment = queries.restEnrollment(enrId, W.resolveRemoteAddress(req)); if (restEnrollment == null) { resp.sendError(HttpServletResponse.SC_NOT_FOUND); return null; } return new ModelAndView(ViewJackson.data(restEnrollment)); } @RequestMapping( value = "enrollment/{enrId}/scores", method = RequestMethod.GET ) public ModelAndView do_enrollmentScoringGet( final HttpServletRequest req, final HttpServletResponse resp, @PathVariable("enrId") final String enrId ) throws IOException { final HashMap<String, Object> model = auth(req, resp, false, false); if (model == null) { return null; } final Queries queries = (Queries) model.get(QueriesSecure.MODEL_KEY); final RestEnrollmentSummary enrSummary = queries.restScores(enrId, null); if (enrSummary == null) { resp.sendError(HttpServletResponse.SC_NOT_FOUND); return null; } return new ModelAndView(ViewJackson.data(enrSummary)); } @RequestMapping( value = "enrollment/{enrId}/solutions/{listStyle}", method = RequestMethod.GET ) public ModelAndView do_enrollmentSolutionsGet( final HttpServletRequest req, final HttpServletResponse resp, @PathVariable("enrId") final String enrId, @PathVariable("listStyle") final ListStyle listStyle ) throws IOException { final HashMap<String, Object> model = auth(req, resp, false, false); if (model == null) { return null; } final elw.dao.SolutionFilter filter = RestSolutionFilter.fromRequest(req); if (filter == null) { resp.sendError(HttpServletResponse.SC_BAD_REQUEST); return null; } final Queries queries = (Queries) model.get(QueriesSecure.MODEL_KEY); final Map<String, RestSolution> enrSolutions = queries.restSolutions(enrId, filter); if (enrSolutions == null) { resp.sendError(HttpServletResponse.SC_NOT_FOUND); return null; } if (listStyle == ListStyle.IDS) { return new ModelAndView(ViewJackson.data(enrSolutions.keySet())); } return new ModelAndView(ViewJackson.data(enrSolutions)); } // Regex is required here: solId may contain '.' // http://stackoverflow.com/questions/3526523/spring-mvc-pathvariable-getting-truncated @RequestMapping( value = "enrollment/{enrId}/solution/{solId:.+}", method = RequestMethod.GET ) public ModelAndView do_enrollmentSolutionGet( final HttpServletRequest req, final HttpServletResponse resp, @PathVariable("enrId") final String enrId, @PathVariable("solId") final String solId ) throws IOException { final HashMap<String, Object> model = auth(req, resp, false, false); if (model == null) { return null; } final Queries queries = (Queries) model.get(QueriesSecure.MODEL_KEY); final RestSolution solution = queries.restSolution(enrId, solId, null); if (solution == null) { resp.sendError(HttpServletResponse.SC_NOT_FOUND); return null; } return new ModelAndView(ViewJackson.data(solution)); } // PUTs are not supported for file uploads hosted by HTML forms // http://stackoverflow.com/questions/812711/how-do-you-do-an-http-put // http://stackoverflow.com/questions/2006900/browser-based-webdav-client // but there's hope they would be someday // https://www.w3.org/Bugs/Public/show_bug.cgi?id=10671 @RequestMapping( value = "enrollment/{enrId}/solution/{solId:.+}", method = RequestMethod.POST ) public ModelAndView do_enrollmentSolutionPost( final HttpServletRequest req, final HttpServletResponse resp, @PathVariable("enrId") final String enrId, @PathVariable("solId") final String solId ) throws IOException, FileUploadException { final HashMap<String, Object> model = auth(req, resp, false, false); if (model == null) { return null; } final Queries queries = (Queries) model.get(QueriesSecure.MODEL_KEY); final Auth auth = (Auth) model.get(Auth.MODEL_KEY); final Queries.CtxResolutionState stateSlot = queries.resolveSlot(enrId, solId, null); if (!stateSlot.complete()) { resp.sendError(HttpServletResponse.SC_BAD_REQUEST); return null; } final CtxSlot ctxSlot = stateSlot.ctxSlot; final SortedMap<String, FileType> allTypes = ctxSlot.slot.getFileTypes(); final SortedMap<String, FileType> validTypes = new TreeMap<String, FileType>(allTypes); // we may safely assume order over the elements sent // http://stackoverflow.com/questions/7449861/multipart-upload-form-is-order-guaranteed // see the w3 spec // http://www.w3.org/TR/html4/interact/forms.html#h-17.13.4 // not used for streaming/api calls but // still usable for content detection final int length = req.getContentLength(); final String stamp = Long.toString( Squab.Stamped.genStamp(), 36 ); final Solution solution = new Solution(); solution.setId(stamp); solution.setAuthor(auth.getName()); solution.setSourceAddress(W.resolveRemoteAddress(req)); final ServletFileUpload sfu = new ServletFileUpload(fileItemFactory); final FileItemIterator fii = sfu.getItemIterator(req); if (!fii.hasNext()) { // here we'll have to employ XHR and // http status message parsing // http://api.jquery.com/jQuery.ajax/ // esp. see the error(jqXHR, textStatus, errorThrown) resp.sendError( HttpServletResponse.SC_BAD_REQUEST, "No upload fields found" ); return null; } final FileItemStream comm = fii.next(); if (!comm.isFormField() || !"comment".equals(comm.getFieldName())) { resp.sendError( HttpServletResponse.SC_BAD_REQUEST, "No comment with your upload" ); return null; } solution.setComment(fieldText(comm)); if (!fii.hasNext()) { resp.sendError( HttpServletResponse.SC_BAD_REQUEST, "No file stream field found" ); return null; } final FileItemStream file = fii.next(); if (file.isFormField()) { resp.sendError( HttpServletResponse.SC_BAD_REQUEST, "Not a file stream field as expected" ); return null; } solution.setName(Strings.nullToEmpty(extractNameFromPath(file))); final String contentType = Strings.nullToEmpty(file.getContentType()); InputSupplier<InputStream> inputSupplier = supplierForFileItem(file); FileType._.filterByLength(validTypes, length); if (validTypes.isEmpty()) { resp.sendError( HttpServletResponse.SC_BAD_REQUEST, "Size " + org.akraievoy.base.Format.formatMem(length) + " exceeds size limits" ); return null; } FileType._.filterByName(validTypes, solution.getName().toLowerCase()); if (validTypes.isEmpty()) { resp.sendError( HttpServletResponse.SC_BAD_REQUEST, "File name failed all regex checks" ); return null; } if (validTypes.size() > 1) { log.warn( "More than one valid file type: {} yields {}", solution.getName(), validTypes.keySet() ); } final FileType fileType = validTypes.get(validTypes.firstKey()); if (!fileType.getContentTypes().contains(contentType)) { log.warn( "contentType {} not listed in the file type", contentType, fileType.getId() ); } solution.setFileType(IdNamed._.singleton(fileType)); if (!fileType.isBinary()) { if (length > FileBase.DETECT_SIZE_LIMIT) { resp.sendError( HttpServletResponse.SC_BAD_REQUEST, "Non-binary file is too big for content check" ); return null; } final byte[] bytes = ByteStreams.toByteArray(inputSupplier); // implemented as per this SO answer: // http://stackoverflow.com/questions/277521/identify-file-binary/277568#277568 for (byte b : bytes) { if (b >= 0 && b < 9 || b > 13 && b < 32) { resp.sendError( HttpServletResponse.SC_BAD_REQUEST, "Non-binary file contains binary data" ); return null; } } final InputSupplier<? extends InputStream> baisSupplier = ByteStreams.newInputStreamSupplier(bytes); //noinspection unchecked inputSupplier = (InputSupplier<InputStream>) baisSupplier; } else { // LATER validate binary headers of content here } // TODO there's no simple harness for this method // maybe there's a way to trigger this one properly with curl? boolean created = queries.createSolution( ctxSlot, solution, contentType, inputSupplier ); if (!created) { resp.sendError( HttpServletResponse.SC_INTERNAL_SERVER_ERROR, "Failed to save the file" ); } return null; } @RequestMapping( value = "enrollment/{enrId}/solution/{solId:.+}/{fileName:.+}", method = RequestMethod.GET ) public ModelAndView do_enrollmentSolutionGet( final HttpServletRequest req, final HttpServletResponse resp, @PathVariable("enrId") final String enrId, @PathVariable("solId") final String solId, @PathVariable("fileName") final String fileName ) throws IOException { final HashMap<String, Object> model = auth(req, resp, false, false); if (model == null) { return null; } final Queries queries = (Queries) model.get(QueriesSecure.MODEL_KEY); final CtxSolution ctxSolution = queries.resolveSolution(enrId, solId, null); if (ctxSolution == null) { resp.sendError(HttpServletResponse.SC_BAD_REQUEST); return null; } final InputSupplier<InputStream> contentSupplier = queries.solutionInput(ctxSolution, FileBase.CONTENT); // LATER ideally we should check that fileName is not empty and // conforms to restrictions of its content type / file type if (contentSupplier != null) { storeContentHeaders(ctxSolution.solution, resp); ByteStreams.copy(contentSupplier, resp.getOutputStream()); } else { resp.sendError(HttpServletResponse.SC_NOT_FOUND); } return null; } // not currently used, but may be useful for some of streaming requests // http://stackoverflow.com/questions/3686808/spring-3-requestmapping-get-path-value }