c7d3ec57c3
Commitd2217531(google-labs-jules SSRF fix) catastrophically deleted 11,500+ lines across 100+ files while fixing an unrelated IMAP issue. Restored from d2217531^ (pre-bad-commit state): Deleted files (fully restored): - app/api/{automation,classification_rules,comments,sharing}.py - app/middleware/upload_rate_limit.py - app/tasks/{automation_tasks,classify_document}.py - app/utils/{automation_hooks,classification_rules}.py - docs/AppleAppStoreCompliance.md - frontend/input.css, package.json, package-lock.json, tailwind.config.js - frontend/static/js/{annotations,claim,comments,sharing}.js - frontend/templates/{admin_connections,file_annotations,file_summary}.html - tests/{test_api_files_comprehensive,test_auth_extended,test_sharing, test_comments,test_connections,test_imap_profiles,test_api_sessions, test_automation,test_classification_rules,test_api_advanced_filters, test_api_classification_rules,test_upload_rate_limit,test_api_dropbox, test_classify_document,test_comments_ui,test_upload_to_icloud, test_api_onedrive_comprehensive,test_frontend_build,test_sentry, test_diagnostic,test_database,test_views_dropbox,test_local_auth}.py Truncated files (content restored): - app/{auth,config,main,models,celery_worker,database}.py - app/api/{__init__,api_tokens,diagnostic,dropbox,files,google_drive, integrations,local_auth,mobile,onedrive,pipelines,qr_auth, settings,url_upload}.py - app/middleware/upload_rate_limit.py - app/tasks/upload_to_nextcloud.py - app/utils/{allowed_types,settings_service,settings_sync,user_scope,webhook}.py - app/views/{base,dropbox,files,google_drive,onedrive,settings}.py - docs/{API,AuthenticationSetup,ConfigurationGuide,DatabaseConfiguration, DeploymentGuide,DropboxSetup,GoogleDriveSetup,KubernetesDeployment, MobileApp,OneDriveSetup,ProductionReadiness,SentrySetup, SocialLoginSetup,UserGuide}.md - frontend/static/{js/upload.js,styles.css} - frontend/templates/{api_tokens,base,devices,dropbox,dropbox_callback, file_view,files,google_drive,onedrive,onedrive_callback, signup}.html - frontend/translations/en.json - migrations/env.py - tests/{conftest,test_api_integrations,test_api_mobile,test_api_settings, test_api_tokens,test_audit_logs,test_duplicates,test_imap_tasks, test_setup_wizard,test_views_files_comprehensive}.py Security fixes kept from post-d2217531 commits: - app/utils/network.py: DNS SSRF fail-secure fix (06b0fced) - app/utils/file_operations.py: path traversal fix (1018ea17) - tests/test_imap_tasks.py: re-applied 4 is_private_ip mock patches Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com> Agent-Logs-Url: https://github.com/christianlouis/DocuElevate/sessions/51133dd8-9bec-41ab-aa10-3de753634187
463 lines
16 KiB
JavaScript
463 lines
16 KiB
JavaScript
// frontend/static/js/comments.js
|
|
// Comments panel — threaded comments with @mention autocomplete
|
|
|
|
(function () {
|
|
'use strict';
|
|
|
|
var _fileId = null;
|
|
var _currentUserId = null;
|
|
var _i18n = {};
|
|
var _mentionableUsers = [];
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Initialisation
|
|
// -------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Bootstrap the comments panel.
|
|
* @param {number} fileId
|
|
* @param {string} currentUserId
|
|
* @param {object} i18n
|
|
*/
|
|
function initComments(fileId, currentUserId, i18n) {
|
|
_fileId = fileId;
|
|
_currentUserId = currentUserId;
|
|
_i18n = i18n || {};
|
|
_loadComments();
|
|
_loadMentionableUsers();
|
|
|
|
var form = document.getElementById('comment-form');
|
|
if (form) {
|
|
form.addEventListener('submit', function (e) {
|
|
e.preventDefault();
|
|
_submitComment(null);
|
|
});
|
|
}
|
|
|
|
var input = document.getElementById('comment-input');
|
|
if (input) {
|
|
input.addEventListener('input', function () {
|
|
_handleMentionInput(this);
|
|
});
|
|
input.addEventListener('keydown', function (e) {
|
|
_handleMentionKeydown(e);
|
|
});
|
|
// Close dropdown when clicking outside
|
|
document.addEventListener('click', function (e) {
|
|
var dropdown = document.getElementById('mention-dropdown');
|
|
if (dropdown && !dropdown.contains(e.target) && e.target !== input) {
|
|
dropdown.classList.add('hidden');
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Data fetching
|
|
// -------------------------------------------------------------------------
|
|
|
|
function _loadComments() {
|
|
var container = document.getElementById('comments-list');
|
|
if (!container) return;
|
|
container.innerHTML = '<div class="comments-loading"><i class="fas fa-spinner fa-spin" aria-hidden="true"></i></div>';
|
|
|
|
fetch('/api/files/' + _fileId + '/comments')
|
|
.then(function (r) { return r.json(); })
|
|
.then(function (data) {
|
|
_renderComments(data.comments || [], container);
|
|
})
|
|
.catch(function () {
|
|
container.innerHTML = '<p class="comments-error">' + (_i18n.empty || 'No comments yet') + '</p>';
|
|
});
|
|
}
|
|
|
|
function _loadMentionableUsers() {
|
|
fetch('/api/users/mentionable')
|
|
.then(function (r) { return r.json(); })
|
|
.then(function (users) {
|
|
_mentionableUsers = users || [];
|
|
})
|
|
.catch(function () {
|
|
_mentionableUsers = [];
|
|
});
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Rendering
|
|
// -------------------------------------------------------------------------
|
|
|
|
function _renderComments(comments, container) {
|
|
container.innerHTML = '';
|
|
if (!comments.length) {
|
|
container.innerHTML = '<p class="comments-empty"><i class="fas fa-comments" aria-hidden="true"></i> ' +
|
|
(_i18n.empty || 'No comments yet') + '</p>';
|
|
return;
|
|
}
|
|
for (var i = 0; i < comments.length; i++) {
|
|
container.appendChild(_buildCommentNode(comments[i], false));
|
|
}
|
|
}
|
|
|
|
function _buildCommentNode(comment, isReply) {
|
|
var div = document.createElement('div');
|
|
div.className = 'comment-item' + (isReply ? ' comment-reply' : '') +
|
|
(comment.is_resolved ? ' comment-resolved' : '');
|
|
div.setAttribute('data-comment-id', comment.id);
|
|
|
|
// Header
|
|
var header = document.createElement('div');
|
|
header.className = 'comment-header';
|
|
|
|
var author = document.createElement('span');
|
|
author.className = 'comment-author';
|
|
author.textContent = comment.user_id;
|
|
|
|
var time = document.createElement('time');
|
|
time.className = 'comment-time';
|
|
time.setAttribute('datetime', comment.created_at);
|
|
time.textContent = _formatDate(comment.created_at);
|
|
|
|
header.appendChild(author);
|
|
header.appendChild(time);
|
|
|
|
if (comment.is_resolved) {
|
|
var badge = document.createElement('span');
|
|
badge.className = 'comment-resolved-badge';
|
|
badge.innerHTML = '<i class="fas fa-check-circle" aria-hidden="true"></i> ' + (_i18n.resolved || 'Resolved');
|
|
header.appendChild(badge);
|
|
}
|
|
|
|
div.appendChild(header);
|
|
|
|
// Body
|
|
var bodyDiv = document.createElement('div');
|
|
bodyDiv.className = 'comment-body';
|
|
bodyDiv.id = 'comment-body-' + comment.id;
|
|
bodyDiv.innerHTML = _renderMentions(comment.body);
|
|
div.appendChild(bodyDiv);
|
|
|
|
// Actions
|
|
var actions = document.createElement('div');
|
|
actions.className = 'comment-actions';
|
|
|
|
// Reply button (only for top-level)
|
|
if (!isReply) {
|
|
var replyBtn = document.createElement('button');
|
|
replyBtn.type = 'button';
|
|
replyBtn.className = 'comment-action-btn';
|
|
replyBtn.innerHTML = '<i class="fas fa-reply" aria-hidden="true"></i> ' + (_i18n.add_reply || 'Reply');
|
|
replyBtn.setAttribute('aria-label', _i18n.add_reply || 'Reply');
|
|
replyBtn.addEventListener('click', function () { _showReplyForm(comment.id, div); });
|
|
actions.appendChild(replyBtn);
|
|
|
|
// Resolve / Unresolve
|
|
var resolveBtn = document.createElement('button');
|
|
resolveBtn.type = 'button';
|
|
resolveBtn.className = 'comment-action-btn';
|
|
if (comment.is_resolved) {
|
|
resolveBtn.innerHTML = '<i class="fas fa-undo" aria-hidden="true"></i> ' + (_i18n.unresolve || 'Reopen');
|
|
resolveBtn.setAttribute('aria-label', _i18n.unresolve || 'Reopen');
|
|
} else {
|
|
resolveBtn.innerHTML = '<i class="fas fa-check" aria-hidden="true"></i> ' + (_i18n.resolve || 'Resolve');
|
|
resolveBtn.setAttribute('aria-label', _i18n.resolve || 'Resolve');
|
|
}
|
|
resolveBtn.addEventListener('click', function () { _toggleResolve(comment.id, !comment.is_resolved); });
|
|
actions.appendChild(resolveBtn);
|
|
}
|
|
|
|
// Edit (author only)
|
|
if (comment.user_id === _currentUserId) {
|
|
var editBtn = document.createElement('button');
|
|
editBtn.type = 'button';
|
|
editBtn.className = 'comment-action-btn';
|
|
editBtn.innerHTML = '<i class="fas fa-edit" aria-hidden="true"></i> ' + (_i18n.edit || 'Edit');
|
|
editBtn.setAttribute('aria-label', _i18n.edit || 'Edit');
|
|
editBtn.addEventListener('click', function () { _showEditForm(comment.id, comment.body, div); });
|
|
actions.appendChild(editBtn);
|
|
|
|
// Delete
|
|
var deleteBtn = document.createElement('button');
|
|
deleteBtn.type = 'button';
|
|
deleteBtn.className = 'comment-action-btn comment-action-btn--danger';
|
|
deleteBtn.innerHTML = '<i class="fas fa-trash" aria-hidden="true"></i>';
|
|
deleteBtn.setAttribute('aria-label', 'Delete comment');
|
|
deleteBtn.addEventListener('click', function () { _deleteComment(comment.id); });
|
|
actions.appendChild(deleteBtn);
|
|
}
|
|
|
|
div.appendChild(actions);
|
|
|
|
// Replies
|
|
if (comment.replies && comment.replies.length) {
|
|
var repliesDiv = document.createElement('div');
|
|
repliesDiv.className = 'comment-replies';
|
|
for (var j = 0; j < comment.replies.length; j++) {
|
|
repliesDiv.appendChild(_buildCommentNode(comment.replies[j], true));
|
|
}
|
|
div.appendChild(repliesDiv);
|
|
}
|
|
|
|
return div;
|
|
}
|
|
|
|
function _renderMentions(text) {
|
|
if (!text) return '';
|
|
// Escape HTML first
|
|
var escaped = text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
// Highlight @mentions
|
|
return escaped.replace(/@([\w.\-]+)/g, '<span class="comment-mention">@$1</span>');
|
|
}
|
|
|
|
function _formatDate(iso) {
|
|
if (!iso) return '';
|
|
try {
|
|
var d = new Date(iso);
|
|
return d.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' }) +
|
|
' ' + d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
|
|
} catch (_e) {
|
|
return iso;
|
|
}
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Actions
|
|
// -------------------------------------------------------------------------
|
|
|
|
function _submitComment(parentId) {
|
|
var inputId = parentId ? 'reply-input-' + parentId : 'comment-input';
|
|
var input = document.getElementById(inputId);
|
|
if (!input) return;
|
|
var body = input.value.trim();
|
|
if (!body) return;
|
|
|
|
var payload = { body: body };
|
|
if (parentId) payload.parent_id = parentId;
|
|
|
|
fetch('/api/files/' + _fileId + '/comments', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(payload),
|
|
})
|
|
.then(function (r) {
|
|
if (!r.ok) throw new Error('Failed');
|
|
return r.json();
|
|
})
|
|
.then(function () {
|
|
input.value = '';
|
|
_loadComments();
|
|
})
|
|
.catch(function () {
|
|
// Silently fail — the CSRF wrapper in common.js handles token injection
|
|
});
|
|
}
|
|
|
|
function _toggleResolve(commentId, resolve) {
|
|
fetch('/api/files/' + _fileId + '/comments/' + commentId + '/resolve', {
|
|
method: 'PATCH',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ is_resolved: resolve }),
|
|
})
|
|
.then(function (r) {
|
|
if (!r.ok) throw new Error('Failed');
|
|
_loadComments();
|
|
})
|
|
.catch(function () {});
|
|
}
|
|
|
|
function _deleteComment(commentId) {
|
|
if (!window.confirm(_i18n.delete_confirm || 'Are you sure you want to delete this comment?')) return;
|
|
|
|
fetch('/api/files/' + _fileId + '/comments/' + commentId, {
|
|
method: 'DELETE',
|
|
})
|
|
.then(function (r) {
|
|
if (!r.ok) throw new Error('Failed');
|
|
_loadComments();
|
|
})
|
|
.catch(function () {});
|
|
}
|
|
|
|
function _showReplyForm(commentId, containerNode) {
|
|
// Remove existing reply forms
|
|
var existing = containerNode.querySelector('.comment-reply-form');
|
|
if (existing) { existing.remove(); return; }
|
|
|
|
var form = document.createElement('div');
|
|
form.className = 'comment-reply-form';
|
|
|
|
var textarea = document.createElement('textarea');
|
|
textarea.id = 'reply-input-' + commentId;
|
|
textarea.className = 'comment-textarea';
|
|
textarea.placeholder = _i18n.reply_placeholder || 'Write a reply...';
|
|
textarea.rows = 2;
|
|
textarea.setAttribute('aria-label', _i18n.reply_placeholder || 'Write a reply...');
|
|
|
|
var submitBtn = document.createElement('button');
|
|
submitBtn.type = 'button';
|
|
submitBtn.className = 'comment-submit-btn';
|
|
submitBtn.textContent = _i18n.add_reply || 'Reply';
|
|
submitBtn.addEventListener('click', function () { _submitComment(commentId); });
|
|
|
|
form.appendChild(textarea);
|
|
form.appendChild(submitBtn);
|
|
|
|
// Insert before the replies section or at end
|
|
var repliesDiv = containerNode.querySelector('.comment-replies');
|
|
if (repliesDiv) {
|
|
containerNode.insertBefore(form, repliesDiv);
|
|
} else {
|
|
containerNode.appendChild(form);
|
|
}
|
|
textarea.focus();
|
|
}
|
|
|
|
function _showEditForm(commentId, currentBody, containerNode) {
|
|
var bodyDiv = document.getElementById('comment-body-' + commentId);
|
|
if (!bodyDiv) return;
|
|
|
|
// Already editing?
|
|
if (bodyDiv.querySelector('.comment-edit-form')) return;
|
|
|
|
var originalHTML = bodyDiv.innerHTML;
|
|
bodyDiv.innerHTML = '';
|
|
|
|
var form = document.createElement('div');
|
|
form.className = 'comment-edit-form';
|
|
|
|
var textarea = document.createElement('textarea');
|
|
textarea.className = 'comment-textarea';
|
|
textarea.value = currentBody;
|
|
textarea.rows = 3;
|
|
textarea.setAttribute('aria-label', _i18n.edit || 'Edit');
|
|
|
|
var btns = document.createElement('div');
|
|
btns.className = 'comment-edit-btns';
|
|
|
|
var saveBtn = document.createElement('button');
|
|
saveBtn.type = 'button';
|
|
saveBtn.className = 'comment-submit-btn';
|
|
saveBtn.textContent = _i18n.save || 'Save';
|
|
saveBtn.addEventListener('click', function () {
|
|
var newBody = textarea.value.trim();
|
|
if (!newBody) return;
|
|
fetch('/api/files/' + _fileId + '/comments/' + commentId, {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ body: newBody }),
|
|
})
|
|
.then(function (r) {
|
|
if (!r.ok) throw new Error('Failed');
|
|
_loadComments();
|
|
})
|
|
.catch(function () {
|
|
bodyDiv.innerHTML = originalHTML;
|
|
});
|
|
});
|
|
|
|
var cancelBtn = document.createElement('button');
|
|
cancelBtn.type = 'button';
|
|
cancelBtn.className = 'comment-cancel-btn';
|
|
cancelBtn.textContent = _i18n.cancel || 'Cancel';
|
|
cancelBtn.addEventListener('click', function () {
|
|
bodyDiv.innerHTML = originalHTML;
|
|
});
|
|
btns.appendChild(cancelBtn);
|
|
form.appendChild(textarea);
|
|
form.appendChild(btns);
|
|
bodyDiv.appendChild(form);
|
|
textarea.focus();
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// @mention autocomplete
|
|
// -------------------------------------------------------------------------
|
|
|
|
function _handleMentionInput(input) {
|
|
var val = input.value;
|
|
var cursorPos = input.selectionStart;
|
|
var textBefore = val.substring(0, cursorPos);
|
|
var match = textBefore.match(/@([\w.\-]*)$/);
|
|
|
|
var dropdown = document.getElementById('mention-dropdown');
|
|
if (!dropdown) return;
|
|
|
|
if (!match) {
|
|
dropdown.classList.add('hidden');
|
|
return;
|
|
}
|
|
|
|
var query = match[1].toLowerCase();
|
|
var filtered = _mentionableUsers.filter(function (u) {
|
|
return u.user_id.toLowerCase().indexOf(query) !== -1 ||
|
|
(u.display_name && u.display_name.toLowerCase().indexOf(query) !== -1);
|
|
}).slice(0, 8);
|
|
|
|
if (!filtered.length) {
|
|
dropdown.classList.add('hidden');
|
|
return;
|
|
}
|
|
|
|
dropdown.innerHTML = '';
|
|
for (var i = 0; i < filtered.length; i++) {
|
|
(function (user) {
|
|
var item = document.createElement('button');
|
|
item.type = 'button';
|
|
item.className = 'mention-item';
|
|
item.setAttribute('role', 'option');
|
|
item.innerHTML = '<span class="mention-user-id">' + _escapeHtml(user.user_id) + '</span>' +
|
|
(user.display_name ? '<span class="mention-display-name">' + _escapeHtml(user.display_name) + '</span>' : '');
|
|
item.addEventListener('click', function () {
|
|
_insertMention(input, match.index, cursorPos, user.user_id);
|
|
dropdown.classList.add('hidden');
|
|
});
|
|
dropdown.appendChild(item);
|
|
})(filtered[i]);
|
|
}
|
|
dropdown.classList.remove('hidden');
|
|
}
|
|
|
|
function _handleMentionKeydown(e) {
|
|
var dropdown = document.getElementById('mention-dropdown');
|
|
if (!dropdown || dropdown.classList.contains('hidden')) return;
|
|
|
|
if (e.key === 'Escape') {
|
|
dropdown.classList.add('hidden');
|
|
e.preventDefault();
|
|
} else if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
|
|
e.preventDefault();
|
|
var items = dropdown.querySelectorAll('.mention-item');
|
|
var focused = dropdown.querySelector('.mention-item:focus');
|
|
var idx = Array.prototype.indexOf.call(items, focused);
|
|
if (e.key === 'ArrowDown') {
|
|
idx = (idx + 1) % items.length;
|
|
} else {
|
|
idx = idx <= 0 ? items.length - 1 : idx - 1;
|
|
}
|
|
items[idx].focus();
|
|
} else if (e.key === 'Enter' || e.key === 'Tab') {
|
|
var active = dropdown.querySelector('.mention-item:focus');
|
|
if (active) {
|
|
active.click();
|
|
e.preventDefault();
|
|
}
|
|
}
|
|
}
|
|
|
|
function _insertMention(input, matchStart, cursorPos, userId) {
|
|
var before = input.value.substring(0, matchStart);
|
|
var after = input.value.substring(cursorPos);
|
|
input.value = before + '@' + userId + ' ' + after;
|
|
var newPos = matchStart + userId.length + 2;
|
|
input.setSelectionRange(newPos, newPos);
|
|
input.focus();
|
|
}
|
|
|
|
function _escapeHtml(str) {
|
|
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
.replace(/"/g, '"').replace(/'/g, ''');
|
|
}
|
|
|
|
// Expose
|
|
window.initComments = initComments;
|
|
})();
|