"use strict"; plugin.consumes = [ "api", "passport", "connect", "connect.render", "connect.render.ejs", "connect.remote-address", "vfs.cache", "analytics" ]; plugin.provides = [ "vfs.server" ]; module.exports = plugin; /** * VFS session: * - unique vfsid * - bound to the client sessionId * - either readonly or read/write * - auto disposes after N seconds of idle time * - keeps: * - vfs-ssh instance * - engine.io instance (only one socket connected at a time) * - vfs rest API instance * * - authentication using tokens or auth headers (no cookies) */ function plugin(options, imports, register) { var api = imports.api; var cache = imports["vfs.cache"]; var passport = imports.passport; var connect = imports.connect; var render = imports["connect.render"]; var analytics = imports["analytics"]; var Types = require("frontdoor").Types; var error = require("http-error"); var kaefer = require("kaefer"); var ratelimit = require("c9/ratelimit"); var requestTimeout = require("c9/request_timeout"); var section = api.section("vfs"); var VFS_ACTIVITY_WINDOW = 1000 * 60 * 60; section.registerType("vfsid", new Types.RegExp(/[a-zA-Z0-9]{16}/)); section.registerType("pid", new Types.Number(0)); // admin interface api.use(render.setTemplatePath(__dirname + "/views")); api.get("/:status", { params: { status: { type: /^vfs(?:\.(?:json|html))?$/, source: "url" } } }, [ api.ensureAdmin(), function(req, res, next) { var type = req.params.status.split(".")[1] || "html"; var entries = cache.getAll(); var data = { entries: [] }; for (var key in entries) { var entry = entries[key]; data.entries.push({ vfsid: entry.vfsid, pid: entry.pid, uid: entry.user.id, ttl: entry.ttl, readonly: entry.vfs ? entry.vfs.readonly : "", state: entry.vfs ? "connected" : "connecting", startTime: entry.startTime, connectTime: entry.connectTime || -1 }); } if (type == "json") res.json(data); else res.render("status.html.ejs", data, next); } ]); // creates a new connection for the specified project section.post("/:pid", { params: { "pid": { type: "pid" }, "version": { type: "string", source: "body", optional: false } } }, [ api.authenticate(), ratelimit("pid", 60 * 1000, 30), function(req, res, next) { var pid = req.params.pid; var version = req.params.version; var user = req.user; trackActivity(user, req); if (version != kaefer.version.protocol) { var err = new error.PreconditionFailed("Wrong VFS protocol version. Expected version '" + kaefer.version.protocol + "' but found '" + version + "'"); err.subtype = "protocol_mismatch"; err.clientVersion = version; err.serverVersion = kaefer.version.protocol; return next(err); } var done = false; var cancel = cache.create(pid, user, function(err, entry) { if (done) return; if (err) return next(err); res.json({ pid: pid, uid: user.id, readonly: entry.vfs.readonly, vfsid: entry.vfsid, activation: entry.vfs.activation || {} }, null, 201); }); // if the clients aborts the request we have to kill the ssh process req.on("close", function() { done = true; cancel(); if(!res.headersSent) res.json({}, 0, 500); }); } ]); // checks if the connection exists and returns connection meta data section.get("/:pid/:vfsid", { params: { "pid": { type: "pid" }, "vfsid": { type: "vfsid" } } }, function(req, res, next) { var pid = req.params.pid; var vfsid = req.params.vfsid; var entry = cache.get(vfsid); if (!entry) { var err = new error.PreconditionFailed("VFS connection does not exist"); err.code = 499; return next(err); } res.json({ pid: pid, vfsid: vfsid, uid: entry.user.id }); }); // read only rest interface section.get("/:pid/plugins/:access_token/:path*", { "access_token": { type: "string" }, "pid": { type: "pid" }, "path": { type: "string" } }, [ requestTimeout(15 * 60 * 1000), connect.getModule().compress(), function(req, res, next) { req.query = { access_token: req.params["access_token"] }; passport.authenticate("bearer", { session: false }, function(err, user) { if (err) return next(err); req.user = user || { id: -1 }; next(); })(req, res, next); }, function(req, res, next) { var pid = req.params.pid; var path = req.params.path; var user = req.user; trackActivity(user, req); if (path.indexOf("../") !== -1) return next(new error.BadRequest("invalid path")); cache.readonlyRest(pid, user, "/.c9/plugins/" + path, "home", req, res, next); } ]); section.get("/:pid/preview/:path*", { "pid": { type: "pid" }, "path": { type: "string" } }, [ requestTimeout(15 * 60 * 1000), connect.getModule().compress(), function(req, res, next) { passport.authenticate("bearer", { session: false }, function(err, user) { if (err) return next(err); req.user = user || { id: -1 }; next(); })(req, res, next); }, function(req, res, next) { var pid = req.params.pid; var path = req.params.path; var user = req.user; cache.readonlyRest(pid, user, path, "workspace", req, res, next); } ]); // disconnects VFS connection section.delete("/:pid/:vfsid", { params: { "pid": { type: "pid" }, "vfsid": { type: "vfsid" } } }, function(req, res, next) { var vfsid = req.params.vfsid; cache.remove(vfsid); res.json({}, null, 201); }); // REST API // serves all files with mime type "text/plain" // real mime type will be in "X-C9-ContentType" section.all("/:pid/:vfsid/:scope/:path*", { params: { "pid": { type: "pid" }, "vfsid": { type: "vfsid" }, "scope": { type: /^(home|workspace)$/ }, "path": { type: "string" } } }, [ function(req, res, next) { var vfsid = req.params.vfsid; var scope = req.params.scope; var path = req.params.path; var entry = cache.get(vfsid); if (!entry) { var err = new error.PreconditionFailed("VFS connection does not exist"); err.code = 499; return next(err); } // TODO: use an interval to make sure this fires // even when this REST api is not used for a day trackActivity(entry.user, req); entry.vfs.handleRest(scope, path, req, res, next); } ]); // engine.io endpoint of the VFS server section.all("/:pid/:vfsid/socket/:path*", { params: { "pid": { type: "pid" }, "vfsid": { type: "vfsid" }, "path": { type: "string" } } }, [ requestTimeout(15 * 60 * 1000), function handleEngine(req, res, next) { var vfsid = req.params.vfsid; var entry = cache.get(vfsid); if (!entry) { var err = new error.PreconditionFailed("VFS connection does not exist"); err.code = 499; return next(err); } entry.vfs.handleEngine(req, res, next); } ]); function trackActivity(user, req) { if (user.id === -1) return; if (new Date(user.lastVfsAccess).getDate() != new Date().getDate() || Date.now() > user.lastVfsAccess + VFS_ACTIVITY_WINDOW) { analytics.superagent && analytics.superagent .post(options.apiBaseUrl + "/metric/usage/" + req.params.pid + "?access_token=" + req.query.access_token) .end(function() {}); user.lastVfsAccess = Date.now(); user.save && user.save(function() {}); } } function handlePublish(vfs, messageString) { var message = JSON.parse(messageString); switch (message.action) { case "remove_member": case "update_member_access": handleProjectMemberAccessChange(vfs, message); break; case "project_changed": handleProjectVisibilityChanged(vfs, message); default: break; } } function handleProjectMemberAccessChange(vfs, message) { if (vfs.uid !== message.body.uid) return; console.log("Removing ", vfs.id, " for user ", vfs.uid, " project ", vfs.pid, " from the vfs connection cache"); // Remove next tick so client has time to recieve final "You've been removed" PubSub message. setTimeout(function() { cache.remove(vfs.id); }, 100); } function handleProjectVisibilityChanged(vfs, message) { if (vfs.uid == message.body.owner) return; if ((message.body.visibility && message.body.visibility == "private") || (message.body.appAccess && message.body.appAccess == "private")) { console.log("Project ", vfs.pid, " recieved message: ", message.body, ". Killing connection of user ", vfs.uid); cache.remove(vfs.id); } } register(null, { "vfs.server": { get section() { return section; }, get handlePublish() { return handlePublish; } } }); }