diff --git a/lib/migrations/202106200000000-create-note-aliases-history.js b/lib/migrations/202106200000000-create-note-aliases-history.js new file mode 100644 index 00000000..505d8d45 --- /dev/null +++ b/lib/migrations/202106200000000-create-note-aliases-history.js @@ -0,0 +1,32 @@ +'use strict' + +module.exports = { + up: function (queryInterface, Sequelize) { + return queryInterface.createTable('ArchivedNoteAliases', { + id: { + type: Sequelize.UUID, + primaryKey: true, + defaultValue: Sequelize.UUIDV4 + }, + noteId: Sequelize.UUID, + alias: { + type: Sequelize.STRING, + allowNull: false, + unique: true + }, + createdAt: Sequelize.DATE, + updatedAt: Sequelize.DATE + }) + .then( + () => queryInterface.addIndex( + 'ArchivedNoteAliases', + ['alias'], { + indicesType: 'UNIQUE' + }) + ) + }, + + down: function (queryInterface) { + return queryInterface.dropTable('Users'); + } +} diff --git a/lib/models/archivedNoteAlias.js b/lib/models/archivedNoteAlias.js new file mode 100644 index 00000000..f49fa99e --- /dev/null +++ b/lib/models/archivedNoteAlias.js @@ -0,0 +1,28 @@ +const Sequelize = require('sequelize') + +module.exports = function (sequelize, DataTypes) { + const ArchivedNoteAlias = sequelize.define('ArchivedNoteAlias', { + id: { + type: DataTypes.UUID, + primaryKey: true, + defaultValue: Sequelize.UUIDV4 + }, + noteId: { + type: DataTypes.UUID + }, + alias: { + type: DataTypes.STRING, + unique: true + } + }) + + ArchivedNoteAlias.associate = function (models) { + ArchivedNoteAlias.belongsTo(models.Note, { + foreignKey: 'noteId', + as: 'note', + constraints: false + }) + } + + return ArchivedNoteAlias +} diff --git a/lib/models/note.js b/lib/models/note.js index 9e0fb6c5..df25d245 100644 --- a/lib/models/note.js +++ b/lib/models/note.js @@ -157,6 +157,11 @@ module.exports = function (sequelize, DataTypes) { as: 'authors', constraints: false }) + Note.hasMany(models.ArchivedNoteAlias, { + foreignKey: 'noteId', + as: 'archivedAlias', + constraints: false + }) } Note.checkFileExist = function (filePath) { try { @@ -223,6 +228,18 @@ module.exports = function (sequelize, DataTypes) { Note.parseNoteId = function (noteId, callback) { async.series({ + parseNoteIdByArchivedAlias: function (_callback) { + sequelize.models.ArchivedNoteAlias.findOne({ + where: { + alias: noteId + } + }).then(function (archivedAlias) { + if (!archivedAlias) { + return _callback(null, null) + } + return callback(null, archivedAlias.noteId) + }); + }, parseNoteIdByAlias: function (_callback) { // try to parse note id by alias (e.g. doc) Note.findOne({ diff --git a/lib/note/index.js b/lib/note/index.js index 41d8cf58..5cb7270f 100644 --- a/lib/note/index.js +++ b/lib/note/index.js @@ -4,10 +4,11 @@ const config = require('../config') const logger = require('../logger') const { Note, User, Revision } = require('../models') -const { newCheckViewPermission, errorForbidden, responseCodiMD, errorNotFound, errorInternalError } = require('../response') +const { newCheckViewPermission, errorForbidden, responseCodiMD, errorNotFound, errorInternalError, responseError } = require('../response') const { updateHistory, historyDelete } = require('../history') const { actionPublish, actionSlide, actionInfo, actionDownload, actionPDF, actionGist, actionRevision, actionPandoc } = require('./noteActions') const realtime = require('../realtime/realtime') +const serv = require('./service'); async function getNoteById (noteId, { includeUser } = { includeUser: false }) { const id = await Note.parseNoteIdAsync(noteId) @@ -332,9 +333,66 @@ const updateNote = async (req, res) => { } } +const checkAliasValid = async (req, res) => { + const originAliasOrNoteId = req.params.originAliasOrNoteId; + const alias = req.query.alias; + const isValid = await serv.asyncCheckAliasValid(originAliasOrNoteId, alias) + .catch((err) => { + logger.error(err.message); + return res.status(500).send('Internal Error.'); + }) + + res.send({ + status: 'ok', + isValid + }); +} + +const updateNoteAlias = async (req, res) => { + const originAliasOrNoteId = req.params.originAliasOrNoteId; + const alias = req.body.alias || ''; + const userId = req.user ? req.user.id : null + const note = await serv.asyncGetNote(originAliasOrNoteId) + .catch((err) => { + logger.error('get note failed:' + err.message); + return false; + }) + + if (note.ownerId != userId) { + return res.status(403).send('Forbidden.'); + } + + const isValid = await serv.asyncCheckAliasValid(originAliasOrNoteId, alias) + .catch((err) => { + logger.error(err.message); + return res.status(500).send('Internal Error.'); + }) + + if (!isValid) { + console.log("\n\n\n", err.message, "\n\n\n"); + return res.status(400).send('Bad Request.'); + } + + const isSuccess = await serv.asyncUpdateAlias(originAliasOrNoteId, alias) + .catch((err) => { + logger.error('update note alias failed:' + err.message); + return false; + }) + + if (!isSuccess) { + return res.status(500).send('Internal Error.'); + } + + res.send({ + status: 'ok' + }) +} + exports.showNote = showNote exports.showPublishNote = showPublishNote exports.noteActions = noteActions exports.listMyNotes = listMyNotes exports.deleteNote = deleteNote exports.updateNote = updateNote +exports.checkAliasValid = checkAliasValid +exports.updateNoteAlias = updateNoteAlias diff --git a/lib/note/service.js b/lib/note/service.js new file mode 100644 index 00000000..e95260a0 --- /dev/null +++ b/lib/note/service.js @@ -0,0 +1,108 @@ + +const { Note, ArchivedNoteAlias, sequelize } = require('../models'); +const realtime = require('../realtime/realtime'); + +const forbiddenAlias = ['', 'new', 'me', 'history', '403', '404', '500', 'config']; +const sanitize = (alias) => { + return alias.replace(/( |\/)/, ''); +} + +const asyncGetNote = async (originAliasOrNoteId) => { + const noteId = await Note.parseNoteIdAsync(originAliasOrNoteId); + const note = await Note.findOne({ + where: { + id: noteId + } + }) + if (!note) { + throw Error('Can\'t find the note.'); + } + return note; +} + +const asyncGetNoteIdForAliasConflict = async (alias) => { + const sanitizedAlias = sanitize(alias); + const p1 = Note.findOne({ + where: { + alias: sanitizedAlias + } + }); + const p2 = ArchivedNoteAlias.findOne({ + where: { + alias: sanitizedAlias + } + }); + const [conflictNote, conflictAarchivedAlias] = await Promise.all([p1, p2]); + + if (conflictNote) { + return conflictNote.id + } + if (conflictAarchivedAlias) { + return conflictAarchivedAlias.noteId + } + return null; +} + +const asyncCheckAliasValid = async (originAliasOrNoteId, alias) => { + const sanitizedAlias = sanitize(alias); + if (forbiddenAlias.indexOf(sanitizedAlias) > -1) { + return false; + } + + const conflictNoteId = await asyncGetNoteIdForAliasConflict(alias) + .catch((err) => { throw err }); + + const note = await asyncGetNote(originAliasOrNoteId) + .catch((err) => { throw err }); + + return !conflictNoteId || conflictNoteId === note.id; +} + +const asyncUpdateAlias = async (originAliasOrNoteId, alias) => { + const sanitizedAlias = sanitize(alias); + const note = await asyncGetNote(originAliasOrNoteId) + .catch((err) => { throw err }); + + const t = await sequelize.transaction(); + if (note.alias) { + const archivedAlias = await ArchivedNoteAlias.findOne({ + where: { + alias: note.alias, + } + }) + .catch(async err => { throw err }) + if (!archivedAlias) { + await ArchivedNoteAlias.create({ + noteId: note.id, + alias: note.alias + }, { transaction: t }) + .catch(async err => { + await t.rollback(); + throw Error('Add archived note alias failed. ' + err.message); + }) + } + } + + const updatedNote = await note.update({ + alias: sanitizedAlias, + lastchangeAt: Date.now() + }, { transaction: t }) + .catch(async err => { + await t.rollback(); + throw Error('Write note content error. ' + err.message); + }) + + await t.commit(); + + realtime.io.to(updatedNote.id) + .emit('alias updated', { + alias: updatedNote.alias + }); + + return true; +} + +exports.sanitize = sanitize; +exports.asyncGetNote = asyncGetNote; +exports.asyncCheckAliasValid = asyncCheckAliasValid; +exports.asyncUpdateAlias = asyncUpdateAlias; diff --git a/lib/routes.js b/lib/routes.js index 9d15c349..a6ac0dc2 100644 --- a/lib/routes.js +++ b/lib/routes.js @@ -71,7 +71,11 @@ appRouter.get('/s/:shortid/:action', response.publishNoteActions) appRouter.get('/p/:shortid', response.showPublishSlide) // publish slide actions appRouter.get('/p/:shortid/:action', response.publishSlideActions) -// gey my note list +// check note alais valid +appRouter.get('/api/notes/:originAliasOrNoteId/checkAliasValid', noteController.checkAliasValid) +// update note alias +appRouter.patch('/api/notes/:originAliasOrNoteId/alias', bodyParser.json(), noteController.updateNoteAlias) +// get my note list appRouter.get('/api/notes/myNotes', noteController.listMyNotes) // delete note by id appRouter.delete('/api/notes/:noteId', noteController.deleteNote) diff --git a/locales/en.json b/locales/en.json index b2883192..9ccd7174 100644 --- a/locales/en.json +++ b/locales/en.json @@ -117,5 +117,7 @@ "Powered by %s": "Powered by %s", "Register": "Register", "Export with pandoc": "Export with pandoc", - "Select output format": "Select output format" + "Select output format": "Select output format", + "Custom Note Url": "Custom Note Url", + "Submit": "Submit" } \ No newline at end of file diff --git a/locales/zh-TW.json b/locales/zh-TW.json index e6c1951a..de12660c 100644 --- a/locales/zh-TW.json +++ b/locales/zh-TW.json @@ -117,5 +117,7 @@ "Powered by %s": "技術支援:%s", "Register": "註冊", "Export with pandoc": "使用 pandoc 匯出", - "Select output format": "選擇輸出格式" + "Select output format": "選擇輸出格式", + "Custom Note Url": "自訂筆記網址", + "Submit": "送出" } diff --git a/public/js/index.js b/public/js/index.js index c736cb7f..9d09f87d 100644 --- a/public/js/index.js +++ b/public/js/index.js @@ -1266,6 +1266,73 @@ $('#revisionModalRevert').click(function () { editor.setValue(revision.content) ui.modal.revision.modal('hide') }) + +// custom note url modal +const checkNoteUrlValid = (noteUrl = '') => { + return new Promise((resolve) => { + $.ajax({ + method: 'GET', + url: `/api/notes/${noteid}/checkAliasValid`, + data: { + alias: noteUrl + }, + success: (data) => { + resolve(data.isValid); + } + }) + }); +} + +const updateNoteUrl = (noteUrl = '') => { + return new Promise((resolve, reject) => { + $.ajax({ + method: 'PATCH', + url: `/api/notes/${noteid}/alias`, + data: JSON.stringify({ + alias: noteUrl + }), + contentType: "application/json;charset=utf-8", + success: (data) => { + resolve(data.status === 'ok'); + }, + error: reject + }) + }); +} + +ui.modal.customNoteUrl.on('submit', function (e) { + e.preventDefault(); + const showErrorMessage = (msg) => { + ui.modal.customNoteUrl.find('.error-message').text(msg); + ui.modal.customNoteUrl.find('.alert').show(); + } + const hideErrorMessage = () => ui.modal.customNoteUrl.find('.alert').hide(); + + const customUrl = ui.modal.customNoteUrl.find('[name="custom-url"]').val(); + checkNoteUrlValid(customUrl) + .then(isValid => { + if (!isValid) { + showErrorMessage('The url is exist.'); + return ; + } + hideErrorMessage(); + return updateNoteUrl(customUrl); + }) + .then(isSuccess => { + if(isSuccess){ + hideErrorMessage(); + ui.modal.customNoteUrl.modal('hide') + } + }, err => { + if(err.status == 403){ + showErrorMessage('Only note owner can edit custom url.'); + } + }) + .catch((err) => { + showErrorMessage('Something wrong: ' + err.message); + }) +}) + // snippet projects ui.modal.snippetImportProjects.change(function () { var accesstoken = $('#snippetImportModalAccessToken').val() @@ -1803,6 +1870,7 @@ socket.on('version', function (data) { } } }) + var authors = [] var authorship = [] var authorMarks = {} // temp variable @@ -2217,6 +2285,11 @@ socket.on('cursor blur', function (data) { } }) +socket.on('alias updated', function (data) { + const alias = data.alias; + history.replaceState({}, '', alias) +}); + var options = { valueNames: ['id', 'name'], item: '
  • ' + diff --git a/public/js/lib/editor/ui-elements.js b/public/js/lib/editor/ui-elements.js index d084607f..9c0abc42 100644 --- a/public/js/lib/editor/ui-elements.js +++ b/public/js/lib/editor/ui-elements.js @@ -83,7 +83,8 @@ export const getUIElements = () => ({ snippetImportProjects: $('#snippetImportModalProjects'), snippetImportSnippets: $('#snippetImportModalSnippets'), revision: $('#revisionModal'), - pandocExport: $('.pandoc-export-modal') + pandocExport: $('.pandoc-export-modal'), + customNoteUrl: $('#customNoteUrlModal') } }) diff --git a/public/views/codimd/body.ejs b/public/views/codimd/body.ejs index c688ae38..07184f2d 100644 --- a/public/views/codimd/body.ejs +++ b/public/views/codimd/body.ejs @@ -251,3 +251,4 @@ <%- include ../shared/help-modal %> <%- include ../shared/revision-modal %> <%- include ../shared/pandoc-export-modal %> +<%- include ../shared/custom-note-url-modal %> diff --git a/public/views/codimd/header.ejs b/public/views/codimd/header.ejs index 86c006f7..6466a2c8 100644 --- a/public/views/codimd/header.ejs +++ b/public/views/codimd/header.ejs @@ -28,6 +28,8 @@
  • +
  • <%= __('Custom Note Url') %> +
  • <%= __('Revision') %>
  • <%= __('Slide Mode') %> @@ -132,6 +134,8 @@