From 166276d19b3f5b04761d411f572aa10fbc4ba903 Mon Sep 17 00:00:00 2001 From: DungTT Date: Wed, 22 Jul 2026 14:11:18 +0700 Subject: [PATCH] update docs --- web-server/.env.example | 3 + web-server/Dockerfile | 2 +- web-server/database/02_schema.sql | 39 +++ web-server/database/05_documents.sql | 67 +++++ web-server/database/README.md | 4 +- web-server/docker-entrypoint.sh | 2 +- web-server/public/css/styles.css | 162 ++++++++++- web-server/public/js/app.js | 11 +- web-server/server.js | 403 ++++++++++++++++++++++++++- web-server/src/document-file-name.js | 23 ++ web-server/src/repository.js | 285 ++++++++++++++++++- web-server/views/document-detail.ejs | 161 +++++++++++ web-server/views/documents.ejs | 161 +++++++++++ web-server/views/users.ejs | 2 + 14 files changed, 1308 insertions(+), 17 deletions(-) create mode 100644 web-server/database/05_documents.sql create mode 100644 web-server/src/document-file-name.js create mode 100644 web-server/views/document-detail.ejs create mode 100644 web-server/views/documents.ejs 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') %> + +
+ + +
+
+
+
+

Thông tin tài liệu

+

Thông tin phân loại và tệp lưu trữ.

+
+
+
+
Nhóm
<%= helpers.documentCategoryLabel(document.category) %>
+
Người tạo
<%= document.createdBy %>
+
Ngày tạo
<%= document.createdAt %>
+
Cập nhật
<%= document.updatedAt %>
+
File
<%= document.originalFileName || 'Không có' %>
+
Dung lượng
<%= document.fileSize || '-' %>
+
+
+ +
+
+
+

Nội dung

+

Đọc nội dung và xem trước file được trình duyệt hỗ trợ.

+
+ <% if (document.filePath) { %> + Mở file + <% } %> +
+
+ <% if (document.content) { %> +
<%= document.content %>
+ <% } %> + + <% if (document.filePath && documentPreviewKind) { %> +
+
+ attach_file + Xem trước: <%= document.originalFileName %> +
+ <% if (documentPreviewKind === 'image') { %> + <%= document.title %> + <% } else { %> + + <% } %> +
+ <% } else if (document.filePath) { %> +
+ description +
+ <%= document.originalFileName %> +

Trình duyệt không xem trực tiếp định dạng này. Hãy tải file để mở bằng ứng dụng phù hợp.

+
+ Tải file +
+ <% } %> + + <% if (!document.content && !document.filePath) { %> +
Tài liệu chưa có nội dung.
+ <% } %> +
+
+
+
+ + + +<%- include('partials/page-end') %> diff --git a/web-server/views/documents.ejs b/web-server/views/documents.ejs new file mode 100644 index 0000000..3481df1 --- /dev/null +++ b/web-server/views/documents.ejs @@ -0,0 +1,161 @@ +<%- include('partials/page-start') %> + +
+ + +
+ + +
+ +
+
+ + + + + + + + + + + + + <% if (documents.length === 0) { %> + + + + <% } %> + <% documents.forEach((item) => { %> + + + + + + + + + <% }) %> + +
Tài liệuNhómFile đính kèmCập nhậtNgười tạoThao 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 + + <% } %> +
+ +
+
+
+
+
+ Hiển thị <%= documents.length %> tài liệu +
+ + Trang 1 / 1 + +
+
+
+
+ + + +<%- include('partials/page-end') %> diff --git a/web-server/views/users.ejs b/web-server/views/users.ejs index 65bfbd4..ea25618 100644 --- a/web-server/views/users.ejs +++ b/web-server/views/users.ejs @@ -114,6 +114,7 @@ data-user-updated-at="<%= user.updatedAt %>" data-user-package-count="<%= user.packageCount %>" data-user-application-count="<%= user.applicationCount %>" + data-user-document-count="<%= user.documentCount %>" > <%= user.name %> @@ -126,6 +127,7 @@ <%= user.packageCount %> packages <%= user.applicationCount %> apps + <%= user.documentCount %> tài liệu <% if (user.id === currentUser.id) { %>