diff --git a/web-server/.env.example b/web-server/.env.example
index 8748d0a..cbc11ec 100644
--- a/web-server/.env.example
+++ b/web-server/.env.example
@@ -7,6 +7,9 @@ DOCKER_NETWORK=robot-installer-net
WEB_SERVER_UPLOADS_DIR=./uploads
MAX_UPLOAD_BYTES=1073741824
AGENT_MAX_UPLOAD_BYTES=1073741824
+DOCUMENT_MAX_UPLOAD_BYTES=52428800
+DOCUMENT_MAX_CONTENT_BYTES=2097152
+DOCUMENT_MAX_CONTENT_CHARS=500000
SQLSERVER_HOST=172.20.235.176
SQLSERVER_PORT=1433
SQLSERVER_DATABASE=RobotInstaller
diff --git a/web-server/Dockerfile b/web-server/Dockerfile
index 46b988c..8395a98 100644
--- a/web-server/Dockerfile
+++ b/web-server/Dockerfile
@@ -18,7 +18,7 @@ COPY --from=dependencies /app/node_modules ./node_modules
COPY . .
COPY docker-entrypoint.sh ./docker-entrypoint.sh
-RUN mkdir -p uploads/packages/agent \
+RUN mkdir -p uploads/packages/agent uploads/documents \
&& chown -R node:node uploads \
&& chmod +x docker-entrypoint.sh
diff --git a/web-server/database/02_schema.sql b/web-server/database/02_schema.sql
index faa6f76..fa2fcea 100644
--- a/web-server/database/02_schema.sql
+++ b/web-server/database/02_schema.sql
@@ -9,6 +9,7 @@ IF OBJECT_ID(N'dbo.ApplicationPackages', N'U') IS NOT NULL
OR OBJECT_ID(N'dbo.PackageVersions', N'U') IS NOT NULL
OR OBJECT_ID(N'dbo.Applications', N'U') IS NOT NULL
OR OBJECT_ID(N'dbo.Packages', N'U') IS NOT NULL
+ OR OBJECT_ID(N'dbo.Documents', N'U') IS NOT NULL
OR OBJECT_ID(N'dbo.EmailConfirmationTokens', N'U') IS NOT NULL
OR OBJECT_ID(N'dbo.Users', N'U') IS NOT NULL
BEGIN
@@ -54,6 +55,37 @@ CREATE TABLE dbo.EmailConfirmationTokens
);
GO
+CREATE TABLE dbo.Documents
+(
+ Id UNIQUEIDENTIFIER NOT NULL
+ CONSTRAINT PK_Documents PRIMARY KEY CLUSTERED
+ CONSTRAINT DF_Documents_Id DEFAULT NEWSEQUENTIALID(),
+ Title NVARCHAR(200) NOT NULL,
+ Category NVARCHAR(50) NOT NULL
+ CONSTRAINT DF_Documents_Category DEFAULT N'other',
+ Summary NVARCHAR(1000) NULL,
+ Content NVARCHAR(MAX) NULL,
+ FilePath NVARCHAR(1000) NULL,
+ OriginalFileName NVARCHAR(260) NULL,
+ MimeType NVARCHAR(200) NULL,
+ FileSizeBytes BIGINT NULL,
+ CreatedByUserId UNIQUEIDENTIFIER NOT NULL,
+ CreatedAt DATETIME2(3) NOT NULL
+ CONSTRAINT DF_Documents_CreatedAt DEFAULT SYSUTCDATETIME(),
+ UpdatedAt DATETIME2(3) NULL,
+ CONSTRAINT FK_Documents_CreatedByUser
+ FOREIGN KEY (CreatedByUserId) REFERENCES dbo.Users(Id),
+ CONSTRAINT CK_Documents_Title_NotBlank CHECK (LEN(LTRIM(RTRIM(Title))) > 0),
+ CONSTRAINT CK_Documents_Category CHECK (
+ Category IN (N'introduction', N'guide', N'user-guide', N'technical', N'policy', N'other')
+ ),
+ CONSTRAINT CK_Documents_FileSizeBytes CHECK (FileSizeBytes IS NULL OR FileSizeBytes >= 0),
+ CONSTRAINT CK_Documents_HasReadableContent CHECK (
+ NULLIF(LTRIM(RTRIM(Content)), N'') IS NOT NULL OR FilePath IS NOT NULL
+ )
+);
+GO
+
CREATE TABLE dbo.Packages
(
Id UNIQUEIDENTIFIER NOT NULL
@@ -164,6 +196,13 @@ CREATE INDEX IX_EmailConfirmationTokens_UserId
ON dbo.EmailConfirmationTokens(UserId);
GO
+CREATE INDEX IX_Documents_Category_UpdatedAt
+ON dbo.Documents(Category, UpdatedAt DESC, CreatedAt DESC);
+
+CREATE INDEX IX_Documents_CreatedByUserId
+ON dbo.Documents(CreatedByUserId);
+GO
+
CREATE UNIQUE INDEX UX_Packages_PackageCode ON dbo.Packages(PackageCode);
CREATE INDEX IX_Packages_CreatedByUserId ON dbo.Packages(CreatedByUserId);
CREATE INDEX IX_Packages_PackageType ON dbo.Packages(PackageType);
diff --git a/web-server/database/05_documents.sql b/web-server/database/05_documents.sql
new file mode 100644
index 0000000..b45b9ca
--- /dev/null
+++ b/web-server/database/05_documents.sql
@@ -0,0 +1,67 @@
+USE [RobotInstaller];
+GO
+
+SET ANSI_NULLS ON;
+SET QUOTED_IDENTIFIER ON;
+GO
+
+IF OBJECT_ID(N'dbo.Documents', N'U') IS NULL
+BEGIN
+ CREATE TABLE dbo.Documents
+ (
+ Id UNIQUEIDENTIFIER NOT NULL
+ CONSTRAINT PK_Documents PRIMARY KEY CLUSTERED
+ CONSTRAINT DF_Documents_Id DEFAULT NEWSEQUENTIALID(),
+ Title NVARCHAR(200) NOT NULL,
+ Category NVARCHAR(50) NOT NULL
+ CONSTRAINT DF_Documents_Category DEFAULT N'other',
+ Summary NVARCHAR(1000) NULL,
+ Content NVARCHAR(MAX) NULL,
+ FilePath NVARCHAR(1000) NULL,
+ OriginalFileName NVARCHAR(260) NULL,
+ MimeType NVARCHAR(200) NULL,
+ FileSizeBytes BIGINT NULL,
+ CreatedByUserId UNIQUEIDENTIFIER NOT NULL,
+ CreatedAt DATETIME2(3) NOT NULL
+ CONSTRAINT DF_Documents_CreatedAt DEFAULT SYSUTCDATETIME(),
+ UpdatedAt DATETIME2(3) NULL,
+ CONSTRAINT FK_Documents_CreatedByUser
+ FOREIGN KEY (CreatedByUserId) REFERENCES dbo.Users(Id),
+ CONSTRAINT CK_Documents_Title_NotBlank CHECK (LEN(LTRIM(RTRIM(Title))) > 0),
+ CONSTRAINT CK_Documents_Category CHECK (
+ Category IN (N'introduction', N'guide', N'user-guide', N'technical', N'policy', N'other')
+ ),
+ CONSTRAINT CK_Documents_FileSizeBytes CHECK (FileSizeBytes IS NULL OR FileSizeBytes >= 0),
+ CONSTRAINT CK_Documents_HasReadableContent CHECK (
+ NULLIF(LTRIM(RTRIM(Content)), N'') IS NOT NULL OR FilePath IS NOT NULL
+ )
+ );
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT 1
+ FROM sys.indexes
+ WHERE name = N'IX_Documents_Category_UpdatedAt'
+ AND object_id = OBJECT_ID(N'dbo.Documents')
+)
+BEGIN
+ CREATE INDEX IX_Documents_Category_UpdatedAt
+ ON dbo.Documents(Category, UpdatedAt DESC, CreatedAt DESC);
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT 1
+ FROM sys.indexes
+ WHERE name = N'IX_Documents_CreatedByUserId'
+ AND object_id = OBJECT_ID(N'dbo.Documents')
+)
+BEGIN
+ CREATE INDEX IX_Documents_CreatedByUserId
+ ON dbo.Documents(CreatedByUserId);
+END;
+GO
+
+PRINT N'RobotInstaller documents schema is ready.';
+GO
diff --git a/web-server/database/README.md b/web-server/database/README.md
index 4b6e2e4..0cf2788 100644
--- a/web-server/database/README.md
+++ b/web-server/database/README.md
@@ -28,6 +28,7 @@ Không lưu mật khẩu thật vào file cấu hình. Khi chạy local, tạo f
| `dbo.PackageVersions` | Các version của từng package |
| `dbo.Applications` | App được đóng gói từ nhiều package |
| `dbo.ApplicationPackages` | Liên kết app-package, có thể chọn version cụ thể |
+| `dbo.Documents` | Nội dung tài liệu và metadata file đính kèm |
| `dbo.Notifications` | Thông báo riêng cho từng user và thông báo hệ thống do Admin đăng |
## Ràng buộc quan trọng
@@ -65,13 +66,14 @@ sqlcmd -S 172.20.235.176 -U sa -b -i .\database\01_create_database.sql
sqlcmd -S 172.20.235.176 -U sa -d RobotInstaller -b -i .\database\02_schema.sql
sqlcmd -S 172.20.235.176 -U sa -d RobotInstaller -b -i .\database\03_views.sql
sqlcmd -S 172.20.235.176 -U sa -d RobotInstaller -b -i .\database\04_notifications.sql
+sqlcmd -S 172.20.235.176 -U sa -d RobotInstaller -b -i .\database\05_documents.sql
```
Chạy các lệnh trên từ thư mục `web-server`.
Khi dùng `sqlcmd` để seed/test dữ liệu, thêm `-I` hoặc bật `SET QUOTED_IDENTIFIER ON` vì schema có filtered index cho ràng buộc một latest version trên mỗi package.
-`04_notifications.sql` là migration chỉ bổ sung bảng/index và có thể chạy lặp lại. Với database đang hoạt động, chỉ chạy file này; không chạy lại `02_schema.sql` vì script schema gốc chủ động dừng khi phát hiện bảng đã tồn tại.
+`04_notifications.sql` và `05_documents.sql` là các migration chỉ bổ sung bảng/index và có thể chạy lặp lại. Với database đang hoạt động, chỉ chạy các migration còn thiếu; không chạy lại `02_schema.sql` vì script schema gốc chủ động dừng khi phát hiện bảng đã tồn tại. Web server cũng tự bảo đảm bảng `Documents` tồn tại khi chức năng tài liệu được truy cập.
## Luồng dữ liệu đề xuất
diff --git a/web-server/docker-entrypoint.sh b/web-server/docker-entrypoint.sh
index d75e250..d2ff68e 100644
--- a/web-server/docker-entrypoint.sh
+++ b/web-server/docker-entrypoint.sh
@@ -1,7 +1,7 @@
#!/bin/sh
set -e
-mkdir -p /app/uploads/packages/agent
+mkdir -p /app/uploads/packages/agent /app/uploads/documents
chown -R node:node /app/uploads
exec su-exec node "$@"
diff --git a/web-server/public/css/styles.css b/web-server/public/css/styles.css
index 2291e26..b9a1697 100644
--- a/web-server/public/css/styles.css
+++ b/web-server/public/css/styles.css
@@ -1724,12 +1724,155 @@ tbody tr:hover td.action-col {
min-width: 980px;
}
+.documents-table {
+ min-width: 980px;
+}
+
+.document-title-cell {
+ max-width: 360px;
+ min-width: 240px;
+}
+
+.document-title-cell .table-subtitle {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.document-file-name {
+ color: var(--text-primary);
+ display: block;
+ font-size: 12px;
+ font-weight: 600;
+ max-width: 220px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.document-detail-grid {
+ display: grid;
+ flex: 1;
+ gap: 16px;
+ grid-template-columns: 320px minmax(0, 1fr);
+ min-height: 0;
+}
+
+.document-meta-panel,
+.document-reader-panel {
+ min-width: 0;
+}
+
+.document-reader {
+ display: flex;
+ flex: 1;
+ flex-direction: column;
+ gap: 20px;
+ min-height: 0;
+ overflow: auto;
+ padding: 20px;
+}
+
+.document-copy {
+ color: var(--text-primary);
+ font-family: "Montserrat Variable", Montserrat, "Segoe UI", sans-serif;
+ font-size: 14px;
+ line-height: 1.75;
+ overflow-wrap: anywhere;
+ white-space: pre-wrap;
+}
+
+.document-preview-block {
+ border-top: 1px solid var(--border-subtle);
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+ padding-top: 16px;
+}
+
+.document-section-heading {
+ align-items: center;
+ color: var(--text-secondary);
+ display: flex;
+ font-size: 12px;
+ gap: 6px;
+}
+
+.document-section-heading .material-symbols-outlined {
+ color: var(--brand-primary);
+ font-size: 18px;
+}
+
+.document-file-preview {
+ background: var(--neutral-50);
+ border: 1px solid var(--border-subtle);
+ border-radius: var(--radius-md);
+ min-height: 540px;
+ width: 100%;
+}
+
+.document-image-preview {
+ align-self: center;
+ border: 1px solid var(--border-subtle);
+ border-radius: var(--radius-md);
+ height: auto;
+ max-height: 70vh;
+ max-width: 100%;
+ object-fit: contain;
+}
+
+.document-attachment-state {
+ align-items: center;
+ background: var(--surface-raised);
+ border: 1px solid var(--border-subtle);
+ border-radius: var(--radius-md);
+ display: grid;
+ gap: 14px;
+ grid-template-columns: auto minmax(0, 1fr) auto;
+ padding: 16px;
+}
+
+.document-attachment-state > .material-symbols-outlined {
+ color: var(--brand-primary);
+ font-size: 34px;
+}
+
+.document-attachment-state strong,
+.document-attachment-state p {
+ display: block;
+ overflow-wrap: anywhere;
+}
+
+.document-attachment-state p {
+ color: var(--text-secondary);
+ font-size: 12px;
+ margin-top: 3px;
+}
+
+.document-remove-option {
+ align-items: flex-start;
+ background: var(--semantic-warning-bg);
+ border: 1px solid #f3d28c;
+ border-radius: var(--radius-md);
+ color: var(--semantic-warning);
+ display: flex;
+ font-size: 12px;
+ gap: 8px;
+ padding: 10px 12px;
+}
+
+.document-remove-option input {
+ flex: 0 0 auto;
+ margin-top: 3px;
+}
+
@media (max-width: 1100px) {
.dashboard-grid,
.detail-grid,
.builder-layout,
.agent-layout,
- .users-layout {
+ .users-layout,
+ .document-detail-grid {
grid-template-columns: 1fr;
}
@@ -1740,9 +1883,14 @@ tbody tr:hover td.action-col {
.detail-grid,
.builder-layout,
.agent-layout,
- .users-layout {
+ .users-layout,
+ .document-detail-grid {
overflow: auto;
}
+
+ .document-reader-panel {
+ min-height: 620px;
+ }
}
@media (max-width: 900px) {
@@ -1833,6 +1981,16 @@ tbody tr:hover td.action-col {
gap: 8px;
}
+ .document-attachment-state {
+ align-items: flex-start;
+ grid-template-columns: auto minmax(0, 1fr);
+ }
+
+ .document-attachment-state .btn {
+ grid-column: 1 / -1;
+ width: 100%;
+ }
+
.modal-backdrop {
align-items: flex-end;
}
diff --git a/web-server/public/js/app.js b/web-server/public/js/app.js
index b2134f4..60cea42 100644
--- a/web-server/public/js/app.js
+++ b/web-server/public/js/app.js
@@ -15,6 +15,7 @@
database_off: 'database_warning_20_regular.svg',
delete: 'delete_20_regular.svg',
deployed_code: 'cube_24_regular.svg',
+ description: 'document_24_regular.svg',
download: 'arrow_download_20_regular.svg',
draft: 'document_24_regular.svg',
edit: 'edit_20_regular.svg',
@@ -22,6 +23,7 @@
forward_to_inbox: 'mail_arrow_forward_20_regular.svg',
group: 'people_24_regular.svg',
inventory_2: 'box_24_regular.svg',
+ library_books: 'library_24_regular.svg',
link_off: 'link_dismiss_20_regular.svg',
login: 'arrow_enter_20_regular.svg',
logout: 'sign_out_20_regular.svg',
@@ -30,6 +32,7 @@
menu: 'navigation_20_regular.svg',
notifications: 'alert_20_regular.svg',
notifications_none: 'alert_off_20_regular.svg',
+ note_add: 'document_add_24_regular.svg',
person_add: 'person_add_20_regular.svg',
precision_manufacturing: 'bot_24_filled.svg',
save: 'save_20_regular.svg',
@@ -451,7 +454,8 @@
createdAt: row.dataset.userCreatedAt || '',
updatedAt: row.dataset.userUpdatedAt || '',
packageCount: row.dataset.userPackageCount || '0',
- applicationCount: row.dataset.userApplicationCount || '0'
+ applicationCount: row.dataset.userApplicationCount || '0',
+ documentCount: row.dataset.userDocumentCount || '0'
};
}
@@ -561,7 +565,10 @@
setText('[data-user-detail="status"]', user.status);
setText('[data-user-detail="createdAt"]', user.createdAt);
setText('[data-user-detail="updatedAt"]', user.updatedAt || 'Chưa cập nhật');
- setText('[data-user-detail="ownedData"]', `${user.packageCount} packages, ${user.applicationCount} apps`);
+ setText(
+ '[data-user-detail="ownedData"]',
+ `${user.packageCount} packages, ${user.applicationCount} apps, ${user.documentCount} tài liệu`
+ );
openModal('userDetailModal');
}
diff --git a/web-server/server.js b/web-server/server.js
index fb590e8..b9f2aaf 100644
--- a/web-server/server.js
+++ b/web-server/server.js
@@ -8,6 +8,7 @@ const path = require('path');
const express = require('express');
const multer = require('multer');
const repository = require('./src/repository');
+const { normalizeDocumentFileName } = require('./src/document-file-name');
const notificationRepository = require('./src/notification-repository');
const mailer = require('./src/mailer');
const { closePool, getPool } = require('./src/db');
@@ -16,6 +17,7 @@ const notiflixVersion = require('notiflix/package.json').version;
const app = express();
const port = Number(process.env.PORT || 3000);
const uploadDir = path.join(__dirname, 'uploads', 'packages');
+const documentUploadDir = path.join(__dirname, 'uploads', 'documents');
const agentPackageDir = path.resolve(process.env.AGENT_PACKAGE_DIR || path.join(uploadDir, 'agent'));
const agentDebianPackageName = 'local-installer-agent';
const authCookieName = 'robot_installer_session';
@@ -31,6 +33,23 @@ const installerIdentifierPattern = /^[a-zA-Z0-9._+-]+$/;
const installerVersionPattern = /^[a-zA-Z0-9._:+~=-]+$/;
const installerIdentifierHint = 'Code chi duoc dung chu, so, dau ., _, +, - va khong co khoang trang.';
const installerVersionHint = 'Version chi duoc dung chu, so va cac ky tu . _ : + ~ = -.';
+const documentCategories = Object.freeze([
+ { id: 'introduction', label: 'Giới thiệu' },
+ { id: 'guide', label: 'Hướng dẫn' },
+ { id: 'user-guide', label: 'Hướng dẫn sử dụng' },
+ { id: 'technical', label: 'Tài liệu kỹ thuật' },
+ { id: 'policy', label: 'Quy trình / chính sách' },
+ { id: 'other', label: 'Khác' }
+]);
+const documentCategoryIds = new Set(documentCategories.map((category) => category.id));
+const allowedDocumentExtensions = new Set([
+ '.pdf', '.doc', '.docx', '.odt', '.rtf', '.txt', '.md',
+ '.png', '.jpg', '.jpeg', '.webp',
+ '.ppt', '.pptx', '.xls', '.xlsx'
+]);
+const documentTextExtensions = new Set(['.txt', '.md']);
+const documentImageExtensions = new Set(['.png', '.jpg', '.jpeg', '.webp']);
+const documentMaxContentChars = Number(process.env.DOCUMENT_MAX_CONTENT_CHARS || 500000);
const agentVersionCollator = new Intl.Collator('en', {
numeric: true,
sensitivity: 'base'
@@ -51,12 +70,14 @@ app.get('/readyz', asyncRoute(async (req, res) => {
}));
fs.mkdirSync(uploadDir, { recursive: true });
+fs.mkdirSync(documentUploadDir, { recursive: true });
fs.mkdirSync(agentPackageDir, { recursive: true });
const navItems = [
{ id: 'dashboard', label: 'Tổng quan', href: '/', icon: 'dashboard' },
{ id: 'packages', label: 'Packages', href: '/packages', icon: 'inventory_2' },
{ id: 'applications', label: 'Applications', href: '/applications', icon: 'apps' },
+ { id: 'documents', label: 'Tài liệu', href: '/documents', icon: 'library_books' },
{ id: 'builder', label: 'Đóng gói App', href: '/builder', icon: 'deployed_code' },
{ id: 'agent', label: 'Agent', href: '/agent', icon: 'memory', adminOnly: true },
{ id: 'users', label: 'Users', href: '/users', icon: 'group', adminOnly: true }
@@ -123,6 +144,24 @@ const upload = multer({
}
});
+const documentStorage = multer.diskStorage({
+ destination: documentUploadDir,
+ filename(req, file, callback) {
+ const normalizedName = normalizeDocumentFileName(file.originalname)
+ .normalize('NFD')
+ .replace(/[\u0300-\u036f]/g, '')
+ .replace(/[^a-zA-Z0-9._-]/g, '-')
+ .replace(/-+/g, '-')
+ .toLowerCase();
+ const extension = path.extname(normalizedName).slice(0, 20);
+ const baseName = normalizedName.slice(0, normalizedName.length - extension.length).slice(0, 180) || 'document';
+ const safeName = `${baseName}${extension}`;
+ const suffix = `${Date.now()}-${crypto.randomBytes(4).toString('hex')}`;
+
+ callback(null, `${suffix}-${safeName}`);
+ }
+});
+
const agentUpload = multer({
storage: agentStorage,
limits: {
@@ -131,6 +170,15 @@ const agentUpload = multer({
}
});
+const documentUpload = multer({
+ storage: documentStorage,
+ limits: {
+ ...multipartLimits,
+ fieldSize: Number(process.env.DOCUMENT_MAX_CONTENT_BYTES || 2 * 1024 * 1024),
+ fileSize: Number(process.env.DOCUMENT_MAX_UPLOAD_BYTES || 50 * 1024 * 1024)
+ }
+});
+
app.set('view engine', 'ejs');
app.set('views', path.join(__dirname, 'views'));
@@ -227,6 +275,9 @@ function helpers() {
if (type === 'docker') return 'badge-info';
if (type === 'apt') return 'badge-warning';
return 'badge-primary';
+ },
+ documentCategoryLabel(categoryId) {
+ return documentCategories.find((category) => category.id === categoryId)?.label || 'Khác';
}
};
}
@@ -1037,6 +1088,128 @@ async function getArtifactFromUpload(file) {
};
}
+function normalizeDocumentCategory(value) {
+ const category = String(value || '').trim().toLowerCase();
+ return documentCategoryIds.has(category) ? category : '';
+}
+
+function getDocumentUploadValidationMessage(file) {
+ if (!file) return '';
+
+ const originalFileName = normalizeDocumentFileName(file.originalname);
+
+ if (originalFileName.length > 260) {
+ return 'Tên file tài liệu không được vượt quá 260 ký tự.';
+ }
+
+ const extension = path.extname(originalFileName).toLowerCase();
+ if (!allowedDocumentExtensions.has(extension)) {
+ return 'Định dạng tài liệu chưa được hỗ trợ. Hãy dùng PDF, Word, OpenDocument, text/Markdown, ảnh, PowerPoint hoặc Excel.';
+ }
+
+ return '';
+}
+
+function getDocumentArtifact(file) {
+ if (!file) {
+ return {
+ filePath: null,
+ originalFileName: null,
+ mimeType: null,
+ fileSizeBytes: null
+ };
+ }
+
+ return {
+ filePath: `/uploads/documents/${file.filename}`,
+ originalFileName: normalizeDocumentFileName(file.originalname),
+ mimeType: file.mimetype || 'application/octet-stream',
+ fileSizeBytes: file.size
+ };
+}
+
+function getLocalDocumentFilePath(filePath) {
+ const storedPath = String(filePath || '').trim();
+ const prefix = '/uploads/documents/';
+
+ if (!storedPath.startsWith(prefix)) return null;
+
+ let relativePath;
+ try {
+ relativePath = decodeURIComponent(storedPath.slice(prefix.length));
+ } catch {
+ return null;
+ }
+
+ if (!relativePath || relativePath.includes('\0')) return null;
+
+ const documentRoot = path.resolve(documentUploadDir);
+ const localPath = path.resolve(documentRoot, relativePath);
+ const pathDelta = path.relative(documentRoot, localPath);
+
+ if (!pathDelta || pathDelta.startsWith('..') || path.isAbsolute(pathDelta)) {
+ return null;
+ }
+
+ return localPath;
+}
+
+async function removeStoredDocumentFile(filePath) {
+ const localPath = getLocalDocumentFilePath(filePath);
+ if (!localPath) return;
+
+ try {
+ await fsp.unlink(localPath);
+ } catch (error) {
+ if (error.code !== 'ENOENT') {
+ console.warn(`Cannot remove document file ${localPath}:`, error.message);
+ }
+ }
+}
+
+function getDocumentPreviewKind(document) {
+ if (!document?.filePath) return '';
+
+ const extension = path.extname(document.originalFileName || document.filePath).toLowerCase();
+ if (extension === '.pdf') return 'pdf';
+ if (documentImageExtensions.has(extension)) return 'image';
+ if (documentTextExtensions.has(extension)) return 'text';
+ return '';
+}
+
+function getDocumentResponseMimeType(document) {
+ const extension = path.extname(document.originalFileName || document.filePath).toLowerCase();
+ const knownTypes = {
+ '.pdf': 'application/pdf',
+ '.txt': 'text/plain; charset=utf-8',
+ '.md': 'text/plain; charset=utf-8',
+ '.png': 'image/png',
+ '.jpg': 'image/jpeg',
+ '.jpeg': 'image/jpeg',
+ '.webp': 'image/webp'
+ };
+
+ return knownTypes[extension] || document.mimeType || 'application/octet-stream';
+}
+
+function setDocumentContentDisposition(res, disposition, fileName) {
+ const originalName = String(fileName || 'document')
+ .replace(/[\r\n]/g, '')
+ .slice(0, 260);
+ const asciiName = originalName
+ .normalize('NFD')
+ .replace(/[\u0300-\u036f]/g, '')
+ .replace(/[^\x20-\x7E]/g, '_')
+ .replace(/["\\;]/g, '_') || 'document';
+ const encodedName = encodeURIComponent(originalName)
+ .replace(/['()]/g, (character) => `%${character.charCodeAt(0).toString(16).toUpperCase()}`);
+
+ res.setHeader(
+ 'Content-Disposition',
+ `${disposition}; filename="${asciiName}"; filename*=UTF-8''${encodedName}`
+ );
+}
+
async function getDebUploadMetadataValidationMessage(file, packageCode, version) {
if (!file || path.extname(file.originalname).toLowerCase() !== '.deb') return null;
@@ -1568,7 +1741,7 @@ exit 1
});
app.use(requireAuthenticated);
-app.use('/uploads', express.static(path.join(__dirname, 'uploads')));
+app.use('/uploads/packages', express.static(uploadDir));
app.get('/api/notifications', asyncRoute(async (req, res) => {
const countOnly = String(req.query.countOnly || '').toLowerCase() === 'true';
@@ -2309,6 +2482,228 @@ app.get('/applications/:id', asyncRoute(async (req, res) => {
);
}));
+app.get('/documents', asyncRoute(async (req, res) => {
+ const [pageData, documents] = await Promise.all([
+ repository.getPageData(req.currentUser),
+ repository.listDocuments()
+ ]);
+
+ res.render(
+ 'documents',
+ viewModel(req, 'documents', 'Tài liệu', pageData, { documents, documentCategories })
+ );
+}));
+
+app.post('/documents', documentUpload.single('documentFile'), asyncRoute(async (req, res) => {
+ try {
+ const title = String(req.body.title || '').trim();
+ const category = normalizeDocumentCategory(req.body.category);
+ const summary = String(req.body.summary || '').trim();
+ const content = String(req.body.content || '').trim();
+ const fileValidationMessage = getDocumentUploadValidationMessage(req.file);
+
+ if (!title || title.length > 200) {
+ await removeUploadedFile(req.file);
+ redirectWithNotice(res, '/documents', 'warning', 'Tiêu đề tài liệu là bắt buộc và không được vượt quá 200 ký tự.');
+ return;
+ }
+
+ if (!category) {
+ await removeUploadedFile(req.file);
+ redirectWithNotice(res, '/documents', 'warning', 'Vui lòng chọn nhóm tài liệu hợp lệ.');
+ return;
+ }
+
+ if (summary.length > 1000 || content.length > documentMaxContentChars) {
+ await removeUploadedFile(req.file);
+ redirectWithNotice(res, '/documents', 'warning', 'Mô tả hoặc nội dung tài liệu vượt quá độ dài cho phép.');
+ return;
+ }
+
+ if (fileValidationMessage) {
+ await removeUploadedFile(req.file);
+ redirectWithNotice(res, '/documents', 'warning', fileValidationMessage);
+ return;
+ }
+
+ if (!content && !req.file) {
+ redirectWithNotice(res, '/documents', 'warning', 'Hãy nhập nội dung hoặc đính kèm ít nhất một file tài liệu.');
+ return;
+ }
+
+ const artifact = getDocumentArtifact(req.file);
+ await repository.createDocument({
+ title,
+ category,
+ summary,
+ content,
+ ...artifact,
+ createdByUserId: req.currentUser.id
+ });
+
+ redirectWithNotice(res, '/documents', 'success', 'Đã lưu tài liệu mới.');
+ } catch (error) {
+ await removeUploadedFile(req.file);
+ throw error;
+ }
+}));
+
+app.get('/documents/:id/file', asyncRoute(async (req, res) => {
+ const document = await repository.getDocumentById(req.params.id);
+ const localPath = document ? getLocalDocumentFilePath(document.filePath) : null;
+
+ if (!document || !localPath) {
+ res.status(404).type('text/plain').send('Không tìm thấy file tài liệu.');
+ return;
+ }
+
+ try {
+ await fsp.access(localPath, fs.constants.R_OK);
+ } catch (error) {
+ if (error.code === 'ENOENT') {
+ res.status(404).type('text/plain').send('File tài liệu không còn tồn tại trên máy chủ.');
+ return;
+ }
+ throw error;
+ }
+
+ const previewKind = getDocumentPreviewKind(document);
+ const shouldDownload = req.query.download === '1' || !previewKind;
+
+ res.setHeader('X-Content-Type-Options', 'nosniff');
+ res.setHeader('Content-Type', getDocumentResponseMimeType(document));
+ setDocumentContentDisposition(
+ res,
+ shouldDownload ? 'attachment' : 'inline',
+ document.originalFileName || path.basename(localPath)
+ );
+ res.sendFile(localPath);
+}));
+
+app.post('/documents/:id/edit', documentUpload.single('documentFile'), asyncRoute(async (req, res) => {
+ const documentId = String(req.params.id || '').trim();
+
+ try {
+ const existingDocument = await repository.getDocumentById(documentId);
+ if (!existingDocument) {
+ await removeUploadedFile(req.file);
+ redirectWithNotice(res, '/documents', 'warning', 'Không tìm thấy tài liệu cần cập nhật.');
+ return;
+ }
+
+ const title = String(req.body.title || '').trim();
+ const category = normalizeDocumentCategory(req.body.category);
+ const summary = String(req.body.summary || '').trim();
+ const content = String(req.body.content || '').trim();
+ const removeAttachment = req.body.removeAttachment === '1';
+ const fileValidationMessage = getDocumentUploadValidationMessage(req.file);
+ const willHaveAttachment = Boolean(req.file) || (Boolean(existingDocument.filePath) && !removeAttachment);
+
+ if (!title || title.length > 200) {
+ await removeUploadedFile(req.file);
+ redirectWithNotice(res, `/documents/${documentId}`, 'warning', 'Tiêu đề tài liệu là bắt buộc và không được vượt quá 200 ký tự.');
+ return;
+ }
+
+ if (!category) {
+ await removeUploadedFile(req.file);
+ redirectWithNotice(res, `/documents/${documentId}`, 'warning', 'Vui lòng chọn nhóm tài liệu hợp lệ.');
+ return;
+ }
+
+ if (summary.length > 1000 || content.length > documentMaxContentChars) {
+ await removeUploadedFile(req.file);
+ redirectWithNotice(res, `/documents/${documentId}`, 'warning', 'Mô tả hoặc nội dung tài liệu vượt quá độ dài cho phép.');
+ return;
+ }
+
+ if (fileValidationMessage) {
+ await removeUploadedFile(req.file);
+ redirectWithNotice(res, `/documents/${documentId}`, 'warning', fileValidationMessage);
+ return;
+ }
+
+ if (!content && !willHaveAttachment) {
+ await removeUploadedFile(req.file);
+ redirectWithNotice(res, `/documents/${documentId}`, 'warning', 'Tài liệu phải có nội dung hoặc file đính kèm.');
+ return;
+ }
+
+ const artifact = getDocumentArtifact(req.file);
+ const replaceAttachment = Boolean(req.file) || removeAttachment;
+ const result = await repository.updateDocument({
+ documentId,
+ title,
+ category,
+ summary,
+ content,
+ replaceAttachment,
+ ...artifact
+ });
+
+ if (!result) {
+ await removeUploadedFile(req.file);
+ redirectWithNotice(res, '/documents', 'warning', 'Không tìm thấy tài liệu cần cập nhật.');
+ return;
+ }
+
+ if (
+ result.attachmentChanged
+ && result.previousFilePath
+ && result.previousFilePath !== result.currentFilePath
+ ) {
+ await removeStoredDocumentFile(result.previousFilePath);
+ }
+
+ redirectWithNotice(res, `/documents/${documentId}`, 'success', 'Đã cập nhật tài liệu.');
+ } catch (error) {
+ await removeUploadedFile(req.file);
+ throw error;
+ }
+}));
+
+app.post('/documents/:id/delete', asyncRoute(async (req, res) => {
+ const documentId = String(req.params.id || '').trim();
+
+ if (!isUuid(documentId)) {
+ redirectWithNotice(res, '/documents', 'warning', 'Không tìm thấy tài liệu cần xóa.');
+ return;
+ }
+
+ const deletedDocument = await repository.deleteDocument(documentId);
+ if (deletedDocument?.filePath) {
+ await removeStoredDocumentFile(deletedDocument.filePath);
+ }
+
+ redirectWithNotice(
+ res,
+ '/documents',
+ deletedDocument ? 'success' : 'warning',
+ deletedDocument ? 'Đã xóa tài liệu và file đính kèm.' : 'Không tìm thấy tài liệu cần xóa.'
+ );
+}));
+
+app.get('/documents/:id', asyncRoute(async (req, res) => {
+ const [pageData, document] = await Promise.all([
+ repository.getPageData(req.currentUser),
+ repository.getDocumentById(req.params.id)
+ ]);
+
+ if (!document) {
+ res.status(404).render('not-found', viewModel(req, 'documents', 'Không tìm thấy', pageData));
+ return;
+ }
+
+ res.render(
+ 'document-detail',
+ viewModel(req, 'documents', document.title, pageData, {
+ document,
+ documentCategories,
+ documentPreviewKind: getDocumentPreviewKind(document)
+ })
+ );
+}));
+
app.get('/users', requireAdmin, asyncRoute(async (req, res) => {
const [pageData, users] = await Promise.all([
repository.getPageData(req.currentUser),
@@ -2447,7 +2842,7 @@ app.post('/users/:id/delete', requireAdmin, asyncRoute(async (req, res) => {
);
} catch (error) {
if (error.code === 'USER_HAS_OWNED_DATA') {
- redirectWithNotice(res, '/users', 'warning', 'Không thể xóa user đang sở hữu package hoặc application. Hãy khóa tài khoản nếu cần.');
+ redirectWithNotice(res, '/users', 'warning', 'Không thể xóa user đang sở hữu package, application hoặc tài liệu. Hãy khóa tài khoản nếu cần.');
return;
}
@@ -2472,7 +2867,9 @@ app.use(async (error, req, res, next) => {
LIMIT_PART_COUNT: 'Biểu mẫu upload có quá nhiều thành phần.',
LIMIT_UNEXPECTED_FILE: 'Trường file upload không hợp lệ.'
};
- const returnPath = req.path.startsWith('/agent/') ? '/agent' : '/packages';
+ const returnPath = req.path.startsWith('/agent/')
+ ? '/agent'
+ : (req.path.startsWith('/documents') ? '/documents' : '/packages');
console.warn(`Rejected multipart upload (${error.code || 'UNKNOWN'}):`, error.message);
redirectWithNotice(
diff --git a/web-server/src/document-file-name.js b/web-server/src/document-file-name.js
new file mode 100644
index 0000000..7bd7082
--- /dev/null
+++ b/web-server/src/document-file-name.js
@@ -0,0 +1,23 @@
+function normalizeDocumentFileName(value) {
+ const fileName = String(value || '');
+ if (!fileName || !/[\u00C2-\u00C5\u00E1\u00E2]/.test(fileName)) return fileName;
+
+ const codePoints = Array.from(fileName, (character) => character.codePointAt(0));
+ if (codePoints.some((codePoint) => codePoint > 0xff)) return fileName;
+
+ const legacyBytes = Buffer.from(codePoints);
+ const decodedName = legacyBytes.toString('utf8');
+
+ if (
+ decodedName.includes('\uFFFD')
+ || !Buffer.from(decodedName, 'utf8').equals(legacyBytes)
+ ) {
+ return fileName;
+ }
+
+ return decodedName;
+}
+
+module.exports = {
+ normalizeDocumentFileName
+};
diff --git a/web-server/src/repository.js b/web-server/src/repository.js
index 7423168..27c2b4a 100644
--- a/web-server/src/repository.js
+++ b/web-server/src/repository.js
@@ -1,5 +1,6 @@
const crypto = require('crypto');
const { sql, getPool } = require('./db');
+const { normalizeDocumentFileName } = require('./document-file-name');
const PASSWORD_HASH_PREFIX = 'pbkdf2';
const PASSWORD_HASH_ITERATIONS = 120000;
@@ -11,6 +12,7 @@ const EMAIL_CONFIRMATION_EXPIRES_MS = Number(process.env.EMAIL_CONFIRMATION_EXPI
let emailConfirmationSchemaPromise;
let applicationOpenUrlSchemaPromise;
let packageTypeSchemaPromise;
+let documentSchemaPromise;
function padDatePart(value) {
return String(value).padStart(2, '0');
@@ -167,7 +169,8 @@ function mapUserRow(row) {
createdAt: formatDate(row.CreatedAt),
updatedAt: formatDate(row.UpdatedAt),
packageCount: Number(row.PackageCount || 0),
- applicationCount: Number(row.ApplicationCount || 0)
+ applicationCount: Number(row.ApplicationCount || 0),
+ documentCount: Number(row.DocumentCount || 0)
};
}
@@ -190,7 +193,7 @@ function duplicateApplicationError() {
}
function userHasOwnedDataError() {
- const error = new Error('User owns packages or applications.');
+ const error = new Error('User owns packages, applications, or documents.');
error.code = 'USER_HAS_OWNED_DATA';
return error;
}
@@ -358,6 +361,69 @@ async function ensurePackageTypeSchema() {
return packageTypeSchemaPromise;
}
+async function ensureDocumentSchema() {
+ if (!documentSchemaPromise) {
+ documentSchemaPromise = getPool().then((pool) => pool.request().query(`
+ IF OBJECT_ID(N'dbo.Documents', N'U') IS NULL
+ BEGIN
+ CREATE TABLE dbo.Documents
+ (
+ Id UNIQUEIDENTIFIER NOT NULL
+ CONSTRAINT PK_Documents PRIMARY KEY CLUSTERED
+ CONSTRAINT DF_Documents_Id DEFAULT NEWSEQUENTIALID(),
+ Title NVARCHAR(200) NOT NULL,
+ Category NVARCHAR(50) NOT NULL
+ CONSTRAINT DF_Documents_Category DEFAULT N'other',
+ Summary NVARCHAR(1000) NULL,
+ Content NVARCHAR(MAX) NULL,
+ FilePath NVARCHAR(1000) NULL,
+ OriginalFileName NVARCHAR(260) NULL,
+ MimeType NVARCHAR(200) NULL,
+ FileSizeBytes BIGINT NULL,
+ CreatedByUserId UNIQUEIDENTIFIER NOT NULL,
+ CreatedAt DATETIME2(3) NOT NULL
+ CONSTRAINT DF_Documents_CreatedAt DEFAULT SYSUTCDATETIME(),
+ UpdatedAt DATETIME2(3) NULL,
+ CONSTRAINT FK_Documents_CreatedByUser
+ FOREIGN KEY (CreatedByUserId) REFERENCES dbo.Users(Id),
+ CONSTRAINT CK_Documents_Title_NotBlank CHECK (LEN(LTRIM(RTRIM(Title))) > 0),
+ CONSTRAINT CK_Documents_Category CHECK (
+ Category IN (N'introduction', N'guide', N'user-guide', N'technical', N'policy', N'other')
+ ),
+ CONSTRAINT CK_Documents_FileSizeBytes CHECK (FileSizeBytes IS NULL OR FileSizeBytes >= 0),
+ CONSTRAINT CK_Documents_HasReadableContent CHECK (
+ NULLIF(LTRIM(RTRIM(Content)), N'') IS NOT NULL OR FilePath IS NOT NULL
+ )
+ );
+ END;
+
+ IF NOT EXISTS (
+ SELECT 1
+ FROM sys.indexes
+ WHERE name = N'IX_Documents_Category_UpdatedAt'
+ AND object_id = OBJECT_ID(N'dbo.Documents')
+ )
+ BEGIN
+ CREATE INDEX IX_Documents_Category_UpdatedAt
+ ON dbo.Documents(Category, UpdatedAt DESC, CreatedAt DESC);
+ END;
+
+ IF NOT EXISTS (
+ SELECT 1
+ FROM sys.indexes
+ WHERE name = N'IX_Documents_CreatedByUserId'
+ AND object_id = OBJECT_ID(N'dbo.Documents')
+ )
+ BEGIN
+ CREATE INDEX IX_Documents_CreatedByUserId
+ ON dbo.Documents(CreatedByUserId);
+ END;
+ `));
+ }
+
+ return documentSchemaPromise;
+}
+
function normalizePackageStatus(isActive) {
return isActive ? 'Active' : 'Archived';
}
@@ -438,6 +504,28 @@ function mapApplicationPackageRow(row) {
};
}
+function mapDocumentRow(row) {
+ if (!row) return null;
+
+ return {
+ id: String(row.Id),
+ title: row.Title,
+ category: row.Category,
+ summary: row.Summary || '',
+ content: row.Content || '',
+ hasContent: row.HasContent === undefined ? Boolean(row.Content) : Boolean(row.HasContent),
+ filePath: row.FilePath || '',
+ originalFileName: normalizeDocumentFileName(row.OriginalFileName || ''),
+ mimeType: row.MimeType || '',
+ fileSizeBytes: Number(row.FileSizeBytes || 0),
+ fileSize: formatFileSize(row.FileSizeBytes),
+ createdByUserId: row.CreatedByUserId ? String(row.CreatedByUserId) : '',
+ createdBy: row.CreatedByUsername || '',
+ createdAt: formatDate(row.CreatedAt),
+ updatedAt: formatDate(row.UpdatedAt || row.CreatedAt)
+ };
+}
+
function isLoopbackHost(hostname) {
const host = String(hostname || '').toLowerCase();
return host === 'localhost' || host === '127.0.0.1' || host === '::1';
@@ -494,23 +582,27 @@ async function getUserById(id) {
}
async function getUserOwnershipCounts(userId) {
+ await ensureDocumentSchema();
const pool = await getPool();
const result = await pool.request()
.input('UserId', sql.UniqueIdentifier, userId)
.query(`
SELECT
(SELECT COUNT_BIG(*) FROM dbo.Packages WHERE CreatedByUserId = @UserId) AS PackageCount,
- (SELECT COUNT_BIG(*) FROM dbo.Applications WHERE CreatedByUserId = @UserId) AS ApplicationCount;
+ (SELECT COUNT_BIG(*) FROM dbo.Applications WHERE CreatedByUserId = @UserId) AS ApplicationCount,
+ (SELECT COUNT_BIG(*) FROM dbo.Documents WHERE CreatedByUserId = @UserId) AS DocumentCount;
`);
const row = result.recordset[0];
return {
packageCount: Number(row.PackageCount || 0),
- applicationCount: Number(row.ApplicationCount || 0)
+ applicationCount: Number(row.ApplicationCount || 0),
+ documentCount: Number(row.DocumentCount || 0)
};
}
async function listUsers() {
+ await ensureDocumentSchema();
const pool = await getPool();
const result = await pool.request().query(`
SELECT
@@ -523,7 +615,8 @@ async function listUsers() {
u.CreatedAt,
u.UpdatedAt,
package_count.PackageCount,
- application_count.ApplicationCount
+ application_count.ApplicationCount,
+ document_count.DocumentCount
FROM dbo.Users AS u
OUTER APPLY (
SELECT COUNT_BIG(*) AS PackageCount
@@ -535,6 +628,11 @@ async function listUsers() {
FROM dbo.Applications AS a
WHERE a.CreatedByUserId = u.Id
) AS application_count
+ OUTER APPLY (
+ SELECT COUNT_BIG(*) AS DocumentCount
+ FROM dbo.Documents AS d
+ WHERE d.CreatedByUserId = u.Id
+ ) AS document_count
ORDER BY u.CreatedAt DESC, u.Username ASC;
`);
@@ -865,7 +963,7 @@ async function updateUser(input) {
async function deleteUser(userId) {
const counts = await getUserOwnershipCounts(userId);
- if (counts.packageCount > 0 || counts.applicationCount > 0) {
+ if (counts.packageCount > 0 || counts.applicationCount > 0 || counts.documentCount > 0) {
throw userHasOwnedDataError();
}
@@ -1588,6 +1686,174 @@ async function removeApplicationPackage(applicationId, packageId) {
return result.recordset.length > 0;
}
+async function listDocuments() {
+ await ensureDocumentSchema();
+ const pool = await getPool();
+ const result = await pool.request().query(`
+ SELECT
+ d.Id,
+ d.Title,
+ d.Category,
+ d.Summary,
+ CASE WHEN NULLIF(LTRIM(RTRIM(d.Content)), N'') IS NULL THEN 0 ELSE 1 END AS HasContent,
+ d.FilePath,
+ d.OriginalFileName,
+ d.MimeType,
+ d.FileSizeBytes,
+ d.CreatedByUserId,
+ d.CreatedAt,
+ d.UpdatedAt,
+ u.Username AS CreatedByUsername
+ FROM dbo.Documents AS d
+ INNER JOIN dbo.Users AS u
+ ON u.Id = d.CreatedByUserId
+ ORDER BY COALESCE(d.UpdatedAt, d.CreatedAt) DESC, d.Title ASC;
+ `);
+
+ return result.recordset.map(mapDocumentRow);
+}
+
+async function getDocumentById(documentId) {
+ await ensureDocumentSchema();
+ const pool = await getPool();
+ const result = await pool.request()
+ .input('Id', sql.NVarChar(100), String(documentId || '').trim())
+ .query(`
+ SELECT TOP (1)
+ d.Id,
+ d.Title,
+ d.Category,
+ d.Summary,
+ d.Content,
+ d.FilePath,
+ d.OriginalFileName,
+ d.MimeType,
+ d.FileSizeBytes,
+ d.CreatedByUserId,
+ d.CreatedAt,
+ d.UpdatedAt,
+ u.Username AS CreatedByUsername
+ FROM dbo.Documents AS d
+ INNER JOIN dbo.Users AS u
+ ON u.Id = d.CreatedByUserId
+ WHERE CONVERT(NVARCHAR(36), d.Id) = @Id;
+ `);
+
+ return mapDocumentRow(result.recordset[0]);
+}
+
+async function createDocument(input) {
+ await ensureDocumentSchema();
+ const pool = await getPool();
+ const result = await pool.request()
+ .input('Title', sql.NVarChar(200), String(input.title || '').trim())
+ .input('Category', sql.NVarChar(50), input.category)
+ .input('Summary', sql.NVarChar(1000), String(input.summary || '').trim() || null)
+ .input('Content', sql.NVarChar(sql.MAX), String(input.content || '').trim() || null)
+ .input('FilePath', sql.NVarChar(1000), input.filePath || null)
+ .input('OriginalFileName', sql.NVarChar(260), input.originalFileName || null)
+ .input('MimeType', sql.NVarChar(200), input.mimeType || null)
+ .input('FileSizeBytes', sql.BigInt, input.fileSizeBytes ?? null)
+ .input('CreatedByUserId', sql.UniqueIdentifier, input.createdByUserId)
+ .query(`
+ INSERT dbo.Documents
+ (
+ Title,
+ Category,
+ Summary,
+ Content,
+ FilePath,
+ OriginalFileName,
+ MimeType,
+ FileSizeBytes,
+ CreatedByUserId
+ )
+ OUTPUT inserted.Id
+ VALUES
+ (
+ @Title,
+ @Category,
+ @Summary,
+ @Content,
+ @FilePath,
+ @OriginalFileName,
+ @MimeType,
+ @FileSizeBytes,
+ @CreatedByUserId
+ );
+ `);
+
+ return String(result.recordset[0].Id);
+}
+
+async function updateDocument(input) {
+ await ensureDocumentSchema();
+ const pool = await getPool();
+ const replaceAttachment = Boolean(input.replaceAttachment);
+ const result = await pool.request()
+ .input('Id', sql.UniqueIdentifier, input.documentId)
+ .input('Title', sql.NVarChar(200), String(input.title || '').trim())
+ .input('Category', sql.NVarChar(50), input.category)
+ .input('Summary', sql.NVarChar(1000), String(input.summary || '').trim() || null)
+ .input('Content', sql.NVarChar(sql.MAX), String(input.content || '').trim() || null)
+ .input('ReplaceAttachment', sql.Bit, replaceAttachment ? 1 : 0)
+ .input('FilePath', sql.NVarChar(1000), input.filePath || null)
+ .input('OriginalFileName', sql.NVarChar(260), input.originalFileName || null)
+ .input('MimeType', sql.NVarChar(200), input.mimeType || null)
+ .input('FileSizeBytes', sql.BigInt, input.fileSizeBytes ?? null)
+ .query(`
+ UPDATE dbo.Documents
+ SET Title = @Title,
+ Category = @Category,
+ Summary = @Summary,
+ Content = @Content,
+ FilePath = CASE WHEN @ReplaceAttachment = 1 THEN @FilePath ELSE FilePath END,
+ OriginalFileName = CASE WHEN @ReplaceAttachment = 1 THEN @OriginalFileName ELSE OriginalFileName END,
+ MimeType = CASE WHEN @ReplaceAttachment = 1 THEN @MimeType ELSE MimeType END,
+ FileSizeBytes = CASE WHEN @ReplaceAttachment = 1 THEN @FileSizeBytes ELSE FileSizeBytes END,
+ UpdatedAt = SYSUTCDATETIME()
+ OUTPUT
+ inserted.Id,
+ deleted.FilePath AS PreviousFilePath,
+ inserted.FilePath AS CurrentFilePath
+ WHERE Id = @Id;
+ `);
+ const row = result.recordset[0];
+
+ if (!row) return null;
+
+ return {
+ id: String(row.Id),
+ previousFilePath: row.PreviousFilePath || '',
+ currentFilePath: row.CurrentFilePath || '',
+ attachmentChanged: replaceAttachment
+ };
+}
+
+async function deleteDocument(documentId) {
+ await ensureDocumentSchema();
+ const pool = await getPool();
+ const result = await pool.request()
+ .input('Id', sql.UniqueIdentifier, documentId)
+ .query(`
+ DELETE FROM dbo.Documents
+ OUTPUT
+ deleted.Id,
+ deleted.FilePath,
+ deleted.OriginalFileName
+ WHERE Id = @Id;
+ `);
+ const row = result.recordset[0];
+
+ if (!row) return null;
+
+ return {
+ id: String(row.Id),
+ filePath: row.FilePath || '',
+ originalFileName: row.OriginalFileName || ''
+ };
+}
+
async function getPageData(currentUser) {
const [stats, packageRows, applications, activity] = await Promise.all([
getStats(),
@@ -1635,5 +1901,10 @@ module.exports = {
updateApplication,
updateApplicationStatus,
deleteApplication,
- removeApplicationPackage
+ removeApplicationPackage,
+ listDocuments,
+ getDocumentById,
+ createDocument,
+ updateDocument,
+ deleteDocument
};
diff --git a/web-server/views/document-detail.ejs b/web-server/views/document-detail.ejs
new file mode 100644
index 0000000..360e03b
--- /dev/null
+++ b/web-server/views/document-detail.ejs
@@ -0,0 +1,161 @@
+<%- include('partials/page-start') %>
+
+ <%= document.summary || 'Tài liệu nội bộ Robot Installer.' %> Đọc nội dung và xem trước file được trình duyệt hỗ trợ.<%= document.title %>
+ Nội dung
+
+ <% } else { %>
+
+ <% } %>
+
Lưu trữ và tra cứu tài liệu giới thiệu, hướng dẫn sử dụng, quy trình và tài liệu kỹ thuật.
+| Tài liệu | +Nhóm | +File đính kèm | +Cập nhật | +Người tạo | +Thao tác | +
|---|---|---|---|---|---|
| Chưa có tài liệu. Bấm Thêm tài liệu để tạo nội dung đầu tiên. | +|||||
| + <%= item.title %> + <%= item.summary || (item.hasContent ? 'Có nội dung đọc trực tiếp' : 'Tài liệu đính kèm') %> + | +<%= helpers.documentCategoryLabel(item.category) %> | ++ <% if (item.filePath) { %> + <%= item.originalFileName %> + <%= item.fileSize %> + <% } else { %> + Không có file + <% } %> + | +<%= item.updatedAt %> | +<%= item.createdBy %> | +
+
+
+ visibility
+
+ <% if (item.filePath) { %>
+
+ download
+
+ <% } %>
+
+
+ |
+