diff --git a/README.md b/README.md index 8accba9f..6762b28a 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,26 @@ -HackMD 0.2.8 +HackMD 0.2.9 === -This is a realtime collaborative markdown notes on all platforms. -But still in early stage, feel free to fork or contribute to it. +HackMD is a realtime collaborative markdown notes on all platforms. +Inspired by Hackpad, but more focusing on speed and flexibility. +Still in early stage, feel free to fork or contribute to this. -Thanks for your using! +Thanks for your using! :smile: +Dependency +--- +- PostgreSQL 9.3.6 or 9.4.1 +- MongoDB 3.0.2 + +Import db schema +--- +The notes are store in PostgreSQL, the schema is in the `hackmd_schema.sql` +To import the sql file in PostgreSQL, type `psql -i hackmd_schema.sql` + +The users, temps and sessions are store in MongoDB, which don't need schema, so just make sure you have the correct connection string. + +Config +--- There are some config you need to change in below files ``` ./run.sh @@ -13,13 +28,25 @@ There are some config you need to change in below files ./public/js/common.js ``` -You can use SSL to encrypt your site by passing certificate path in the `config.js` and set `usessl=true`. - -And there is a script called `run.sh`, it's for someone like me to run the server via npm package `forever`, and can passing environment variable to the server, like heroku does. +The script `run.sh`, it's for someone like me to run the server via npm package `forever`, and can passing environment variable to the server, like heroku does. To install `forever`, just type `npm install forever -g` -The notes are store in PostgreSQL, and I provided the schema in the `hackmd_schema.sql`. -The users and sessions are store in mongoDB, which don't need schema, so just connect it directly. +You can use SSL to encrypt your site by passing certificate path in the `config.js` and set `usessl=true` + +Run a server +--- +To run the server, type `bash run.sh` +Log will be at `~/.forever/hackmd.log` + +Stop a server +--- +To stop the server, simply type `forever stop hackmd` + +Backup db +--- +To backup the db, type `bash backup.sh` +Backup files will be at `./backups/` + **License under MIT.** \ No newline at end of file diff --git a/app.js b/app.js index d3984882..c5286974 100644 --- a/app.js +++ b/app.js @@ -5,6 +5,7 @@ var toobusy = require('toobusy-js'); var ejs = require('ejs'); var passport = require('passport'); var methodOverride = require('method-override'); +var cookieParser = require('cookie-parser'); var bodyParser = require('body-parser'); var mongoose = require('mongoose'); var compression = require('compression') @@ -14,9 +15,12 @@ var fs = require('fs'); var shortid = require('shortid'); var imgur = require('imgur'); var formidable = require('formidable'); +var morgan = require('morgan'); +var passportSocketIo = require("passport.socketio"); //core var config = require("./config.js"); +var logger = require("./lib/logger.js"); var User = require("./lib/user.js"); var Temp = require("./lib/temp.js"); var auth = require("./lib/auth.js"); @@ -45,7 +49,12 @@ if (config.usessl) { var app = express(); var server = require('http').createServer(app); } +//socket io listen var io = require('socket.io').listen(server); +//logger +app.use(morgan('combined', { + "stream": logger.stream +})); // connect to the mongodb mongoose.connect(process.env.MONGOLAB_URI || config.mongodbstring); @@ -65,6 +74,15 @@ var urlencodedParser = bodyParser.urlencoded({ extended: false }); +//session store +var sessionStore = new MongoStore({ + mongooseConnection: mongoose.connection, + touchAfter: config.sessiontouch + }, + function (err) { + console.log(err); + }); + //compression app.use(compression()); @@ -79,13 +97,7 @@ app.use(session({ expires: new Date(Date.now() + config.sessionlife), }, maxAge: new Date(Date.now() + config.sessionlife), - store: new MongoStore({ - mongooseConnection: mongoose.connection, - touchAfter: config.sessiontouch - }, - function (err) { - console.log(err); - }) + store: sessionStore })); //middleware which blocks requests when we're too busy @@ -293,6 +305,7 @@ app.get('/me', function (req, res) { var profile = JSON.parse(user.profile); res.send({ status: 'ok', + id: req.session.passport.user, name: profile.displayName || profile.username }); } @@ -317,7 +330,9 @@ app.post('/uploadimage', function (req, res) { .then(function (json) { if (config.debug) console.log('SERVER uploadimage success: ' + JSON.stringify(json)); - res.send({link:json.data.link}); + res.send({ + link: json.data.link + }); }) .catch(function (err) { console.error(err); @@ -337,6 +352,15 @@ app.get("/:noteId/:action", response.noteActions); //socket.io secure io.use(realtime.secure); +//socket.io auth +io.use(passportSocketIo.authorize({ + cookieParser: cookieParser, + key: config.sessionname, + secret: config.sessionsecret, + store: sessionStore, + success: realtime.onAuthorizeSuccess, + fail: realtime.onAuthorizeFail +})); //socket.io heartbeat io.set('heartbeat interval', config.heartbeatinterval); io.set('heartbeat timeout', config.heartbeattimeout); diff --git a/backup.sh b/backup.sh new file mode 100644 index 00000000..e7afc560 --- /dev/null +++ b/backup.sh @@ -0,0 +1,7 @@ +#!/bin/bash +path=./backups +today=$(date +"%Y%m%d") +timestamp=$(date +"%Y%m%d%H%M%S") +mkdir -p $path/$today +pg_dump hackmd > $path/$today/postgresql_$timestamp +mongodump -d hackmd -o $path/$today/mongodb_$timestamp \ No newline at end of file diff --git a/lib/logger.js b/lib/logger.js new file mode 100644 index 00000000..c843330b --- /dev/null +++ b/lib/logger.js @@ -0,0 +1,21 @@ +var winston = require('winston'); +winston.emitErrs = true; + +var logger = new winston.Logger({ + transports: [ + new winston.transports.Console({ + level: 'debug', + handleExceptions: true, + json: false, + colorize: true + }) + ], + exitOnError: false +}); + +module.exports = logger; +module.exports.stream = { + write: function(message, encoding){ + logger.info(message); + } +}; \ No newline at end of file diff --git a/lib/realtime.js b/lib/realtime.js index e3800275..1a57c77e 100644 --- a/lib/realtime.js +++ b/lib/realtime.js @@ -7,6 +7,8 @@ var async = require('async'); var LZString = require('lz-string'); var shortId = require('shortid'); var randomcolor = require("randomcolor"); +var Chance = require('chance'), + chance = new Chance(); //core var config = require("../config.js"); @@ -18,11 +20,22 @@ var User = require("./user.js"); //public var realtime = { + onAuthorizeSuccess: onAuthorizeSuccess, + onAuthorizeFail: onAuthorizeFail, secure: secure, connection: connection, getStatus: getStatus }; +function onAuthorizeSuccess(data, accept) { + accept(null, true); +} + +function onAuthorizeFail(data, message, error, accept) { + if (error) throw new Error(message); + accept(null, true); +} + function secure(socket, next) { try { var handshakeData = socket.request; @@ -53,8 +66,10 @@ var updater = setInterval(function () { if (note.isDirty) { if (config.debug) console.log("updater found dirty note: " + key); - var title = Note.getNoteTitle(LZString.decompressFromBase64(note.body)); - db.saveToDB(key, title, note.body, + var body = LZString.decompressFromUTF16(note.body); + var title = Note.getNoteTitle(body); + body = LZString.compressToBase64(body); + db.saveToDB(key, title, body, function (err, result) {}); note.isDirty = false; } @@ -72,7 +87,7 @@ function getStatus(callback) { var distinctaddresses = []; Object.keys(users).forEach(function (key) { var value = users[key]; - if(value.login) + if (value.login) regusers++; var found = false; for (var i = 0; i < distinctaddresses.length; i++) { @@ -83,9 +98,9 @@ function getStatus(callback) { } if (!found) { distinctaddresses.push(value.address); - if(value.login) + if (value.login) distinctregusers++; - } + } }); User.getUserCount(function (err, regcount) { if (err) { @@ -129,17 +144,25 @@ function emitOnlineUsers(socket) { Object.keys(notes[notename].users).forEach(function (key) { var user = notes[notename].users[key]; if (user) - users.push({ - id: user.id, - color: user.color, - cursor: user.cursor - }); + users.push(buildUserOutData(user)); }); notes[notename].socks.forEach(function (sock) { - sock.emit('online users', { - count: notes[notename].socks.length, + var out = { users: users - }); + }; + out = LZString.compressToUTF16(JSON.stringify(out)); + sock.emit('online users', out); + }); +} + +function emitUserStatus(socket) { + var notename = getNotenameFromSocket(socket); + if (!notename || !notes[notename]) return; + notes[notename].socks.forEach(function (sock) { + if (sock != socket) { + var out = buildUserOutData(users[socket.id]); + sock.emit('user status', out); + } }); } @@ -198,7 +221,8 @@ function startConnection(socket) { isConnectionBusy = false; return console.error(err); } - var body = data.rows[0].content; + var body = LZString.decompressFromBase64(data.rows[0].content); + body = LZString.compressToUTF16(body); notes[notename] = { socks: [], body: body, @@ -232,8 +256,10 @@ function disconnect(socket) { notes[notename].socks.splice(index, 1); } if (Object.keys(notes[notename].users).length <= 0) { - var title = Note.getNoteTitle(LZString.decompressFromBase64(notes[notename].body)); - db.saveToDB(notename, title, notes[notename].body, + var body = LZString.decompressFromUTF16(notes[notename].body); + var title = Note.getNoteTitle(body); + body = LZString.compressToBase64(body); + db.saveToDB(notename, title, body, function (err, result) { delete notes[notename]; if (config.debug) { @@ -265,20 +291,80 @@ function disconnect(socket) { } } +function buildUserOutData(user) { + var out = { + id: user.id, + login: user.login, + userid: user.userid, + color: user.color, + cursor: user.cursor, + name: user.name, + idle: user.idle, + type: user.type + }; + return out; +} + +function updateUserData(socket, user) { + //retrieve user data from passport + if (socket.request.user && socket.request.user.logged_in) { + var profile = JSON.parse(socket.request.user.profile); + user.name = profile.displayName || profile.username; + user.userid = socket.request.user._id; + user.login = true; + } else { + user.userid = null; + user.name = 'Guest ' + chance.last(); + user.login = false; + } +} function connection(socket) { + //split notename from socket + var notename = getNotenameFromSocket(socket); + + //initialize user data + //random color + var color = randomcolor({ + luminosity: 'light' + }); + //make sure color not duplicated or reach max random count + if (notename && notes[notename]) { + var randomcount = 0; + var maxrandomcount = 5; + var found = false; + do { + Object.keys(notes[notename].users).forEach(function (user) { + if (user.color == color) { + found = true; + return; + } + }); + if (found) { + color = randomcolor({ + luminosity: 'light' + }); + randomcount++; + } + } while (found && randomcount < maxrandomcount); + } + //create user data users[socket.id] = { id: socket.id, address: socket.handshake.address, 'user-agent': socket.handshake.headers['user-agent'], otk: shortId.generate(), - color: randomcolor({ - luminosity: 'light' - }), + color: color, cursor: null, - login: false + login: false, + userid: null, + name: null, + idle: false, + type: null }; + updateUserData(socket, users[socket.id]); + //start connection connectionSocketQueue.push(socket); startConnection(socket); @@ -293,61 +379,88 @@ function connection(socket) { notes[notename].isDirty = true; } }); - + + //received user status socket.on('user status', function (data) { - if(data) - users[socket.id].login = data.login; + var notename = getNotenameFromSocket(socket); + if (!notename) return; + if (config.debug) + console.log('SERVER received [' + notename + '] user status from [' + socket.id + ']: ' + JSON.stringify(data)); + if (data) { + var user = users[socket.id]; + user.idle = data.idle; + user.type = data.type; + } + emitUserStatus(socket); }); - socket.on('online users', function () { + //reveiced when user logout or changed + socket.on('user changed', function () { + console.log('user changed'); + var notename = getNotenameFromSocket(socket); + if (!notename || !notes[notename]) return; + updateUserData(socket, notes[notename].users[socket.id]); emitOnlineUsers(socket); }); + //received sync of online users request + socket.on('online users', function () { + var notename = getNotenameFromSocket(socket); + if (!notename || !notes[notename]) return; + var users = []; + Object.keys(notes[notename].users).forEach(function (key) { + var user = notes[notename].users[key]; + if (user) + users.push(buildUserOutData(user)); + }); + var out = { + users: users + }; + out = LZString.compressToUTF16(JSON.stringify(out)); + socket.emit('online users', out); + }); + + //check version socket.on('version', function () { socket.emit('version', config.version); }); + //received cursor focus socket.on('cursor focus', function (data) { var notename = getNotenameFromSocket(socket); if (!notename || !notes[notename]) return; users[socket.id].cursor = data; + var out = buildUserOutData(users[socket.id]); notes[notename].socks.forEach(function (sock) { if (sock != socket) { - var out = { - id: socket.id, - color: users[socket.id].color, - cursor: data - }; sock.emit('cursor focus', out); } }); }); + //received cursor activity socket.on('cursor activity', function (data) { var notename = getNotenameFromSocket(socket); if (!notename || !notes[notename]) return; users[socket.id].cursor = data; + var out = buildUserOutData(users[socket.id]); notes[notename].socks.forEach(function (sock) { if (sock != socket) { - var out = { - id: socket.id, - color: users[socket.id].color, - cursor: data - }; sock.emit('cursor activity', out); } }); }); + //received cursor blur socket.on('cursor blur', function () { var notename = getNotenameFromSocket(socket); if (!notename || !notes[notename]) return; users[socket.id].cursor = null; + var out = { + id: socket.id + }; notes[notename].socks.forEach(function (sock) { if (sock != socket) { - var out = { - id: socket.id - }; if (sock != socket) { sock.emit('cursor blur', out); } @@ -365,7 +478,7 @@ function connection(socket) { socket.on('change', function (op) { var notename = getNotenameFromSocket(socket); if (!notename) return; - op = LZString.decompressFromBase64(op); + op = LZString.decompressFromUTF16(op); if (op) op = JSON.parse(op); if (config.debug) @@ -390,10 +503,12 @@ function connection(socket) { if (sock != socket) { if (config.debug) console.log('SERVER emit sync data out [' + notename + ']: ' + sock.id + ', op:' + JSON.stringify(op)); - sock.emit('change', LZString.compressToBase64(JSON.stringify(op))); + sock.emit('change', LZString.compressToUTF16(JSON.stringify(op))); } }); break; + default: + console.log('SERVER received uncaught [' + notename + '] data changed: ' + socket.id + ', op:' + JSON.stringify(op)); } }); } diff --git a/package.json b/package.json index 2d2d4625..a0f80b59 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "hackmd", - "version": "0.2.8", + "version": "0.2.9", "description": "Realtime collaborative markdown notes on all platforms.", "main": "app.js", "author": "jackycute", @@ -9,6 +9,7 @@ "dependencies": { "async": "^0.9.0", "body-parser": "^1.12.3", + "chance": "^0.7.5", "cheerio": "^0.19.0", "compression": "^1.4.3", "connect-mongo": "^0.8.1", @@ -27,17 +28,20 @@ "marked": "^0.3.3", "method-override": "^2.3.2", "mongoose": "^4.0.2", + "morgan": "^1.5.3", "node-uuid": "^1.4.3", "passport": "^0.2.1", "passport-dropbox-oauth2": "^0.1.6", "passport-facebook": "^2.0.0", "passport-github": "^0.1.5", "passport-twitter": "^1.0.3", + "passport.socketio": "^3.5.1", "pg": "4.x", "randomcolor": "^0.2.0", "shortid": "2.1.3", "socket.io": "1.3.5", - "toobusy-js": "^0.4.1" + "toobusy-js": "^0.4.1", + "winston": "^1.0.0" }, "engines": { "node": "0.10.x" diff --git a/public/css/index.css b/public/css/index.css index b3a6bae5..f1f303ed 100644 --- a/public/css/index.css +++ b/public/css/index.css @@ -20,7 +20,6 @@ form, font-family: 'Source Code Pro', Consolas, monaco, monospace; line-height: 18px; font-size: 16px; - /*height: auto;*/ min-height: 100%; overflow-y: hidden !important; -webkit-overflow-scrolling: touch; @@ -30,7 +29,7 @@ form, overflow-y: auto !important; } .CodeMirror-code { - /*padding-bottom: 72px;*/ + padding-bottom: 36px; } .CodeMirror-linenumber { opacity: 0.5; @@ -73,23 +72,82 @@ form, font-size: 14px; } .nav-mobile { - position: relative; + position: inherit; margin-top: 8px; margin-bottom: 8px; } +.nav-mobile .dropdown-menu { + left: 40%; + right: 6px; + top: 42px; +} .nav-status { float: right !important; padding: 7px 8px; } .ui-status { + cursor: auto !important; min-width: 120px; + background-color: transparent !important; +} +.ui-status span { + cursor: pointer; } .ui-short-status { + cursor: pointer; min-width: 40px; } +.ui-short-status:hover { + text-decoration: none; +} +.ui-user-item { + /*na*/ +} +.ui-user-name { + margin-top: 2px; +} +.ui-user-icon { + font-size: 20px; + margin-top: 2px; + margin-right: 5px; +} +.ui-user-status { + margin-top: 5px; +} +.ui-user-status-online { + color: rgb(92,184,92); +} +.ui-user-status-idle { + color: rgb(240,173,78); +} +.ui-user-status-offline { + color: rgb(119,119,119); +} +.list > li > a { + overflow: hidden; + text-overflow: ellipsis; +} +#short-online-user-list .list .name { + max-width: 65%; + overflow: hidden; + text-overflow: ellipsis; + float: left; +} +#online-user-list .list .name { + max-width: 110px; + overflow: hidden; + text-overflow: ellipsis; + float: left; +} .navbar-right { margin-right: 0; } +.navbar-nav > li > a { + cursor: pointer; +} +.dropdown-menu > li > a { + cursor: pointer; +} .other-cursors { position:relative; z-index:3; @@ -99,10 +157,63 @@ form, position: absolute; border-right: none; } +.cursortag { + cursor: pointer; + background: black; + position: absolute; + padding: 2px 7px 2px 8px; + font-size: 12px; + max-width: 150px; + text-overflow: ellipsis; + overflow: hidden; + font-family: inherit; + border-radius: .25em; + white-space: nowrap; +} .fixfixed .navbar-fixed-top { position: absolute !important; } div[contenteditable]:empty:not(:focus):before{ content:attr(data-ph); color: gray; +} +.dropdown-menu { + max-height: 80vh; + overflow: auto; +} +.dropdown-menu::-webkit-scrollbar { + display: none; +} +.dropdown-menu .emoji { + margin-bottom: 0 !important; +} +.dropdown-menu.other-cursor { + width: auto !important; +} +.CodeMirror-scrollbar-filler { + background: inherit; +} +.unselectable { + -moz-user-select: none; + -khtml-user-select: none; + -webkit-user-select: none; + -o-user-select: none; + user-select: none; +} + +.cm-trailing-space-a:before, +.cm-trailing-space-b:before, +.cm-trailing-space-new-line:before { + font-weight: bold; + color: hsl(30, 100%, 50%); /* a dark orange */ + position: absolute; +} + +.cm-trailing-space-a:before, +.cm-trailing-space-b:before { + content: '·'; +} + +.cm-trailing-space-new-line:before { + content: '↵'; } \ No newline at end of file diff --git a/public/index.html b/public/index.html index c52b8ec3..2eb76ddc 100644 --- a/public/index.html +++ b/public/index.html @@ -81,8 +81,8 @@