diff --git a/lib/migrations/20180525153000-user-add-delete-token.js b/lib/migrations/20180525153000-user-add-delete-token.js new file mode 100644 index 0000000..642fa5d --- /dev/null +++ b/lib/migrations/20180525153000-user-add-delete-token.js @@ -0,0 +1,13 @@ +'use strict' +module.exports = { + up: function (queryInterface, Sequelize) { + return queryInterface.addColumn('Users', 'deleteToken', { + type: Sequelize.UUID, + defaultValue: Sequelize.UUIDV4 + }) + }, + + down: function (queryInterface, Sequelize) { + return queryInterface.removeColumn('Users', 'deleteToken') + } +} diff --git a/lib/models/author.js b/lib/models/author.js index 8b4f74e..03f832a 100644 --- a/lib/models/author.js +++ b/lib/models/author.js @@ -24,12 +24,16 @@ module.exports = function (sequelize, DataTypes) { Author.belongsTo(models.Note, { foreignKey: 'noteId', as: 'note', - constraints: false + constraints: false, + onDelete: 'CASCADE', + hooks: true }) Author.belongsTo(models.User, { foreignKey: 'userId', as: 'user', - constraints: false + constraints: false, + onDelete: 'CASCADE', + hooks: true }) } } diff --git a/lib/models/note.js b/lib/models/note.js index 2a048e3..7d8e962 100644 --- a/lib/models/note.js +++ b/lib/models/note.js @@ -85,13 +85,15 @@ module.exports = function (sequelize, DataTypes) { type: DataTypes.DATE } }, { - paranoid: true, + paranoid: false, classMethods: { associate: function (models) { Note.belongsTo(models.User, { foreignKey: 'ownerId', as: 'owner', - constraints: false + constraints: false, + onDelete: 'CASCADE', + hooks: true }) Note.belongsTo(models.User, { foreignKey: 'lastchangeuserId', diff --git a/lib/models/revision.js b/lib/models/revision.js index 9ecd14d..8bc95cb 100644 --- a/lib/models/revision.js +++ b/lib/models/revision.js @@ -102,7 +102,9 @@ module.exports = function (sequelize, DataTypes) { Revision.belongsTo(models.Note, { foreignKey: 'noteId', as: 'note', - constraints: false + constraints: false, + onDelete: 'CASCADE', + hooks: true }) }, getNoteRevisions: function (note, callback) { diff --git a/lib/models/user.js b/lib/models/user.js index 0f6acd6..5dd1386 100644 --- a/lib/models/user.js +++ b/lib/models/user.js @@ -31,6 +31,10 @@ module.exports = function (sequelize, DataTypes) { refreshToken: { type: DataTypes.TEXT }, + deleteToken: { + type: DataTypes.UUID, + defaultValue: Sequelize.UUIDV4 + }, email: { type: Sequelize.TEXT, validate: { @@ -66,6 +70,9 @@ module.exports = function (sequelize, DataTypes) { }) }, getProfile: function (user) { + if (!user) { + return null + } return user.profile ? User.parseProfile(user.profile) : (user.email ? User.parseProfileByEmail(user.email) : null) }, parseProfile: function (profile) { diff --git a/lib/realtime.js b/lib/realtime.js index 070bde2..f6c62d4 100644 --- a/lib/realtime.js +++ b/lib/realtime.js @@ -486,11 +486,13 @@ function startConnection (socket) { for (var i = 0; i < note.authors.length; i++) { var author = note.authors[i] var profile = models.User.getProfile(author.user) - authors[author.userId] = { - userid: author.userId, - color: author.color, - photo: profile.photo, - name: profile.name + if (profile) { + authors[author.userId] = { + userid: author.userId, + color: author.color, + photo: profile.photo, + name: profile.name + } } } diff --git a/lib/response.js b/lib/response.js index 89ade7a..4cfa9a7 100644 --- a/lib/response.js +++ b/lib/response.js @@ -2,6 +2,7 @@ // response // external modules var fs = require('fs') +var path = require('path') var markdownpdf = require('markdown-pdf') var shortId = require('shortid') var querystring = require('querystring') @@ -61,7 +62,10 @@ function responseError (res, code, detail, msg) { } function showIndex (req, res, next) { - res.render(config.indexPath, { + var authStatus = req.isAuthenticated() + var deleteToken = '' + + var data = { url: config.serverURL, useCDN: config.useCDN, allowAnonymous: config.allowAnonymous, @@ -81,10 +85,28 @@ function showIndex (req, res, next) { email: config.isEmailEnable, allowEmailRegister: config.allowEmailRegister, allowPDFExport: config.allowPDFExport, - signin: req.isAuthenticated(), + signin: authStatus, infoMessage: req.flash('info'), - errorMessage: req.flash('error') - }) + errorMessage: req.flash('error'), + privacyStatement: fs.existsSync(path.join(config.docsPath, 'privacy.md')), + termsOfUse: fs.existsSync(path.join(config.docsPath, 'terms-of-use.md')), + deleteToken: deleteToken + } + + if (authStatus) { + models.User.findOne({ + where: { + id: req.user.id + } + }).then(function (user) { + if (user) { + data.deleteToken = user.deleteToken + res.render(config.indexPath, data) + } + }) + } else { + res.render(config.indexPath, data) + } } function responseHackMD (res, note) { diff --git a/lib/web/userRouter.js b/lib/web/userRouter.js index 963961c..db786d5 100644 --- a/lib/web/userRouter.js +++ b/lib/web/userRouter.js @@ -1,8 +1,11 @@ 'use strict' +const archiver = require('archiver') +const async = require('async') const Router = require('express').Router const response = require('../response') +const config = require('../config') const models = require('../models') const logger = require('../logger') const {generateAvatar} = require('../letter-avatars') @@ -36,6 +39,87 @@ UserRouter.get('/me', function (req, res) { } }) +// delete the currently authenticated user +UserRouter.get('/me/delete/:token?', function (req, res) { + if (req.isAuthenticated()) { + models.User.findOne({ + where: { + id: req.user.id + } + }).then(function (user) { + if (!user) { + return response.errorNotFound(res) + } + if (user.deleteToken === req.params.token) { + user.destroy().then(function () { + res.redirect(config.serverURL + '/') + }) + } else { + return response.errorForbidden(res) + } + }).catch(function (err) { + logger.error('delete user failed: ' + err) + return response.errorInternalError(res) + }) + } else { + return response.errorForbidden(res) + } +}) + +// export the data of the authenticated user +UserRouter.get('/me/export', function (req, res) { + if (req.isAuthenticated()) { + // let output = fs.createWriteStream(__dirname + '/example.zip'); + let archive = archiver('zip', { + zlib: { level: 3 } // Sets the compression level. + }) + res.setHeader('Content-Type', 'application/zip') + res.attachment('archive.zip') + archive.pipe(res) + archive.on('error', function (err) { + logger.error('export user data failed: ' + err) + return response.errorInternalError(res) + }) + models.User.findOne({ + where: { + id: req.user.id + } + }).then(function (user) { + models.Note.findAll({ + where: { + ownerId: user.id + } + }).then(function (notes) { + let list = [] + async.each(notes, function (note, callback) { + let title + let extension = '' + do { + title = note.title + extension + extension++ + } while (list.indexOf(title) !== -1) + + list.push(title) + logger.debug('Write: ' + title + '.md') + archive.append(Buffer.from(note.content), { name: title + '.md', date: note.lastchangeAt }) + callback(null, null) + }, function (err) { + if (err) { + return response.errorInternalError(res) + } + + archive.finalize() + }) + }) + }).catch(function (err) { + logger.error('export user data failed: ' + err) + return response.errorInternalError(res) + }) + } else { + return response.errorForbidden(res) + } +}) + UserRouter.get('/user/:username/avatar.svg', function (req, res, next) { res.setHeader('Content-Type', 'image/svg+xml') res.setHeader('Cache-Control', 'public, max-age=86400') diff --git a/locales/en.json b/locales/en.json index 1aef3f6..f9c29b5 100644 --- a/locales/en.json +++ b/locales/en.json @@ -105,5 +105,11 @@ "Export to Snippet": "Export to Snippet", "Select Visibility Level": "Select Visibility Level", "Night Theme": "Night Theme", - "Follow us on %s and %s.": "Follow us on %s, and %s." + "Follow us on %s and %s.": "Follow us on %s, and %s.", + "Privacy": "Privacy", + "Terms of Use": "Terms of Use", + "Do you really want to delete your user account?": "Do you really want to delete your user account?", + "This will delete your account, all notes that are owned by you and remove all references to your account from other notes.": "This will delete your account, all notes that are owned by you and remove all references to your account from other notes.", + "Delete user": "Delete user", + "Export user data": "Export user data" } diff --git a/package.json b/package.json index b7420ce..20920e2 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ }, "dependencies": { "Idle.Js": "git+https://github.com/shawnmclean/Idle.js", + "archiver": "^2.1.1", "async": "^2.1.4", "aws-sdk": "^2.7.20", "base64url": "^3.0.0", diff --git a/public/docs/privacy.md.example b/public/docs/privacy.md.example new file mode 100644 index 0000000..08bf909 --- /dev/null +++ b/public/docs/privacy.md.example @@ -0,0 +1,17 @@ +Privacy +=== + +We process the following data, for the following purposes: + +|your data|our usage| +|---------|---------| +|IP-Address|Used to communicate with your browser and our servers. It's may exposed to third-parties which provide resources for this service. These services are, depending on your login method, the document you visit and the setup of this instance: Google, Disqus, MathJax, GitHub, SlideShare/LinkedIn, yahoo, Gravatar, Imgur, Amazon, and Cloudflare.| +|Usernames and profiles|Your username as well as user profiles that are connected with it are transmitted and stored by us to provide a useful login integration with services like GitHub, Facebook, Twitter, GitLab, Dropbox, Google. Depending on the setup of this HackMD instance there are maybe other third-parties involved using SAML, LDAP or the integration with a Mattermost instance.| +|Profile pictures| Your profile picture is either loaded from the service you used to login, the HackMD instance or Gravatar.| +|Uploaded pictures| Pictures that are uploaded for documents are either uploaded to Amazon S3, Imgur, a minio instance or the local filesystem of the HackMD server.| + +All account data and notes are stored in a mysql/postgres/sqlite database. Besides the user accounts and the document themselves also relationships between the documents and the user accounts are stored. This includes ownership, authorship and revisions of all changes made during the creation of a note. + +To delete your account and all your notes owned by your user account, you can find a button in the drop down menu on the front page. + +The deletion of guest notes is not possible. These don't have any ownership and this means we can't connect these to you or anyone else. If you participated in a guest note or a note owned by someone else, your authorship for the revisions is removed from these notes as well. But the content you created will stay in place as the integrity of these notes has to stay untouched. diff --git a/public/js/cover.js b/public/js/cover.js index c9c2b6c..2a09b4c 100644 --- a/public/js/cover.js +++ b/public/js/cover.js @@ -39,7 +39,7 @@ const options = { '' + '
' + '
' + - '
' + + '
' + '
' + '

' + '

' + @@ -208,8 +208,8 @@ function historyCloseClick (e) { e.preventDefault() const id = $(this).closest('a').siblings('span').html() const value = historyList.get('id', id)[0]._values - $('.ui-delete-modal-msg').text('Do you really want to delete below history?') - $('.ui-delete-modal-item').html(` ${value.text}
${value.time}`) + $('.ui-delete-history-modal-msg').text('Do you really want to delete below history?') + $('.ui-delete-history-modal-item').html(` ${value.text}
${value.time}`) clearHistory = false deleteId = id } @@ -277,7 +277,7 @@ function deleteHistory () { checkHistoryList() } } - $('.delete-modal').modal('hide') + $('.delete-history-modal').modal('hide') deleteId = null clearHistory = false }) @@ -297,12 +297,12 @@ function deleteHistory () { deleteId = null }) } - $('.delete-modal').modal('hide') + $('.delete-history-modal').modal('hide') clearHistory = false }) } -$('.ui-delete-modal-confirm').click(() => { +$('.ui-delete-history-modal-confirm').click(() => { deleteHistory() }) @@ -342,8 +342,8 @@ $('.ui-open-history').bind('change', e => { }) $('.ui-clear-history').click(() => { - $('.ui-delete-modal-msg').text('Do you really want to clear all history?') - $('.ui-delete-modal-item').html('There is no turning back.') + $('.ui-delete-history-modal-msg').text('Do you really want to clear all history?') + $('.ui-delete-history-modal-item').html('There is no turning back.') clearHistory = true deleteId = null }) @@ -371,6 +371,10 @@ $('.ui-refresh-history').click(() => { }) }) +$('.ui-delete-user-modal-cancel').click(() => { + $('.ui-delete-user').parent().removeClass('active') +}) + $('.ui-logout').click(() => { clearLoginState() location.href = `${serverurl}/logout` diff --git a/public/views/index/body.ejs b/public/views/index/body.ejs index 122335a..220e0da 100644 --- a/public/views/index/body.ejs +++ b/public/views/index/body.ejs @@ -27,6 +27,8 @@

@@ -108,7 +110,7 @@ - + @@ -148,7 +150,7 @@

- © 2018 HackMD | <%= __('Releases') %> + © 2018 HackMD | <%= __('Releases') %><% if(privacyStatement) { %> | <%= __('Privacy') %><% } %><% if(termsOfUse) { %> | <%= __('Terms of Use') %><% } %>

- -