Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
'use strict';

/**
* Add Admin Tools page to the Settings nav group.
* Extensible dashboard surface for admin file/system utilities.
*
* @author Mohammad Elwan
*/

const navElements = [
{
name: 'Admin Tools',
groupId: 'Settings',
icon: 'tools',
order: 4,
admin: true,
path: 'admin_tools',
component: 'AdminTools',
},
];

/** @type {import('sequelize-cli').Migration} */
module.exports = {
async up(queryInterface) {
await queryInterface.bulkInsert(
'nav_element',
await Promise.all(
navElements.map(async (element) => {
const groupId = await queryInterface.rawSelect(
'nav_group',
{ where: { name: element.groupId } },
['id']
);

return {
...element,
groupId,
createdAt: new Date(),
updatedAt: new Date(),
};
})
),
{}
);
},

async down(queryInterface) {
await queryInterface.bulkDelete(
'nav_element',
{ name: navElements.map((element) => element.name) },
{}
);
},
};
166 changes: 166 additions & 0 deletions backend/webserver/sockets/document.js
Original file line number Diff line number Diff line change
Expand Up @@ -1613,6 +1613,171 @@ class DocumentSocket extends Socket {
}


/**
* Replace the PDF or ZIP file of an existing document on disk.
*
* Keeps the same document id and hash. Used by admins to correct a wrong
* submission file without creating a new document. For PDFs, existing CARE
* annotations and comments on the document are removed.
*
* @author Mohammad Elwan
* @param {Object} data - The input data from the frontend
* @param {number} data.documentId - The ID of the document to replace
* @param {Buffer} data.file - The binary content of the replacement file
* @param {string} data.name - The original filename (used to check the extension)
* @param {Object} options - Additional configuration parameter
* @param {Object} options.transaction - Sequelize DB transaction options
* @returns {Promise<Object>}
* @throws {Error} - If the user is not an admin or the file type does not match the document
*/
async replaceDocumentFile(data, options) {
if (!(await this.isAdmin())) {
throw new Error("You do not have permission to replace document files.");
}

if (!data || !data.file) {
throw new Error("No file uploaded");
}

if (!data.documentId) {
throw new Error("documentId is required");
}

if (!data.name || typeof data.name !== "string") {
throw new Error("File name is required");
}

const document = await this.validateDocument(data.documentId, "id", false);
const extensionMap = {
[docTypes.DOC_TYPE_PDF]: ".pdf",
[docTypes.DOC_TYPE_ZIP]: ".zip",
};
const expectedExtension = extensionMap[document.type];

if (!expectedExtension) {
throw new Error("Only PDF and ZIP documents can be replaced with this tool.");
}

const fileExtension = data.name.substring(data.name.lastIndexOf(".")).toLowerCase();
if (fileExtension !== expectedExtension) {
throw new Error(
`File type mismatch: document requires ${expectedExtension}, got ${fileExtension || "(none)"}`
);
}

if (document.type === docTypes.DOC_TYPE_PDF) {
await this.clearDocumentAnnotations(document.id, options);
}

const target = path.join(UPLOAD_PATH, `${document.hash}${expectedExtension}`);
await this.writeReplacementFile(document, data.file, target, expectedExtension, options);

return {
documentId: document.id,
hash: document.hash,
message: `Replaced ${expectedExtension} file for document #${document.id}.`,
};
}

/**
* Soft-delete CARE annotations and comments for a document.
* Matches the cascade used when a PDF document is deleted.
*
* @author Mohammad Elwan
* @param {number} documentId - The document whose annotations should be removed
* @param {Object} options - Additional configuration parameter
* @param {Object} options.transaction - Sequelize DB transaction options
* @returns {Promise<void>}
*/
async clearDocumentAnnotations(documentId, options) {
const annotations = await this.models["annotation"].getAllByKey(
"documentId",
documentId,
{transaction: options.transaction}
);
const uniqueAnnotationIds = [...new Set((annotations || []).map((annotation) => annotation.id))];
for (const annotationId of uniqueAnnotationIds) {
await this.models["annotation"].deleteById(annotationId, {transaction: options.transaction});
}

const comments = await this.models["comment"].getAllByKey(
"documentId",
documentId,
{transaction: options.transaction}
);
const uniqueCommentIds = [...new Set((comments || []).map((comment) => comment.id))];
for (const commentId of uniqueCommentIds) {
await this.models["comment"].deleteById(commentId, {transaction: options.transaction});
}
}

/**
* Prepare replacement file bytes (best-effort PDFRPC strip for PDFs).
*
* @author Mohammad Elwan
* @param {Object} document - The document record
* @param {Buffer} fileData - The uploaded file content
* @param {string} expectedExtension - The expected file extension
* @returns {Promise<Buffer>} Bytes to write after the DB transaction commits
*/
async prepareReplacementFileBytes(document, fileData, expectedExtension) {
if (expectedExtension !== ".pdf") {
return fileData;
}

try {
const {file} = await this.server.rpcs["PDFRPC"].deleteAllAnnotations({
file: fileData,
document,
});
if (!file) {
throw new Error("Couldn't delete original annotations");
}
return file;
} catch (annotationRpcErr) {
this.logger.error(
"Error deleting annotations during document file replace: " + annotationRpcErr.message
);
return fileData;
}
}

/**
* Write a replacement temp file, then rename onto the live hash path after commit.
* Temp write happens before commit (failure rolls back annotation deletes). Only the
* rename runs in afterCommit. If rename fails after commit, the socket callback fails
* so the admin is notified (temp file is left for manual recovery).
*
* @author Mohammad Elwan
* @param {Object} document - The document record
* @param {Buffer} fileData - The uploaded file content
* @param {string} target - The filesystem path to overwrite
* @param {string} expectedExtension - The expected file extension
* @param {Object} options - Additional configuration parameter
* @param {Object} options.transaction - Sequelize DB transaction options
* @returns {Promise<void>}
* @throws {Error} - If the temp file cannot be written, or if rename fails after commit
*/
async writeReplacementFile(document, fileData, target, expectedExtension, options) {
const bytes = await this.prepareReplacementFileBytes(document, fileData, expectedExtension);
const tempPath = `${target}.replace-tmp`;
fs.writeFileSync(tempPath, bytes);

options.transaction.afterCommit(() => {
try {
fs.renameSync(tempPath, target);
} catch (err) {
this.logger.error(
`Error promoting replacement file for document #${document.id}: ${err.message}`
);
throw new Error(
`Annotations were updated but the file could not be replaced for document #${document.id}. ` +
`The new file is at ${tempPath}; rename it to ${target} manually. ${err.message}`
);
}
});
}

/**
* Subscribe the client's socket to a document-specific communication channel.
*
Expand Down Expand Up @@ -1675,6 +1840,7 @@ class DocumentSocket extends Socket {
this.createSocket("documentGet", this.getDocument, {}, false);
this.createSocket("documentCreate", this.createDocument, {}, true);
this.createSocket("documentAdd", this.addDocument, {}, true);
this.createSocket("documentReplaceFile", this.replaceDocumentFile, {}, true);
this.createSocket("documentUpdate", this.updateDocument, {}, true);
this.createSocket("documentGetMoodleSubmissions", this.documentGetMoodleSubmissions, {}, false);
this.createSocket("documentDownloadMoodleSubmissions", this.downloadMoodleSubmissions, {}, false);
Expand Down
19 changes: 19 additions & 0 deletions docs/source/for_developers/backend/socket.rst
Original file line number Diff line number Diff line change
Expand Up @@ -445,6 +445,25 @@ In these cases, you can register a manual `afterCommit` hook on the transaction
When using ``autoTable`` models, commit-time change tracking and ``<table>Refresh`` emits are automatic.
Details in :ref:`Store Updates & <table>Refresh> Events <table-refresh-events>`.

.. _document-replace-file:

documentReplaceFile
~~~~~~~~~~~~~~~~~~~

Admin-only socket used by the Admin Tools dashboard to correct a wrong PDF or ZIP on disk
without creating a new document row.

- Event name: ``documentReplaceFile``
- Registered with ``createSocket(..., true)`` (transactional)
- Input: ``documentId``, ``file`` (binary), ``name`` (original filename for extension checks)
- Keeps the same document ``id`` and ``hash``; overwrites ``files/{hash}.pdf`` or ``files/{hash}.zip``
- Only PDF and ZIP documents are supported; the upload extension must match the document type
- For PDFs: soft-deletes existing CARE annotations and comments on that document, and best-effort
strips embedded PDF annotations via PDFRPC (falls back to the raw upload if strip fails)
- Writes ``files/{hash}.{ext}.replace-tmp`` before commit (failure rolls back DB changes); renames
onto the live hash path in ``afterCommit``. If rename fails after commit, the socket returns an
error so the admin is notified and the temp file is left for manual recovery.

.. _document-create-example:

Example Lifecycle
Expand Down
28 changes: 28 additions & 0 deletions docs/source/for_developers/basics/user_stories.rst
Original file line number Diff line number Diff line change
Expand Up @@ -1722,6 +1722,34 @@ Manage Configurations

-----

Replace Document File from Admin Tools
--------------------------------------

.. container:: user-story

:Story:
As an **Admin**, I want to replace the PDF or ZIP file of an existing document from
Admin Tools, so that I can correct a wrong submission file without creating a new
document or breaking existing links that use the same document id and hash.

:Acceptance:
- From Settings → Admin Tools, I can open the replace-document-file tool.
- I can filter and search documents, select one PDF or ZIP document, and upload a
replacement file of the same type.
- After a successful replace, the document keeps the same id and hash; only the file
bytes on disk change.
- When I replace a PDF, existing CARE annotations and comments on that document are
removed.

**Negative cases:**

- When I am not an admin, I cannot use Admin Tools or the replace socket.
- When the uploaded file extension does not match the selected document type, the
replace is rejected and the stored file is unchanged.
- When the selected document is not a PDF or ZIP, the tool does not allow replace.

-----

Export User Statistics
-----------------------

Expand Down
70 changes: 70 additions & 0 deletions frontend/src/components/dashboard/AdminTools.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
<template>
<div>
<Card title="Admin Tools">
<template #body>
<p class="text-muted mb-3">
Administrative utilities for recovering and maintaining system files.
Open a tool below to run a specific admin action.
</p>

<div class="d-flex flex-column gap-2 align-items-start">
<BasicButton
class="btn btn-outline-secondary"
text="Replace document file"
title="Replace an existing PDF or ZIP file on disk"
icon="arrow-repeat"
@click="openReplaceDocumentFileModal"
/>
</div>
</template>
</Card>

<ReplaceDocumentFileModal
v-if="modals.replaceDocumentFile"
ref="replaceDocumentFileModal"
@hide="modals.replaceDocumentFile = false"
/>
</div>
</template>

<script>
import Card from "@/basic/dashboard/card/Card.vue";
import BasicButton from "@/basic/Button.vue";
import ReplaceDocumentFileModal from "@/components/dashboard/admin_tools/ReplaceDocumentFileModal.vue";

/**
* Admin Tools dashboard page.
*
* Extensible list of admin utilities; each tool opens its own modal.
* Tool modals mount only while open so their table subscriptions stay idle.
*
* @author Mohammad Elwan
*/
export default {
name: "AdminTools",
components: {
Card,
BasicButton,
ReplaceDocumentFileModal,
},
data() {
return {
modals: {
replaceDocumentFile: false,
},
};
},
methods: {
/**
* Mount and open the document file replace modal.
*/
openReplaceDocumentFileModal() {
this.modals.replaceDocumentFile = true;
this.$nextTick(() => this.$refs.replaceDocumentFileModal?.open());
},
},
};
</script>

<style scoped>
</style>
Loading
Loading