Merge pull request #830 from SISheogorath/feature/GDPR

GDPR compliant part 1
This commit is contained in:
Christoph (Sheogorath) Kern 2018-06-17 23:33:57 +02:00 committed by GitHub
commit 56d78a7d6c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 216 additions and 30 deletions

View file

@ -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')
}
}

View file

@ -24,12 +24,16 @@ module.exports = function (sequelize, DataTypes) {
Author.belongsTo(models.Note, { Author.belongsTo(models.Note, {
foreignKey: 'noteId', foreignKey: 'noteId',
as: 'note', as: 'note',
constraints: false constraints: false,
onDelete: 'CASCADE',
hooks: true
}) })
Author.belongsTo(models.User, { Author.belongsTo(models.User, {
foreignKey: 'userId', foreignKey: 'userId',
as: 'user', as: 'user',
constraints: false constraints: false,
onDelete: 'CASCADE',
hooks: true
}) })
} }
} }

View file

@ -85,13 +85,15 @@ module.exports = function (sequelize, DataTypes) {
type: DataTypes.DATE type: DataTypes.DATE
} }
}, { }, {
paranoid: true, paranoid: false,
classMethods: { classMethods: {
associate: function (models) { associate: function (models) {
Note.belongsTo(models.User, { Note.belongsTo(models.User, {
foreignKey: 'ownerId', foreignKey: 'ownerId',
as: 'owner', as: 'owner',
constraints: false constraints: false,
onDelete: 'CASCADE',
hooks: true
}) })
Note.belongsTo(models.User, { Note.belongsTo(models.User, {
foreignKey: 'lastchangeuserId', foreignKey: 'lastchangeuserId',

View file

@ -102,7 +102,9 @@ module.exports = function (sequelize, DataTypes) {
Revision.belongsTo(models.Note, { Revision.belongsTo(models.Note, {
foreignKey: 'noteId', foreignKey: 'noteId',
as: 'note', as: 'note',
constraints: false constraints: false,
onDelete: 'CASCADE',
hooks: true
}) })
}, },
getNoteRevisions: function (note, callback) { getNoteRevisions: function (note, callback) {

View file

@ -31,6 +31,10 @@ module.exports = function (sequelize, DataTypes) {
refreshToken: { refreshToken: {
type: DataTypes.TEXT type: DataTypes.TEXT
}, },
deleteToken: {
type: DataTypes.UUID,
defaultValue: Sequelize.UUIDV4
},
email: { email: {
type: Sequelize.TEXT, type: Sequelize.TEXT,
validate: { validate: {
@ -66,6 +70,9 @@ module.exports = function (sequelize, DataTypes) {
}) })
}, },
getProfile: function (user) { getProfile: function (user) {
if (!user) {
return null
}
return user.profile ? User.parseProfile(user.profile) : (user.email ? User.parseProfileByEmail(user.email) : null) return user.profile ? User.parseProfile(user.profile) : (user.email ? User.parseProfileByEmail(user.email) : null)
}, },
parseProfile: function (profile) { parseProfile: function (profile) {

View file

@ -486,11 +486,13 @@ function startConnection (socket) {
for (var i = 0; i < note.authors.length; i++) { for (var i = 0; i < note.authors.length; i++) {
var author = note.authors[i] var author = note.authors[i]
var profile = models.User.getProfile(author.user) var profile = models.User.getProfile(author.user)
authors[author.userId] = { if (profile) {
userid: author.userId, authors[author.userId] = {
color: author.color, userid: author.userId,
photo: profile.photo, color: author.color,
name: profile.name photo: profile.photo,
name: profile.name
}
} }
} }

View file

@ -2,6 +2,7 @@
// response // response
// external modules // external modules
var fs = require('fs') var fs = require('fs')
var path = require('path')
var markdownpdf = require('markdown-pdf') var markdownpdf = require('markdown-pdf')
var shortId = require('shortid') var shortId = require('shortid')
var querystring = require('querystring') var querystring = require('querystring')
@ -61,7 +62,10 @@ function responseError (res, code, detail, msg) {
} }
function showIndex (req, res, next) { function showIndex (req, res, next) {
res.render(config.indexPath, { var authStatus = req.isAuthenticated()
var deleteToken = ''
var data = {
url: config.serverURL, url: config.serverURL,
useCDN: config.useCDN, useCDN: config.useCDN,
allowAnonymous: config.allowAnonymous, allowAnonymous: config.allowAnonymous,
@ -81,10 +85,28 @@ function showIndex (req, res, next) {
email: config.isEmailEnable, email: config.isEmailEnable,
allowEmailRegister: config.allowEmailRegister, allowEmailRegister: config.allowEmailRegister,
allowPDFExport: config.allowPDFExport, allowPDFExport: config.allowPDFExport,
signin: req.isAuthenticated(), signin: authStatus,
infoMessage: req.flash('info'), 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) { function responseHackMD (res, note) {

View file

@ -1,8 +1,11 @@
'use strict' 'use strict'
const archiver = require('archiver')
const async = require('async')
const Router = require('express').Router const Router = require('express').Router
const response = require('../response') const response = require('../response')
const config = require('../config')
const models = require('../models') const models = require('../models')
const logger = require('../logger') const logger = require('../logger')
const {generateAvatar} = require('../letter-avatars') 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) { UserRouter.get('/user/:username/avatar.svg', function (req, res, next) {
res.setHeader('Content-Type', 'image/svg+xml') res.setHeader('Content-Type', 'image/svg+xml')
res.setHeader('Cache-Control', 'public, max-age=86400') res.setHeader('Cache-Control', 'public, max-age=86400')

View file

@ -105,5 +105,11 @@
"Export to Snippet": "Export to Snippet", "Export to Snippet": "Export to Snippet",
"Select Visibility Level": "Select Visibility Level", "Select Visibility Level": "Select Visibility Level",
"Night Theme": "Night Theme", "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"
} }

View file

@ -16,6 +16,7 @@
}, },
"dependencies": { "dependencies": {
"Idle.Js": "git+https://github.com/shawnmclean/Idle.js", "Idle.Js": "git+https://github.com/shawnmclean/Idle.js",
"archiver": "^2.1.1",
"async": "^2.1.4", "async": "^2.1.4",
"aws-sdk": "^2.7.20", "aws-sdk": "^2.7.20",
"base64url": "^3.0.0", "base64url": "^3.0.0",

View file

@ -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.

View file

@ -39,7 +39,7 @@ const options = {
'<a href="#">' + '<a href="#">' +
'<div class="item">' + '<div class="item">' +
'<div class="ui-history-pin fa fa-thumb-tack fa-fw"></div>' + '<div class="ui-history-pin fa fa-thumb-tack fa-fw"></div>' +
'<div class="ui-history-close fa fa-close fa-fw" data-toggle="modal" data-target=".delete-modal"></div>' + '<div class="ui-history-close fa fa-close fa-fw" data-toggle="modal" data-target=".delete-history-modal"></div>' +
'<div class="content">' + '<div class="content">' +
'<h4 class="text"></h4>' + '<h4 class="text"></h4>' +
'<p>' + '<p>' +
@ -208,8 +208,8 @@ function historyCloseClick (e) {
e.preventDefault() e.preventDefault()
const id = $(this).closest('a').siblings('span').html() const id = $(this).closest('a').siblings('span').html()
const value = historyList.get('id', id)[0]._values const value = historyList.get('id', id)[0]._values
$('.ui-delete-modal-msg').text('Do you really want to delete below history?') $('.ui-delete-history-modal-msg').text('Do you really want to delete below history?')
$('.ui-delete-modal-item').html(`<i class="fa fa-file-text"></i> ${value.text}<br><i class="fa fa-clock-o"></i> ${value.time}`) $('.ui-delete-history-modal-item').html(`<i class="fa fa-file-text"></i> ${value.text}<br><i class="fa fa-clock-o"></i> ${value.time}`)
clearHistory = false clearHistory = false
deleteId = id deleteId = id
} }
@ -277,7 +277,7 @@ function deleteHistory () {
checkHistoryList() checkHistoryList()
} }
} }
$('.delete-modal').modal('hide') $('.delete-history-modal').modal('hide')
deleteId = null deleteId = null
clearHistory = false clearHistory = false
}) })
@ -297,12 +297,12 @@ function deleteHistory () {
deleteId = null deleteId = null
}) })
} }
$('.delete-modal').modal('hide') $('.delete-history-modal').modal('hide')
clearHistory = false clearHistory = false
}) })
} }
$('.ui-delete-modal-confirm').click(() => { $('.ui-delete-history-modal-confirm').click(() => {
deleteHistory() deleteHistory()
}) })
@ -342,8 +342,8 @@ $('.ui-open-history').bind('change', e => {
}) })
$('.ui-clear-history').click(() => { $('.ui-clear-history').click(() => {
$('.ui-delete-modal-msg').text('Do you really want to clear all history?') $('.ui-delete-history-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-item').html('There is no turning back.')
clearHistory = true clearHistory = true
deleteId = null 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(() => { $('.ui-logout').click(() => {
clearLoginState() clearLoginState()
location.href = `${serverurl}/logout` location.href = `${serverurl}/logout`

View file

@ -27,6 +27,8 @@
</button> </button>
<ul class="dropdown-menu" aria-labelledby="profileLabel"> <ul class="dropdown-menu" aria-labelledby="profileLabel">
<li><a href="<%- url %>/features"><i class="fa fa-dot-circle-o fa-fw"></i> <%= __('Features') %></a></li> <li><a href="<%- url %>/features"><i class="fa fa-dot-circle-o fa-fw"></i> <%= __('Features') %></a></li>
<li><a href="<%- url %>/me/export"><i class="fa fa-cloud-download fa-fw"></i> <%= __('Export user data') %></a></li>
<li><a class="ui-delete-user" data-toggle="modal" data-target=".delete-user-modal"><i class="fa fa-trash fa-fw"></i> <%= __('Delete user') %></a></li>
<li><a href="<%- url %>/logout"><i class="fa fa-sign-out fa-fw"></i> <%= __('Sign Out') %></a></li> <li><a href="<%- url %>/logout"><i class="fa fa-sign-out fa-fw"></i> <%= __('Sign Out') %></a></li>
</ul> </ul>
</span> </span>
@ -108,7 +110,7 @@
<span class="btn btn-default btn-file ui-open-history" title="<%= __('Import history') %>"> <span class="btn btn-default btn-file ui-open-history" title="<%= __('Import history') %>">
<i class="fa fa-folder-open-o"></i><input type="file" /> <i class="fa fa-folder-open-o"></i><input type="file" />
</span> </span>
<a href="#" class="btn btn-default ui-clear-history" title="<%= __('Clear history') %>" data-toggle="modal" data-target=".delete-modal"><i class="fa fa-trash-o"></i></a> <a href="#" class="btn btn-default ui-clear-history" title="<%= __('Clear history') %>" data-toggle="modal" data-target=".delete-history-modal"><i class="fa fa-trash-o"></i></a>
</span> </span>
<a href="#" class="btn btn-default ui-refresh-history" title="<%= __('Refresh history') %>"><i class="fa fa-refresh"></i></a> <a href="#" class="btn btn-default ui-refresh-history" title="<%= __('Refresh history') %>"><i class="fa fa-refresh"></i></a>
</form> </form>
@ -148,7 +150,7 @@
<option value="ko">한국어</option> <option value="ko">한국어</option>
</select> </select>
<p> <p>
&copy; 2018 <a href="https://hackmd.io">HackMD</a> | <a href="<%- url %>/s/release-notes" target="_blank"><%= __('Releases') %></a> &copy; 2018 <a href="https://hackmd.io">HackMD</a> | <a href="<%- url %>/s/release-notes" target="_blank"><%= __('Releases') %></a><% if(privacyStatement) { %> | <a href="<%- url %>/s/privacy" target="_blank"><%= __('Privacy') %></a><% } %><% if(termsOfUse) { %> | <a href="<%- url %>/s/terms-of-use" target="_blank"><%= __('Terms of Use') %></a><% } %>
</p> </p>
<h6 class="social-foot"> <h6 class="social-foot">
<%- __('Follow us on %s and %s.', '<a href="https://github.com/hackmdio/HackMD" target="_blank"><i class="fa fa-github"></i> GitHub</a>, <a href="https://twitter.com/hackmdio" target="_blank"><i class="fa fa-twitter"></i> Twitter</a>', '<a href="https://www.facebook.com/hackmdio" target="_blank"><i class="fa fa-facebook-square"></i> Facebook</a>') %> <%- __('Follow us on %s and %s.', '<a href="https://github.com/hackmdio/HackMD" target="_blank"><i class="fa fa-github"></i> GitHub</a>, <a href="https://twitter.com/hackmdio" target="_blank"><i class="fa fa-twitter"></i> Twitter</a>', '<a href="https://www.facebook.com/hackmdio" target="_blank"><i class="fa fa-facebook-square"></i> Facebook</a>') %>
@ -158,8 +160,8 @@
</div> </div>
</div> </div>
</div> </div>
<!-- delete modal --> <!-- delete history modal -->
<div class="modal fade delete-modal" tabindex="-1" role="dialog" aria-labelledby="myModalLabel" aria-hidden="true"> <div class="modal fade delete-history-modal" tabindex="-1" role="dialog" aria-labelledby="myModalLabel" aria-hidden="true">
<div class="modal-dialog modal-sm"> <div class="modal-dialog modal-sm">
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header">
@ -168,12 +170,32 @@
<h4 class="modal-title" id="myModalLabel"><%= __('Are you sure?') %></h4> <h4 class="modal-title" id="myModalLabel"><%= __('Are you sure?') %></h4>
</div> </div>
<div class="modal-body" style="color:black;"> <div class="modal-body" style="color:black;">
<h5 class="ui-delete-modal-msg"></h5> <h5 class="ui-delete-history-modal-msg"></h5>
<strong class="ui-delete-modal-item"></strong> <strong class="ui-delete-history-modal-item"></strong>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<button type="button" class="btn btn-default" data-dismiss="modal"><%= __('Cancel') %></button> <button type="button" class="btn btn-default" data-dismiss="modal"><%= __('Cancel') %></button>
<button type="button" class="btn btn-danger ui-delete-modal-confirm"><%= __('Yes, do it!') %></button> <button type="button" class="btn btn-danger ui-delete-history-modal-confirm"><%= __('Yes, do it!') %></button>
</div>
</div>
</div>
</div>
<!-- delete user modal -->
<div class="modal fade delete-user-modal" tabindex="-1" role="dialog" aria-labelledby="myModalLabel" aria-hidden="true">
<div class="modal-dialog modal-sm">
<div class="modal-content">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal" aria-label="Close"><span aria-hidden="true">&times;</span>
</button>
<h4 class="modal-title" id="myModalLabel"><%= __('Are you sure?') %></h4>
</div>
<div class="modal-body" style="color:black;">
<h5 class="ui-delete-user-modal-msg"><%= __('Do you really want to delete your user account?') %></h5>
<strong class="ui-delete-user-modal-item"><%= __('This will delete your account, all notes that are owned by you and remove all references to your account from other notes.') %></strong>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default ui-delete-user-modal-cancel" data-dismiss="modal"><%= __('Cancel') %></button>
<a type="button" class="btn btn-danger" href="<%- url %>/me/delete/<%- deleteToken %>"><%= __('Yes, do it!') %></a>
</div> </div>
</div> </div>
</div> </div>