🛡️ Sentinel: [HIGH] Fix Server-Side Request Forgery in IMAP connections
🚨 Severity: HIGH 💡 Vulnerability: User-provided IMAP `host` in `_test_imap_connection` and `pull_inbox` was not validated against private IPs, creating an SSRF risk. 🎯 Impact: Attackers could abuse the endpoints to port-scan or interact with internal/private network services. 🔧 Fix: Integrated `is_private_ip` from `app.utils.network` to block connections resolving to private, loopback, link-local, or reserved IPs. ✅ Verification: Ran `test_imap_tasks.py` and `test_api_imap_accounts.py` successfully. Checked `ruff` output and diffs. Removed all scratch files from the commit. Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
This commit is contained in:
@@ -1,307 +0,0 @@
|
||||
// frontend/static/js/annotations.js
|
||||
// Annotations panel — CRUD for PDF page annotations
|
||||
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var _fileId = null;
|
||||
var _currentUserId = null;
|
||||
var _i18n = {};
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Initialisation
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Bootstrap the annotations panel.
|
||||
* @param {number} fileId
|
||||
* @param {string} currentUserId
|
||||
* @param {object} i18n
|
||||
*/
|
||||
function initAnnotations(fileId, currentUserId, i18n) {
|
||||
_fileId = fileId;
|
||||
_currentUserId = currentUserId;
|
||||
_i18n = i18n || {};
|
||||
_loadAnnotations();
|
||||
|
||||
// Expose reload function so the EmbedPDF viewer init script can refresh the
|
||||
// list after auto-saving an annotation created inside the viewer.
|
||||
window._reloadAnnotations = _loadAnnotations;
|
||||
|
||||
var form = document.getElementById('annotation-form');
|
||||
if (form) {
|
||||
form.addEventListener('submit', function (e) {
|
||||
e.preventDefault();
|
||||
_createAnnotation();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Data fetching
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
function _loadAnnotations() {
|
||||
var container = document.getElementById('annotations-list');
|
||||
if (!container) return;
|
||||
container.innerHTML = '<div class="annotations-loading"><i class="fas fa-spinner fa-spin" aria-hidden="true"></i></div>';
|
||||
|
||||
fetch('/api/files/' + _fileId + '/annotations')
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (data) {
|
||||
_renderAnnotations(data.annotations || [], container);
|
||||
})
|
||||
.catch(function () {
|
||||
container.innerHTML = '<p class="annotations-empty">' + (_i18n.empty || 'No annotations yet') + '</p>';
|
||||
});
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Rendering
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
function _renderAnnotations(annotations, container) {
|
||||
container.innerHTML = '';
|
||||
if (!annotations.length) {
|
||||
container.innerHTML = '<p class="annotations-empty"><i class="fas fa-sticky-note" aria-hidden="true"></i> ' +
|
||||
(_i18n.empty || 'No annotations yet') + '</p>';
|
||||
return;
|
||||
}
|
||||
for (var i = 0; i < annotations.length; i++) {
|
||||
container.appendChild(_buildAnnotationNode(annotations[i]));
|
||||
}
|
||||
}
|
||||
|
||||
function _buildAnnotationNode(ann) {
|
||||
var div = document.createElement('div');
|
||||
div.className = 'annotation-item';
|
||||
div.setAttribute('data-annotation-id', ann.id);
|
||||
|
||||
// Type badge + color indicator
|
||||
var header = document.createElement('div');
|
||||
header.className = 'annotation-header';
|
||||
|
||||
var typeBadge = document.createElement('span');
|
||||
typeBadge.className = 'annotation-type annotation-type--' + ann.annotation_type;
|
||||
typeBadge.textContent = _i18n['type_' + ann.annotation_type] || ann.annotation_type;
|
||||
|
||||
var pageInfo = document.createElement('button');
|
||||
pageInfo.type = 'button';
|
||||
pageInfo.className = 'annotation-page annotation-page--link';
|
||||
pageInfo.setAttribute('aria-label', (_i18n.go_to_page || 'Go to page') + ' ' + ann.page);
|
||||
pageInfo.title = (_i18n.go_to_page || 'Go to page') + ' ' + ann.page;
|
||||
pageInfo.innerHTML = '<i class="fas fa-file-alt" aria-hidden="true"></i> ' +
|
||||
(_i18n.page || 'Page') + ' ' + ann.page;
|
||||
pageInfo.addEventListener('click', function () {
|
||||
if (typeof window._embedpdfScrollToPage === 'function') {
|
||||
window._embedpdfScrollToPage(ann.page);
|
||||
}
|
||||
});
|
||||
|
||||
header.appendChild(typeBadge);
|
||||
if (ann.color) {
|
||||
var colorDot = document.createElement('span');
|
||||
colorDot.className = 'annotation-color-dot';
|
||||
colorDot.style.backgroundColor = ann.color;
|
||||
colorDot.setAttribute('aria-label', (_i18n.color || 'Color') + ': ' + ann.color);
|
||||
header.appendChild(colorDot);
|
||||
}
|
||||
header.appendChild(pageInfo);
|
||||
|
||||
div.appendChild(header);
|
||||
|
||||
// Content
|
||||
var content = document.createElement('div');
|
||||
content.className = 'annotation-content';
|
||||
content.id = 'annotation-content-' + ann.id;
|
||||
content.textContent = ann.content;
|
||||
div.appendChild(content);
|
||||
|
||||
// Meta
|
||||
var meta = document.createElement('div');
|
||||
meta.className = 'annotation-meta';
|
||||
|
||||
var author = document.createElement('span');
|
||||
author.className = 'annotation-author';
|
||||
author.textContent = ann.user_id;
|
||||
|
||||
var time = document.createElement('time');
|
||||
time.className = 'annotation-time';
|
||||
time.setAttribute('datetime', ann.created_at);
|
||||
time.textContent = _formatDate(ann.created_at);
|
||||
|
||||
meta.appendChild(author);
|
||||
meta.appendChild(time);
|
||||
div.appendChild(meta);
|
||||
|
||||
// Actions (author only)
|
||||
if (ann.user_id === _currentUserId) {
|
||||
var actions = document.createElement('div');
|
||||
actions.className = 'annotation-actions';
|
||||
|
||||
var editBtn = document.createElement('button');
|
||||
editBtn.type = 'button';
|
||||
editBtn.className = 'annotation-action-btn';
|
||||
editBtn.innerHTML = '<i class="fas fa-edit" aria-hidden="true"></i>';
|
||||
editBtn.setAttribute('aria-label', 'Edit annotation');
|
||||
editBtn.addEventListener('click', function () { _showEditForm(ann); });
|
||||
actions.appendChild(editBtn);
|
||||
|
||||
var deleteBtn = document.createElement('button');
|
||||
deleteBtn.type = 'button';
|
||||
deleteBtn.className = 'annotation-action-btn annotation-action-btn--danger';
|
||||
deleteBtn.innerHTML = '<i class="fas fa-trash" aria-hidden="true"></i>';
|
||||
deleteBtn.setAttribute('aria-label', 'Delete annotation');
|
||||
deleteBtn.addEventListener('click', function () { _deleteAnnotation(ann.id); });
|
||||
actions.appendChild(deleteBtn);
|
||||
|
||||
div.appendChild(actions);
|
||||
}
|
||||
|
||||
return div;
|
||||
}
|
||||
|
||||
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 _createAnnotation() {
|
||||
var content = document.getElementById('annotation-content-input');
|
||||
var page = document.getElementById('annotation-page-input');
|
||||
var type = document.getElementById('annotation-type-input');
|
||||
var color = document.getElementById('annotation-color-input');
|
||||
|
||||
if (!content || !content.value.trim()) return;
|
||||
|
||||
var payload = {
|
||||
content: content.value.trim(),
|
||||
page: parseInt(page ? page.value : '1', 10) || 1,
|
||||
annotation_type: type ? type.value : 'note',
|
||||
color: color ? color.value : null,
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 0,
|
||||
height: 0,
|
||||
};
|
||||
|
||||
fetch('/api/files/' + _fileId + '/annotations', {
|
||||
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 () {
|
||||
content.value = '';
|
||||
if (page) page.value = '1';
|
||||
_loadAnnotations();
|
||||
})
|
||||
.catch(function () {});
|
||||
}
|
||||
|
||||
function _deleteAnnotation(annotationId) {
|
||||
if (!window.confirm(_i18n.delete_confirm || 'Are you sure you want to delete this annotation?')) return;
|
||||
|
||||
fetch('/api/files/' + _fileId + '/annotations/' + annotationId, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
.then(function (r) {
|
||||
if (!r.ok) throw new Error('Failed');
|
||||
_loadAnnotations();
|
||||
})
|
||||
.catch(function () {});
|
||||
}
|
||||
|
||||
function _showEditForm(ann) {
|
||||
var contentDiv = document.getElementById('annotation-content-' + ann.id);
|
||||
if (!contentDiv) return;
|
||||
if (contentDiv.querySelector('.annotation-edit-form')) return;
|
||||
|
||||
var originalText = contentDiv.textContent;
|
||||
contentDiv.textContent = '';
|
||||
|
||||
var form = document.createElement('div');
|
||||
form.className = 'annotation-edit-form';
|
||||
|
||||
var textarea = document.createElement('textarea');
|
||||
textarea.className = 'annotation-textarea';
|
||||
textarea.value = ann.content;
|
||||
textarea.rows = 3;
|
||||
textarea.setAttribute('aria-label', 'Edit annotation');
|
||||
|
||||
var typeSelect = document.createElement('select');
|
||||
typeSelect.className = 'annotation-select';
|
||||
typeSelect.setAttribute('aria-label', 'Annotation type');
|
||||
var types = ['note', 'highlight', 'underline', 'strikethrough'];
|
||||
for (var i = 0; i < types.length; i++) {
|
||||
var opt = document.createElement('option');
|
||||
opt.value = types[i];
|
||||
opt.textContent = _i18n['type_' + types[i]] || types[i];
|
||||
if (types[i] === ann.annotation_type) opt.selected = true;
|
||||
typeSelect.appendChild(opt);
|
||||
}
|
||||
|
||||
var btns = document.createElement('div');
|
||||
btns.className = 'annotation-edit-btns';
|
||||
|
||||
var saveBtn = document.createElement('button');
|
||||
saveBtn.type = 'button';
|
||||
saveBtn.className = 'annotation-submit-btn';
|
||||
saveBtn.textContent = _i18n.save || 'Save';
|
||||
saveBtn.addEventListener('click', function () {
|
||||
var newContent = textarea.value.trim();
|
||||
if (!newContent) return;
|
||||
fetch('/api/files/' + _fileId + '/annotations/' + ann.id, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
content: newContent,
|
||||
annotation_type: typeSelect.value,
|
||||
page: ann.page,
|
||||
x: ann.x,
|
||||
y: ann.y,
|
||||
}),
|
||||
})
|
||||
.then(function (r) {
|
||||
if (!r.ok) throw new Error('Failed');
|
||||
_loadAnnotations();
|
||||
})
|
||||
.catch(function () {
|
||||
contentDiv.textContent = originalText;
|
||||
});
|
||||
});
|
||||
|
||||
var cancelBtn = document.createElement('button');
|
||||
cancelBtn.type = 'button';
|
||||
cancelBtn.className = 'annotation-cancel-btn';
|
||||
cancelBtn.textContent = _i18n.cancel || 'Cancel';
|
||||
cancelBtn.addEventListener('click', function () {
|
||||
contentDiv.textContent = originalText;
|
||||
});
|
||||
|
||||
btns.appendChild(saveBtn);
|
||||
btns.appendChild(cancelBtn);
|
||||
|
||||
form.appendChild(textarea);
|
||||
form.appendChild(typeSelect);
|
||||
form.appendChild(btns);
|
||||
contentDiv.appendChild(form);
|
||||
textarea.focus();
|
||||
}
|
||||
|
||||
// Expose
|
||||
window.initAnnotations = initAnnotations;
|
||||
})();
|
||||
@@ -1,44 +0,0 @@
|
||||
/**
|
||||
* claim.js — Claim-ownership UI helper for unowned documents.
|
||||
*
|
||||
* Usage: call initClaimOwnership(fileId, i18n) after DOMContentLoaded.
|
||||
* The i18n object must contain:
|
||||
* confirm, success, failed
|
||||
*/
|
||||
function initClaimOwnership(fileId, i18n) {
|
||||
var btn = document.getElementById('claim-btn');
|
||||
var msg = document.getElementById('claim-msg');
|
||||
if (!btn) return;
|
||||
|
||||
btn.addEventListener('click', function () {
|
||||
if (!confirm(i18n.confirm)) return;
|
||||
btn.disabled = true;
|
||||
fetch('/api/files/' + fileId + '/claim', { method: 'POST' })
|
||||
.then(function (r) { return r.json().then(function (d) { return { ok: r.ok, data: d }; }); })
|
||||
.then(function (result) {
|
||||
if (result.ok || (result.data && result.data.status === 'already_owned')) {
|
||||
if (msg) {
|
||||
msg.textContent = i18n.success;
|
||||
msg.style.color = '#059669';
|
||||
msg.style.display = msg.tagName === 'SPAN' ? 'inline' : 'block';
|
||||
}
|
||||
setTimeout(function () { location.reload(); }, 1200);
|
||||
} else {
|
||||
if (msg) {
|
||||
msg.textContent = (result.data && result.data.detail) || i18n.failed;
|
||||
msg.style.color = '#dc2626';
|
||||
msg.style.display = msg.tagName === 'SPAN' ? 'inline' : 'block';
|
||||
}
|
||||
btn.disabled = false;
|
||||
}
|
||||
})
|
||||
.catch(function () {
|
||||
if (msg) {
|
||||
msg.textContent = i18n.failed;
|
||||
msg.style.color = '#dc2626';
|
||||
msg.style.display = msg.tagName === 'SPAN' ? 'inline' : 'block';
|
||||
}
|
||||
btn.disabled = false;
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,462 +0,0 @@
|
||||
// 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;
|
||||
})();
|
||||
@@ -1,224 +0,0 @@
|
||||
/**
|
||||
* sharing.js – File sharing management UI.
|
||||
*
|
||||
* Renders the current shares for a document and lets the file owner
|
||||
* add new shares, change roles, or revoke access.
|
||||
*
|
||||
* Usage:
|
||||
* initSharing(fileId, i18n)
|
||||
*
|
||||
* The i18n object is expected to contain all keys used below.
|
||||
*/
|
||||
|
||||
/* global fetch */
|
||||
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var _fileId = null;
|
||||
var _i18n = {};
|
||||
|
||||
// ── DOM helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
function _el(id) {
|
||||
return document.getElementById(id);
|
||||
}
|
||||
|
||||
function _esc(str) {
|
||||
return String(str)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function _t(key) {
|
||||
return _i18n[key] || key;
|
||||
}
|
||||
|
||||
// ── API helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
function _apiUrl(suffix) {
|
||||
return '/api/files/' + _fileId + suffix;
|
||||
}
|
||||
|
||||
function _fetchShares() {
|
||||
return fetch(_apiUrl('/shares'), { credentials: 'same-origin' })
|
||||
.then(function (r) { return r.json(); });
|
||||
}
|
||||
|
||||
function _addShare(userId, role) {
|
||||
return fetch(_apiUrl('/shares'), {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ shared_with_user_id: userId, role: role }),
|
||||
}).then(function (r) {
|
||||
return r.json().then(function (body) {
|
||||
if (!r.ok) throw new Error((body && body.detail) || r.statusText);
|
||||
return body;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function _updateRole(shareId, role) {
|
||||
return fetch(_apiUrl('/shares/' + shareId), {
|
||||
method: 'PUT',
|
||||
credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ role: role }),
|
||||
}).then(function (r) {
|
||||
return r.json().then(function (body) {
|
||||
if (!r.ok) throw new Error((body && body.detail) || r.statusText);
|
||||
return body;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function _revokeShare(shareId) {
|
||||
return fetch(_apiUrl('/shares/' + shareId), {
|
||||
method: 'DELETE',
|
||||
credentials: 'same-origin',
|
||||
}).then(function (r) {
|
||||
return r.json().then(function (body) {
|
||||
if (!r.ok) throw new Error((body && body.detail) || r.statusText);
|
||||
return body;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ── Render ───────────────────────────────────────────────────────────────
|
||||
|
||||
function _renderShares(shares) {
|
||||
var list = _el('sharing-list');
|
||||
if (!list) return;
|
||||
|
||||
if (!shares || shares.length === 0) {
|
||||
list.innerHTML = '<p style="color:#64748b;font-size:0.875rem;">' + _esc(_t('no_shares')) + '</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
var rows = shares.map(function (s) {
|
||||
var roleLabel = s.role === 'editor' ? _t('role_editor') : _t('role_viewer');
|
||||
return (
|
||||
'<div style="display:flex;align-items:center;justify-content:space-between;gap:0.5rem;padding:0.5rem 0;border-bottom:1px solid #f1f5f9;">' +
|
||||
'<span style="font-size:0.875rem;color:#334155;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1;" ' +
|
||||
'aria-label="' + _esc(_t('user_id_label') + ': ' + (s.display_name || s.user_id)) + '" ' +
|
||||
'title="' + _esc(s.user_id) + '">' +
|
||||
_esc(s.display_name || s.user_id) +
|
||||
'</span>' +
|
||||
'<select' +
|
||||
' data-share-id="' + _esc(s.share_id) + '"' +
|
||||
' class="sharing-role-select"' +
|
||||
' aria-label="' + _esc(_t('change_role')) + '"' +
|
||||
' style="padding:0.25rem 0.5rem;border:1px solid #cbd5e1;border-radius:0.25rem;font-size:0.8rem;background:#fff;"' +
|
||||
'>' +
|
||||
'<option value="viewer"' + (s.role === 'viewer' ? ' selected' : '') + '>' + _esc(_t('role_viewer')) + '</option>' +
|
||||
'<option value="editor"' + (s.role === 'editor' ? ' selected' : '') + '>' + _esc(_t('role_editor')) + '</option>' +
|
||||
'</select>' +
|
||||
'<button' +
|
||||
' data-share-id="' + _esc(s.share_id) + '"' +
|
||||
' class="sharing-revoke-btn"' +
|
||||
' aria-label="' + _esc(_t('revoke')) + '"' +
|
||||
' title="' + _esc(_t('revoke')) + '"' +
|
||||
' style="padding:0.25rem 0.5rem;background:#fee2e2;color:#b91c1c;border:1px solid #fca5a5;border-radius:0.25rem;font-size:0.8rem;cursor:pointer;"' +
|
||||
'>' +
|
||||
'<i class="fas fa-user-minus" aria-hidden="true"></i>' +
|
||||
'</button>' +
|
||||
'</div>'
|
||||
);
|
||||
});
|
||||
|
||||
list.innerHTML = rows.join('');
|
||||
|
||||
// Role change handlers
|
||||
list.querySelectorAll('.sharing-role-select').forEach(function (sel) {
|
||||
sel.addEventListener('change', function () {
|
||||
var shareId = sel.getAttribute('data-share-id');
|
||||
var newRole = sel.value;
|
||||
_updateRole(shareId, newRole)
|
||||
.then(function () { _loadAndRender(); })
|
||||
.catch(function (err) { _showError(err.message); });
|
||||
});
|
||||
});
|
||||
|
||||
// Revoke handlers
|
||||
list.querySelectorAll('.sharing-revoke-btn').forEach(function (btn) {
|
||||
btn.addEventListener('click', function () {
|
||||
if (!window.confirm(_t('revoke_confirm'))) return;
|
||||
var shareId = btn.getAttribute('data-share-id');
|
||||
_revokeShare(shareId)
|
||||
.then(function () { _loadAndRender(); })
|
||||
.catch(function (err) { _showError(err.message); });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function _loadAndRender() {
|
||||
var list = _el('sharing-list');
|
||||
if (!list) return;
|
||||
list.innerHTML = '<p style="color:#64748b;font-size:0.875rem;">' + _esc(_t('loading')) + '</p>';
|
||||
_fetchShares()
|
||||
.then(function (data) {
|
||||
// GET /files/{id}/shares returns an array; /files/{id}/shared-with also returns array
|
||||
var shares = Array.isArray(data) ? data : (data.shares || []);
|
||||
// Normalise keys: shares list uses share_id, but the shares endpoint returns id
|
||||
shares = shares.map(function (s) {
|
||||
return {
|
||||
share_id: s.share_id || s.id,
|
||||
user_id: s.user_id || s.shared_with_user_id,
|
||||
display_name: s.display_name || s.shared_with_user_id || s.user_id,
|
||||
role: s.role,
|
||||
};
|
||||
});
|
||||
_renderShares(shares);
|
||||
})
|
||||
.catch(function (err) {
|
||||
if (list) list.innerHTML = '<p style="color:#ef4444;font-size:0.875rem;">' + _esc(err.message) + '</p>';
|
||||
});
|
||||
}
|
||||
|
||||
function _showError(msg) {
|
||||
var el = _el('sharing-form-error');
|
||||
if (!el) return;
|
||||
el.textContent = msg;
|
||||
el.style.display = 'block';
|
||||
setTimeout(function () { el.style.display = 'none'; }, 5000);
|
||||
}
|
||||
|
||||
// ── Init ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function initSharing(fileId, i18n) {
|
||||
_fileId = fileId;
|
||||
_i18n = i18n || {};
|
||||
|
||||
_loadAndRender();
|
||||
|
||||
var form = _el('sharing-form');
|
||||
if (!form) return;
|
||||
|
||||
form.addEventListener('submit', function (e) {
|
||||
e.preventDefault();
|
||||
var userInput = _el('share-user-input');
|
||||
var roleInput = _el('share-role-input');
|
||||
var userId = userInput ? userInput.value.trim() : '';
|
||||
var role = roleInput ? roleInput.value : 'viewer';
|
||||
|
||||
if (!userId) {
|
||||
_showError(_t('error_empty_user'));
|
||||
return;
|
||||
}
|
||||
|
||||
_addShare(userId, role)
|
||||
.then(function () {
|
||||
if (userInput) userInput.value = '';
|
||||
_loadAndRender();
|
||||
})
|
||||
.catch(function (err) { _showError(err.message); });
|
||||
});
|
||||
}
|
||||
|
||||
// Expose
|
||||
window.initSharing = initSharing;
|
||||
})();
|
||||
@@ -433,17 +433,9 @@ function _uploadSingleFile(file, progressBar, statusEl, onTerminal) {
|
||||
if (xhr.status === 200) {
|
||||
const result = JSON.parse(xhr.responseText);
|
||||
progressBar.style.width = '100%';
|
||||
|
||||
if (result.status === 'duplicate' && result.duplicate_of) {
|
||||
// Exact duplicate – no processing task was created
|
||||
progressBar.className = 'file-progress-bar bg-yellow-400 h-2 rounded-full';
|
||||
statusEl.textContent = `Duplicate – already processed (file #${result.duplicate_of.original_file_id})`;
|
||||
statusEl.className = 'text-xs text-yellow-600 mt-1';
|
||||
} else {
|
||||
progressBar.className = 'file-progress-bar bg-green-500 h-2 rounded-full';
|
||||
statusEl.textContent = `Success: Task ID: ${result.task_id}`;
|
||||
statusEl.className = 'text-xs text-green-600 mt-1';
|
||||
}
|
||||
progressBar.className = 'file-progress-bar bg-green-500 h-2 rounded-full';
|
||||
statusEl.textContent = `Success: Task ID: ${result.task_id}`;
|
||||
statusEl.className = 'text-xs text-green-600 mt-1';
|
||||
_onUploadSuccess();
|
||||
onTerminal();
|
||||
resolve({ rateLimited: false, retryAfterSeconds: 0 });
|
||||
|
||||
+239
-2
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user