Files
InstallerRobot/web-server/src/repository.js
2026-05-22 16:47:51 +07:00

1325 lines
40 KiB
JavaScript

const crypto = require('crypto');
const { sql, getPool } = require('./db');
const PASSWORD_HASH_PREFIX = 'pbkdf2';
const PASSWORD_HASH_ITERATIONS = 120000;
const PASSWORD_HASH_KEY_LENGTH = 32;
const PASSWORD_HASH_DIGEST = 'sha256';
const PLACEHOLDER_PASSWORD_HASH = 'ui-placeholder-password-hash';
const EMAIL_CONFIRMATION_EXPIRES_MS = Number(process.env.EMAIL_CONFIRMATION_EXPIRES_MS || 1000 * 60 * 60 * 24);
let emailConfirmationSchemaPromise;
function toDateInputValue(value) {
const date = value ? new Date(value) : new Date();
if (Number.isNaN(date.getTime())) return new Date().toISOString().slice(0, 10);
return date.toISOString().slice(0, 10);
}
function formatDate(value) {
if (!value) return '';
return toDateInputValue(value);
}
function formatFileSize(bytes) {
if (!Number.isFinite(Number(bytes)) || Number(bytes) <= 0) return '';
const numericBytes = Number(bytes);
const units = ['B', 'KB', 'MB', 'GB'];
const index = Math.min(Math.floor(Math.log(numericBytes) / Math.log(1024)), units.length - 1);
const value = numericBytes / Math.pow(1024, index);
return `${value.toFixed(value >= 10 || index === 0 ? 0 : 1)} ${units[index]}`;
}
function normalizeRole(role) {
return role === 'Admin' ? 'Admin' : 'User';
}
function normalizeEmail(email) {
return String(email || '').trim().toLowerCase();
}
function isPasswordHashUsable(passwordHash) {
return String(passwordHash || '').startsWith(`${PASSWORD_HASH_PREFIX}$`);
}
function hashPassword(password) {
const salt = crypto.randomBytes(16).toString('base64url');
const hash = crypto.pbkdf2Sync(
password,
salt,
PASSWORD_HASH_ITERATIONS,
PASSWORD_HASH_KEY_LENGTH,
PASSWORD_HASH_DIGEST
).toString('base64url');
return `${PASSWORD_HASH_PREFIX}$${PASSWORD_HASH_DIGEST}$${PASSWORD_HASH_ITERATIONS}$${salt}$${hash}`;
}
function verifyPassword(password, storedHash) {
if (!isPasswordHashUsable(storedHash)) return false;
const [prefix, digest, iterationsText, salt, expectedHash] = String(storedHash).split('$');
const iterations = Number(iterationsText);
if (prefix !== PASSWORD_HASH_PREFIX || !digest || !iterations || !salt || !expectedHash) {
return false;
}
try {
const actualHash = crypto.pbkdf2Sync(
password,
salt,
iterations,
Buffer.from(expectedHash, 'base64url').length,
digest
).toString('base64url');
const actualBuffer = Buffer.from(actualHash);
const expectedBuffer = Buffer.from(expectedHash);
return actualBuffer.length === expectedBuffer.length && crypto.timingSafeEqual(actualBuffer, expectedBuffer);
} catch (error) {
return false;
}
}
function mapCurrentUserRow(row) {
if (!row) return null;
return {
id: String(row.Id),
username: row.Username,
email: row.Email,
name: row.FullName || row.Username,
fullName: row.FullName || '',
role: row.Role,
isActive: Boolean(row.IsActive)
};
}
function mapUserRow(row) {
return {
...mapCurrentUserRow(row),
status: row.IsActive ? 'Active' : 'Inactive',
createdAt: formatDate(row.CreatedAt),
updatedAt: formatDate(row.UpdatedAt),
packageCount: Number(row.PackageCount || 0),
applicationCount: Number(row.ApplicationCount || 0)
};
}
function duplicateUserError() {
const error = new Error('Username or email already exists.');
error.code = 'DUPLICATE_USER';
return error;
}
function duplicatePackageError() {
const error = new Error('Package code or version already exists.');
error.code = 'DUPLICATE_PACKAGE';
return error;
}
function duplicateApplicationError() {
const error = new Error('Application code already exists.');
error.code = 'DUPLICATE_APPLICATION';
return error;
}
function userHasOwnedDataError() {
const error = new Error('User owns packages or applications.');
error.code = 'USER_HAS_OWNED_DATA';
return error;
}
function hashEmailToken(token) {
return crypto.createHash('sha256').update(String(token || '')).digest('hex');
}
async function ensureEmailConfirmationSchema() {
if (!emailConfirmationSchemaPromise) {
emailConfirmationSchemaPromise = getPool().then((pool) => pool.request().query(`
IF OBJECT_ID(N'dbo.EmailConfirmationTokens', N'U') IS NULL
BEGIN
CREATE TABLE dbo.EmailConfirmationTokens
(
Id UNIQUEIDENTIFIER NOT NULL
CONSTRAINT PK_EmailConfirmationTokens PRIMARY KEY CLUSTERED
CONSTRAINT DF_EmailConfirmationTokens_Id DEFAULT NEWSEQUENTIALID(),
UserId UNIQUEIDENTIFIER NOT NULL,
TokenHash CHAR(64) NOT NULL,
ExpiresAt DATETIME2(3) NOT NULL,
ConfirmedAt DATETIME2(3) NULL,
CreatedAt DATETIME2(3) NOT NULL
CONSTRAINT DF_EmailConfirmationTokens_CreatedAt DEFAULT SYSUTCDATETIME(),
CONSTRAINT FK_EmailConfirmationTokens_User
FOREIGN KEY (UserId) REFERENCES dbo.Users(Id) ON DELETE CASCADE
);
END;
IF NOT EXISTS (
SELECT 1
FROM sys.indexes
WHERE name = N'UX_EmailConfirmationTokens_TokenHash'
AND object_id = OBJECT_ID(N'dbo.EmailConfirmationTokens')
)
BEGIN
CREATE UNIQUE INDEX UX_EmailConfirmationTokens_TokenHash
ON dbo.EmailConfirmationTokens(TokenHash);
END;
IF NOT EXISTS (
SELECT 1
FROM sys.indexes
WHERE name = N'IX_EmailConfirmationTokens_UserId'
AND object_id = OBJECT_ID(N'dbo.EmailConfirmationTokens')
)
BEGIN
CREATE INDEX IX_EmailConfirmationTokens_UserId
ON dbo.EmailConfirmationTokens(UserId);
END;
`));
}
return emailConfirmationSchemaPromise;
}
function normalizePackageStatus(isActive) {
return isActive ? 'Active' : 'Archived';
}
function normalizeApplicationStatus(status) {
return ['Draft', 'Released', 'Archived'].includes(status) ? status : 'Draft';
}
function normalizeVersionStatus(row) {
if (row.IsLatest) return 'Latest';
if (row.IsDeprecated) return 'Deprecated';
return 'Stable';
}
function mapPackageRow(row) {
return {
id: String(row.Id),
code: row.PackageCode,
name: row.PackageName,
type: row.PackageType,
latestVersion: row.LatestVersion || '',
latestReleaseDate: formatDate(row.LatestReleaseDate),
status: normalizePackageStatus(row.IsActive),
owner: row.CreatedByUsername || '',
description: row.Description || '',
artifact: row.LatestFilePath || row.LatestDockerImage || '',
versionCount: Number(row.VersionCount || 0),
versions: []
};
}
function mapVersionRow(row) {
return {
id: String(row.Id),
packageId: String(row.PackageId),
version: row.Version,
releaseDate: formatDate(row.ReleaseDate),
uploadedBy: row.UploadedBy || '',
status: normalizeVersionStatus(row),
size: formatFileSize(row.FileSizeBytes),
changeLog: row.ChangeLog || '',
filePath: row.FilePath || '',
dockerImage: row.DockerImage || '',
checksum: row.FileChecksumSha256 || ''
};
}
function mapApplicationRow(row) {
return {
id: String(row.Id),
code: row.AppCode,
name: row.AppName,
version: row.AppVersion,
status: row.Status,
createdAt: formatDate(row.CreatedAt),
createdBy: row.CreatedByUsername || '',
notes: row.Notes || row.Description || '',
packageCount: Number(row.PackageCount || 0),
packages: []
};
}
function mapApplicationPackageRow(row) {
return {
id: String(row.Id),
applicationId: String(row.ApplicationId),
packageId: String(row.PackageId),
code: row.PackageCode,
name: row.PackageName,
type: row.PackageType,
selectedVersionId: row.SelectedVersionId ? String(row.SelectedVersionId) : '',
selectedVersion: row.SelectedVersion || '',
filePath: row.FilePath || '',
dockerImage: row.DockerImage || ''
};
}
function toAbsoluteUrl(baseUrl, filePath) {
if (!filePath) return '';
if (/^https?:\/\//i.test(filePath)) return filePath;
const normalizedBaseUrl = String(baseUrl || '').replace(/\/+$/, '');
const normalizedPath = String(filePath).startsWith('/') ? filePath : `/${filePath}`;
return `${normalizedBaseUrl}${normalizedPath}`;
}
async function getUserById(id) {
const pool = await getPool();
const result = await pool.request()
.input('Id', sql.NVarChar(100), id)
.query(`
SELECT TOP (1) Id, Username, Email, FullName, Role, IsActive
FROM dbo.Users
WHERE CONVERT(NVARCHAR(36), Id) = @Id;
`);
return mapCurrentUserRow(result.recordset[0]);
}
async function getUserOwnershipCounts(userId) {
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;
`);
const row = result.recordset[0];
return {
packageCount: Number(row.PackageCount || 0),
applicationCount: Number(row.ApplicationCount || 0)
};
}
async function listUsers() {
const pool = await getPool();
const result = await pool.request().query(`
SELECT
u.Id,
u.Username,
u.Email,
u.FullName,
u.Role,
u.IsActive,
u.CreatedAt,
u.UpdatedAt,
package_count.PackageCount,
application_count.ApplicationCount
FROM dbo.Users AS u
OUTER APPLY (
SELECT COUNT_BIG(*) AS PackageCount
FROM dbo.Packages AS p
WHERE p.CreatedByUserId = u.Id
) AS package_count
OUTER APPLY (
SELECT COUNT_BIG(*) AS ApplicationCount
FROM dbo.Applications AS a
WHERE a.CreatedByUserId = u.Id
) AS application_count
ORDER BY u.CreatedAt DESC, u.Username ASC;
`);
return result.recordset.map(mapUserRow);
}
async function countUsableUsers() {
const pool = await getPool();
const result = await pool.request()
.input('PasswordHashPrefix', sql.NVarChar(50), `${PASSWORD_HASH_PREFIX}$%`)
.query(`
SELECT COUNT_BIG(*) AS Total
FROM dbo.Users
WHERE PasswordHash LIKE @PasswordHashPrefix;
`);
return Number(result.recordset[0].Total || 0);
}
async function findUserAccountByIdentifier(identifier) {
const pool = await getPool();
const result = await pool.request()
.input('Identifier', sql.NVarChar(255), String(identifier || '').trim())
.query(`
SELECT TOP (1) Id, Username, Email, PasswordHash, FullName, Role, IsActive
FROM dbo.Users
WHERE Username = @Identifier OR Email = @Identifier;
`);
return result.recordset[0] || null;
}
async function getPendingUserByEmail(email) {
const pool = await getPool();
const result = await pool.request()
.input('Email', sql.NVarChar(255), normalizeEmail(email))
.input('PasswordHashPrefix', sql.NVarChar(50), `${PASSWORD_HASH_PREFIX}$%`)
.query(`
SELECT TOP (1) Id, Username, Email, FullName, Role, IsActive
FROM dbo.Users
WHERE Email = @Email
AND IsActive = 0
AND PasswordHash LIKE @PasswordHashPrefix;
`);
return mapCurrentUserRow(result.recordset[0]);
}
async function getRegistrationConflict({ username, email }) {
const pool = await getPool();
const result = await pool.request()
.input('Username', sql.NVarChar(100), String(username || '').trim())
.input('Email', sql.NVarChar(255), normalizeEmail(email))
.query(`
SELECT TOP (1) Id, Username, Email, FullName, Role, IsActive
FROM dbo.Users
WHERE Username = @Username OR Email = @Email
ORDER BY
CASE
WHEN Email = @Email THEN 0
WHEN Username = @Username THEN 1
ELSE 2
END,
CreatedAt ASC;
`);
const user = mapCurrentUserRow(result.recordset[0]);
if (!user) return null;
return {
user,
field: normalizeEmail(user.email) === normalizeEmail(email) ? 'email' : 'username'
};
}
async function authenticateUser(identifier, password) {
const row = await findUserAccountByIdentifier(identifier);
if (!row || !row.IsActive) return null;
if (!verifyPassword(String(password || ''), row.PasswordHash)) return null;
return mapCurrentUserRow(row);
}
async function createUser(input) {
const pool = await getPool();
const username = String(input.username || '').trim();
const email = normalizeEmail(input.email);
const fullName = String(input.fullName || '').trim() || null;
const passwordHash = hashPassword(String(input.password || ''));
const usableUserCount = await countUsableUsers();
const role = input.role ? normalizeRole(input.role) : (usableUserCount === 0 ? 'Admin' : 'User');
const isActive = input.isActive === undefined ? true : Boolean(input.isActive);
const existingResult = await pool.request()
.input('Username', sql.NVarChar(100), username)
.input('Email', sql.NVarChar(255), email)
.query(`
SELECT TOP (1) Id, PasswordHash
FROM dbo.Users
WHERE Username = @Username OR Email = @Email;
`);
const existing = existingResult.recordset[0];
if (existing) {
if (usableUserCount === 0 && existing.PasswordHash === PLACEHOLDER_PASSWORD_HASH) {
const updateResult = await pool.request()
.input('Id', sql.UniqueIdentifier, existing.Id)
.input('Username', sql.NVarChar(100), username)
.input('Email', sql.NVarChar(255), email)
.input('PasswordHash', sql.NVarChar(500), passwordHash)
.input('FullName', sql.NVarChar(200), fullName)
.input('Role', sql.NVarChar(50), role)
.input('IsActive', sql.Bit, isActive ? 1 : 0)
.query(`
UPDATE dbo.Users
SET Username = @Username,
Email = @Email,
PasswordHash = @PasswordHash,
FullName = @FullName,
Role = @Role,
IsActive = @IsActive,
UpdatedAt = SYSUTCDATETIME()
OUTPUT inserted.Id, inserted.Username, inserted.Email, inserted.FullName, inserted.Role, inserted.IsActive
WHERE Id = @Id;
`);
return mapCurrentUserRow(updateResult.recordset[0]);
}
throw duplicateUserError();
}
if (usableUserCount === 0) {
const placeholderResult = await pool.request()
.input('PasswordHash', sql.NVarChar(500), PLACEHOLDER_PASSWORD_HASH)
.query(`
SELECT TOP (1) Id
FROM dbo.Users
WHERE PasswordHash = @PasswordHash
ORDER BY CreatedAt ASC;
`);
const placeholder = placeholderResult.recordset[0];
if (placeholder) {
const updateResult = await pool.request()
.input('Id', sql.UniqueIdentifier, placeholder.Id)
.input('Username', sql.NVarChar(100), username)
.input('Email', sql.NVarChar(255), email)
.input('PasswordHash', sql.NVarChar(500), passwordHash)
.input('FullName', sql.NVarChar(200), fullName)
.input('Role', sql.NVarChar(50), role)
.input('IsActive', sql.Bit, isActive ? 1 : 0)
.query(`
UPDATE dbo.Users
SET Username = @Username,
Email = @Email,
PasswordHash = @PasswordHash,
FullName = @FullName,
Role = @Role,
IsActive = @IsActive,
UpdatedAt = SYSUTCDATETIME()
OUTPUT inserted.Id, inserted.Username, inserted.Email, inserted.FullName, inserted.Role, inserted.IsActive
WHERE Id = @Id;
`);
return mapCurrentUserRow(updateResult.recordset[0]);
}
}
try {
const result = await pool.request()
.input('Username', sql.NVarChar(100), username)
.input('Email', sql.NVarChar(255), email)
.input('PasswordHash', sql.NVarChar(500), passwordHash)
.input('FullName', sql.NVarChar(200), fullName)
.input('Role', sql.NVarChar(50), role)
.input('IsActive', sql.Bit, isActive ? 1 : 0)
.query(`
INSERT dbo.Users (Username, Email, PasswordHash, FullName, Role, IsActive)
OUTPUT inserted.Id, inserted.Username, inserted.Email, inserted.FullName, inserted.Role, inserted.IsActive
VALUES (@Username, @Email, @PasswordHash, @FullName, @Role, @IsActive);
`);
return mapCurrentUserRow(result.recordset[0]);
} catch (error) {
if (error.number === 2601 || error.number === 2627) {
throw duplicateUserError();
}
throw error;
}
}
async function createEmailConfirmationToken(userId) {
await ensureEmailConfirmationSchema();
const pool = await getPool();
const token = crypto.randomBytes(32).toString('base64url');
const tokenHash = hashEmailToken(token);
const expiresAt = new Date(Date.now() + EMAIL_CONFIRMATION_EXPIRES_MS);
await pool.request()
.input('UserId', sql.UniqueIdentifier, userId)
.input('TokenHash', sql.Char(64), tokenHash)
.input('ExpiresAt', sql.DateTime2, expiresAt)
.query(`
DELETE FROM dbo.EmailConfirmationTokens
WHERE UserId = @UserId
AND ConfirmedAt IS NULL;
INSERT dbo.EmailConfirmationTokens (UserId, TokenHash, ExpiresAt)
VALUES (@UserId, @TokenHash, @ExpiresAt);
`);
return token;
}
async function confirmEmailToken(token) {
await ensureEmailConfirmationSchema();
const pool = await getPool();
const result = await pool.request()
.input('TokenHash', sql.Char(64), hashEmailToken(token))
.query(`
SET NOCOUNT ON;
SET XACT_ABORT ON;
BEGIN TRANSACTION;
DECLARE @TokenId UNIQUEIDENTIFIER;
DECLARE @UserId UNIQUEIDENTIFIER;
SELECT TOP (1)
@TokenId = Id,
@UserId = UserId
FROM dbo.EmailConfirmationTokens WITH (UPDLOCK, HOLDLOCK)
WHERE TokenHash = @TokenHash
AND ConfirmedAt IS NULL
AND ExpiresAt >= SYSUTCDATETIME();
IF @TokenId IS NULL
BEGIN
COMMIT TRANSACTION;
SELECT TOP (0) Id, Username, Email, FullName, Role, IsActive
FROM dbo.Users;
RETURN;
END;
UPDATE dbo.EmailConfirmationTokens
SET ConfirmedAt = SYSUTCDATETIME()
WHERE Id = @TokenId;
UPDATE dbo.Users
SET IsActive = 1,
UpdatedAt = SYSUTCDATETIME()
OUTPUT inserted.Id, inserted.Username, inserted.Email, inserted.FullName, inserted.Role, inserted.IsActive
WHERE Id = @UserId;
COMMIT TRANSACTION;
`);
return mapCurrentUserRow(result.recordset[0]);
}
async function updateUserAccess(input) {
const pool = await getPool();
await pool.request()
.input('Id', sql.UniqueIdentifier, input.userId)
.input('Role', sql.NVarChar(50), normalizeRole(input.role))
.input('IsActive', sql.Bit, input.isActive ? 1 : 0)
.query(`
UPDATE dbo.Users
SET Role = @Role,
IsActive = @IsActive,
UpdatedAt = SYSUTCDATETIME()
WHERE Id = @Id;
`);
}
async function updateUser(input) {
const pool = await getPool();
const username = String(input.username || '').trim();
const email = normalizeEmail(input.email);
const fullName = String(input.fullName || '').trim() || null;
const role = normalizeRole(input.role);
const isActive = input.isActive ? 1 : 0;
const passwordHash = input.password ? hashPassword(String(input.password)) : null;
const existingResult = await pool.request()
.input('Id', sql.UniqueIdentifier, input.userId)
.input('Username', sql.NVarChar(100), username)
.input('Email', sql.NVarChar(255), email)
.query(`
SELECT TOP (1) Id
FROM dbo.Users
WHERE (Username = @Username OR Email = @Email)
AND Id <> @Id;
`);
if (existingResult.recordset.length > 0) {
throw duplicateUserError();
}
const result = await pool.request()
.input('Id', sql.UniqueIdentifier, input.userId)
.input('Username', sql.NVarChar(100), username)
.input('Email', sql.NVarChar(255), email)
.input('FullName', sql.NVarChar(200), fullName)
.input('Role', sql.NVarChar(50), role)
.input('IsActive', sql.Bit, isActive)
.input('PasswordHash', sql.NVarChar(500), passwordHash)
.query(`
UPDATE dbo.Users
SET Username = @Username,
Email = @Email,
FullName = @FullName,
Role = @Role,
IsActive = @IsActive,
PasswordHash = COALESCE(@PasswordHash, PasswordHash),
UpdatedAt = SYSUTCDATETIME()
OUTPUT inserted.Id, inserted.Username, inserted.Email, inserted.FullName, inserted.Role, inserted.IsActive
WHERE Id = @Id;
`);
return mapCurrentUserRow(result.recordset[0]);
}
async function deleteUser(userId) {
const counts = await getUserOwnershipCounts(userId);
if (counts.packageCount > 0 || counts.applicationCount > 0) {
throw userHasOwnedDataError();
}
const pool = await getPool();
const result = await pool.request()
.input('Id', sql.UniqueIdentifier, userId)
.query(`
DELETE FROM dbo.Users
OUTPUT deleted.Id
WHERE Id = @Id;
`);
return result.recordset.length > 0;
}
async function listPackages() {
const pool = await getPool();
const result = await pool.request().query(`
SELECT *
FROM dbo.vw_PackageList
ORDER BY CreatedAt DESC, PackageName ASC;
`);
return result.recordset.map(mapPackageRow);
}
async function listPackageVersions() {
const pool = await getPool();
const result = await pool.request().query(`
SELECT *
FROM dbo.vw_PackageVersionList
ORDER BY PackageId, IsLatest DESC, ReleaseDate DESC, UploadedAt DESC;
`);
return result.recordset.map(mapVersionRow);
}
async function attachPackageVersions(packages) {
if (packages.length === 0) return packages;
const versions = await listPackageVersions();
const versionsByPackageId = new Map();
versions.forEach((version) => {
if (!versionsByPackageId.has(version.packageId)) {
versionsByPackageId.set(version.packageId, []);
}
versionsByPackageId.get(version.packageId).push(version);
});
return packages.map((packageItem) => ({
...packageItem,
versions: versionsByPackageId.get(packageItem.id) || []
}));
}
async function getPackageById(id) {
const pool = await getPool();
const packageResult = await pool.request()
.input('Id', sql.NVarChar(100), id)
.query(`
SELECT TOP (1) *
FROM dbo.vw_PackageList
WHERE CONVERT(NVARCHAR(36), Id) = @Id OR PackageCode = @Id;
`);
if (packageResult.recordset.length === 0) return null;
const packageItem = mapPackageRow(packageResult.recordset[0]);
const versions = await pool.request()
.input('PackageId', sql.UniqueIdentifier, packageItem.id)
.query(`
SELECT *
FROM dbo.vw_PackageVersionList
WHERE PackageId = @PackageId
ORDER BY IsLatest DESC, ReleaseDate DESC, UploadedAt DESC;
`);
packageItem.versions = versions.recordset.map(mapVersionRow);
return packageItem;
}
async function listApplications() {
const pool = await getPool();
const result = await pool.request().query(`
SELECT *
FROM dbo.vw_ApplicationList
ORDER BY CreatedAt DESC, AppName ASC;
`);
const applications = result.recordset.map(mapApplicationRow);
return attachApplicationPackages(applications);
}
async function listApplicationPackages(applicationId) {
const pool = await getPool();
const request = pool.request();
let whereClause = '';
if (applicationId) {
request.input('ApplicationId', sql.UniqueIdentifier, applicationId);
whereClause = 'WHERE ApplicationId = @ApplicationId';
}
const result = await request.query(`
SELECT *
FROM dbo.vw_ApplicationPackageDetails
${whereClause}
ORDER BY AppName ASC, PackageName ASC;
`);
return result.recordset.map(mapApplicationPackageRow);
}
async function attachApplicationPackages(applications) {
if (applications.length === 0) return applications;
const applicationPackages = await listApplicationPackages();
const packagesByApplicationId = new Map();
applicationPackages.forEach((packageItem) => {
if (!packagesByApplicationId.has(packageItem.applicationId)) {
packagesByApplicationId.set(packageItem.applicationId, []);
}
packagesByApplicationId.get(packageItem.applicationId).push(packageItem);
});
return applications.map((application) => {
const packages = packagesByApplicationId.get(application.id) || [];
return {
...application,
packageCount: packages.length,
packages
};
});
}
async function getApplicationById(id) {
const pool = await getPool();
const appResult = await pool.request()
.input('Id', sql.NVarChar(100), id)
.query(`
SELECT TOP (1) *
FROM dbo.vw_ApplicationList
WHERE CONVERT(NVARCHAR(36), Id) = @Id OR AppCode = @Id;
`);
if (appResult.recordset.length === 0) return null;
const application = mapApplicationRow(appResult.recordset[0]);
application.packages = await listApplicationPackages(application.id);
application.packageCount = application.packages.length;
return application;
}
async function getApplicationManifest(appCode, version, baseUrl) {
const pool = await getPool();
const appResult = await pool.request()
.input('AppCode', sql.NVarChar(100), String(appCode || '').trim())
.input('AppVersion', sql.NVarChar(50), String(version || '').trim())
.query(`
SELECT TOP (1) Id, AppCode, AppName, AppVersion
FROM dbo.Applications
WHERE AppCode = @AppCode
AND AppVersion = @AppVersion
AND Status = N'Released';
`);
const appRow = appResult.recordset[0];
if (!appRow) return null;
const componentResult = await pool.request()
.input('ApplicationId', sql.UniqueIdentifier, appRow.Id)
.query(`
SELECT
ap.Id,
p.PackageCode,
p.PackageName,
p.PackageType,
COALESCE(selected_version.Version, latest_version.Version) AS Version,
COALESCE(selected_version.FilePath, latest_version.FilePath) AS FilePath,
COALESCE(selected_version.DockerImage, latest_version.DockerImage) AS DockerImage,
COALESCE(selected_version.FileChecksumSha256, latest_version.FileChecksumSha256) AS FileChecksumSha256,
ROW_NUMBER() OVER (ORDER BY ap.AddedAt ASC, p.PackageCode ASC) * 10 AS InstallOrder
FROM dbo.ApplicationPackages AS ap
INNER JOIN dbo.Packages AS p
ON p.Id = ap.PackageId
LEFT JOIN dbo.PackageVersions AS selected_version
ON selected_version.Id = ap.SelectedVersionId
OUTER APPLY (
SELECT TOP (1) latest.*
FROM dbo.PackageVersions AS latest
WHERE latest.PackageId = p.Id
AND latest.IsLatest = 1
ORDER BY latest.ReleaseDate DESC, latest.UploadedAt DESC
) AS latest_version
WHERE ap.ApplicationId = @ApplicationId
ORDER BY ap.AddedAt ASC, p.PackageCode ASC;
`);
const components = componentResult.recordset.map((row) => {
const installOrder = Number(row.InstallOrder || 10);
if (row.PackageType === 'docker') {
return {
componentId: row.PackageCode,
type: 'docker',
installOrder,
required: true,
image: row.DockerImage || '',
tag: row.Version || 'latest',
containerName: row.PackageCode
};
}
return {
componentId: row.PackageCode,
type: 'deb',
installOrder,
required: true,
packageName: row.PackageCode,
version: row.Version || '',
downloadUrl: toAbsoluteUrl(baseUrl, row.FilePath),
sha256: row.FileChecksumSha256 || ''
};
});
return {
schemaVersion: '1.0',
appId: appRow.AppCode,
appName: appRow.AppName,
version: appRow.AppVersion,
architecture: 'amd64',
components
};
}
async function getStats() {
const pool = await getPool();
const result = await pool.request().query(`
SELECT
(SELECT COUNT_BIG(*) FROM dbo.Packages) AS TotalPackages,
(SELECT COUNT_BIG(*) FROM dbo.Packages WHERE IsActive = 1) AS ActivePackages,
(SELECT COUNT_BIG(*) FROM dbo.PackageVersions) AS TotalVersions,
(SELECT COUNT_BIG(*) FROM dbo.Applications) AS TotalApplications,
(SELECT COUNT_BIG(*) FROM dbo.Applications WHERE Status = N'Released') AS ReleasedApplications;
`);
const row = result.recordset[0];
return {
totalPackages: Number(row.TotalPackages || 0),
activePackages: Number(row.ActivePackages || 0),
totalVersions: Number(row.TotalVersions || 0),
totalApplications: Number(row.TotalApplications || 0),
releasedApplications: Number(row.ReleasedApplications || 0)
};
}
async function getActivity() {
const pool = await getPool();
const result = await pool.request().query(`
SELECT TOP (6)
p.PackageName,
p.PackageCode,
pv.Version,
pv.UploadedAt,
pv.IsLatest
FROM dbo.PackageVersions AS pv
INNER JOIN dbo.Packages AS p
ON p.Id = pv.PackageId
ORDER BY pv.UploadedAt DESC;
`);
return result.recordset.map((row) => ({
title: `${row.IsLatest ? 'Latest' : 'Upload'} ${row.Version}`,
detail: `${row.PackageName} (${row.PackageCode})`,
time: formatDate(row.UploadedAt),
icon: row.IsLatest ? 'stars' : 'upload_file'
}));
}
async function createPackageWithVersion(input) {
const pool = await getPool();
const transaction = new sql.Transaction(pool);
await transaction.begin();
try {
const packageResult = await new sql.Request(transaction)
.input('PackageCode', sql.NVarChar(100), input.packageCode)
.input('PackageName', sql.NVarChar(200), input.packageName)
.input('PackageType', sql.NVarChar(20), input.packageType)
.input('Description', sql.NVarChar(sql.MAX), input.description || null)
.input('CreatedByUserId', sql.UniqueIdentifier, input.createdByUserId)
.query(`
INSERT dbo.Packages (PackageCode, PackageName, PackageType, Description, CreatedByUserId)
OUTPUT inserted.Id
VALUES (@PackageCode, @PackageName, @PackageType, @Description, @CreatedByUserId);
`);
const packageId = String(packageResult.recordset[0].Id);
const versionResult = await new sql.Request(transaction)
.input('PackageId', sql.UniqueIdentifier, packageId)
.input('Version', sql.NVarChar(50), input.version)
.input('FilePath', sql.NVarChar(1000), input.filePath || null)
.input('DockerImage', sql.NVarChar(500), input.dockerImage || null)
.input('FileChecksumSha256', sql.Char(64), input.checksum || null)
.input('FileSizeBytes', sql.BigInt, input.fileSizeBytes || null)
.input('ChangeLog', sql.NVarChar(sql.MAX), input.changeLog || null)
.input('ReleaseDate', sql.DateTime2, input.releaseDate ? new Date(input.releaseDate) : new Date())
.query(`
INSERT dbo.PackageVersions (
PackageId, Version, FilePath, DockerImage, FileChecksumSha256,
FileSizeBytes, ChangeLog, ReleaseDate, IsLatest
)
OUTPUT inserted.Id
VALUES (
@PackageId, @Version, @FilePath, @DockerImage, @FileChecksumSha256,
@FileSizeBytes, @ChangeLog, @ReleaseDate, 1
);
`);
await transaction.commit();
return {
packageId,
versionId: String(versionResult.recordset[0].Id)
};
} catch (error) {
await transaction.rollback();
if (error.number === 2601 || error.number === 2627) {
throw duplicatePackageError();
}
throw error;
}
}
async function addPackageVersion(input) {
const pool = await getPool();
let insertResult;
try {
insertResult = await pool.request()
.input('PackageId', sql.UniqueIdentifier, input.packageId)
.input('Version', sql.NVarChar(50), input.version)
.input('FilePath', sql.NVarChar(1000), input.filePath || null)
.input('DockerImage', sql.NVarChar(500), input.dockerImage || null)
.input('FileChecksumSha256', sql.Char(64), input.checksum || null)
.input('FileSizeBytes', sql.BigInt, input.fileSizeBytes || null)
.input('ChangeLog', sql.NVarChar(sql.MAX), input.changeLog || null)
.input('ReleaseDate', sql.DateTime2, input.releaseDate ? new Date(input.releaseDate) : new Date())
.query(`
INSERT dbo.PackageVersions (
PackageId, Version, FilePath, DockerImage, FileChecksumSha256,
FileSizeBytes, ChangeLog, ReleaseDate
)
OUTPUT inserted.Id
VALUES (
@PackageId, @Version, @FilePath, @DockerImage, @FileChecksumSha256,
@FileSizeBytes, @ChangeLog, @ReleaseDate
);
`);
} catch (error) {
if (error.number === 2601 || error.number === 2627) {
throw duplicatePackageError();
}
throw error;
}
const versionId = String(insertResult.recordset[0].Id);
await pool.request()
.input('PackageVersionId', sql.UniqueIdentifier, versionId)
.execute('dbo.SetLatestPackageVersion');
return versionId;
}
async function deletePackage(packageId) {
const pool = await getPool();
const transaction = new sql.Transaction(pool);
await transaction.begin();
try {
await new sql.Request(transaction)
.input('Id', sql.UniqueIdentifier, packageId)
.query(`
DELETE FROM dbo.ApplicationPackages
WHERE PackageId = @Id;
`);
const result = await new sql.Request(transaction)
.input('Id', sql.UniqueIdentifier, packageId)
.query(`
DELETE FROM dbo.Packages
OUTPUT deleted.Id
WHERE Id = @Id;
`);
await transaction.commit();
return result.recordset.length > 0;
} catch (error) {
await transaction.rollback();
throw error;
}
}
async function setLatestPackageVersion(packageVersionId) {
const pool = await getPool();
await pool.request()
.input('PackageVersionId', sql.UniqueIdentifier, packageVersionId)
.execute('dbo.SetLatestPackageVersion');
}
async function deletePackageVersion(packageVersionId) {
const pool = await getPool();
const versionResult = await pool.request()
.input('Id', sql.UniqueIdentifier, packageVersionId)
.query(`
SELECT TOP (1) PackageId
FROM dbo.PackageVersions
WHERE Id = @Id;
`);
const version = versionResult.recordset[0];
if (!version) {
return {
deleted: false,
packageId: null
};
}
await pool.request()
.input('PackageVersionId', sql.UniqueIdentifier, packageVersionId)
.execute('dbo.DeletePackageVersion');
return {
deleted: true,
packageId: String(version.PackageId)
};
}
async function createApplication(input) {
const pool = await getPool();
const transaction = new sql.Transaction(pool);
await transaction.begin();
try {
const appResult = await new sql.Request(transaction)
.input('AppCode', sql.NVarChar(100), input.appCode)
.input('AppName', sql.NVarChar(200), input.appName)
.input('AppVersion', sql.NVarChar(50), input.appVersion)
.input('Notes', sql.NVarChar(500), input.notes || null)
.input('CreatedByUserId', sql.UniqueIdentifier, input.createdByUserId)
.input('Status', sql.NVarChar(50), normalizeApplicationStatus(input.status))
.query(`
INSERT dbo.Applications (AppCode, AppName, AppVersion, Notes, CreatedByUserId, Status)
OUTPUT inserted.Id
VALUES (@AppCode, @AppName, @AppVersion, @Notes, @CreatedByUserId, @Status);
`);
const applicationId = String(appResult.recordset[0].Id);
for (const packageItem of input.packages) {
await new sql.Request(transaction)
.input('ApplicationId', sql.UniqueIdentifier, applicationId)
.input('PackageId', sql.UniqueIdentifier, packageItem.packageId)
.input('SelectedVersionId', sql.UniqueIdentifier, packageItem.selectedVersionId || null)
.query(`
INSERT dbo.ApplicationPackages (ApplicationId, PackageId, SelectedVersionId)
VALUES (@ApplicationId, @PackageId, @SelectedVersionId);
`);
}
await transaction.commit();
return applicationId;
} catch (error) {
await transaction.rollback();
if (error.number === 2601 || error.number === 2627) {
throw duplicateApplicationError();
}
throw error;
}
}
async function updateApplication(input) {
const pool = await getPool();
const transaction = new sql.Transaction(pool);
await transaction.begin();
try {
const conflictResult = await new sql.Request(transaction)
.input('ApplicationId', sql.UniqueIdentifier, input.applicationId)
.input('AppCode', sql.NVarChar(100), input.appCode)
.query(`
SELECT TOP (1) Id
FROM dbo.Applications
WHERE AppCode = @AppCode
AND Id <> @ApplicationId;
`);
if (conflictResult.recordset.length > 0) {
throw duplicateApplicationError();
}
const appResult = await new sql.Request(transaction)
.input('ApplicationId', sql.UniqueIdentifier, input.applicationId)
.input('AppCode', sql.NVarChar(100), input.appCode)
.input('AppName', sql.NVarChar(200), input.appName)
.input('AppVersion', sql.NVarChar(50), input.appVersion)
.input('Notes', sql.NVarChar(500), input.notes || null)
.input('Status', sql.NVarChar(50), normalizeApplicationStatus(input.status))
.query(`
UPDATE dbo.Applications
SET AppCode = @AppCode,
AppName = @AppName,
AppVersion = @AppVersion,
Notes = @Notes,
Status = @Status,
UpdatedAt = SYSUTCDATETIME()
OUTPUT inserted.Id
WHERE Id = @ApplicationId;
`);
if (appResult.recordset.length === 0) {
await transaction.commit();
return null;
}
await new sql.Request(transaction)
.input('ApplicationId', sql.UniqueIdentifier, input.applicationId)
.query(`
DELETE FROM dbo.ApplicationPackages
WHERE ApplicationId = @ApplicationId;
`);
for (const packageItem of input.packages) {
await new sql.Request(transaction)
.input('ApplicationId', sql.UniqueIdentifier, input.applicationId)
.input('PackageId', sql.UniqueIdentifier, packageItem.packageId)
.input('SelectedVersionId', sql.UniqueIdentifier, packageItem.selectedVersionId || null)
.query(`
INSERT dbo.ApplicationPackages (ApplicationId, PackageId, SelectedVersionId)
VALUES (@ApplicationId, @PackageId, @SelectedVersionId);
`);
}
await transaction.commit();
} catch (error) {
await transaction.rollback();
if (error.code === 'DUPLICATE_APPLICATION') {
throw error;
}
if (error.number === 2601 || error.number === 2627) {
throw duplicateApplicationError();
}
throw error;
}
return getApplicationById(input.applicationId);
}
async function updateApplicationStatus(applicationId, status) {
const pool = await getPool();
const result = await pool.request()
.input('ApplicationId', sql.UniqueIdentifier, applicationId)
.input('Status', sql.NVarChar(50), normalizeApplicationStatus(status))
.query(`
UPDATE dbo.Applications
SET Status = @Status,
UpdatedAt = SYSUTCDATETIME()
OUTPUT inserted.Id
WHERE Id = @ApplicationId;
`);
return result.recordset.length > 0;
}
async function deleteApplication(applicationId) {
const pool = await getPool();
const result = await pool.request()
.input('ApplicationId', sql.UniqueIdentifier, applicationId)
.query(`
DELETE FROM dbo.Applications
OUTPUT deleted.Id
WHERE Id = @ApplicationId;
`);
return result.recordset.length > 0;
}
async function removeApplicationPackage(applicationId, packageId) {
const pool = await getPool();
const result = await pool.request()
.input('ApplicationId', sql.UniqueIdentifier, applicationId)
.input('PackageId', sql.UniqueIdentifier, packageId)
.query(`
DELETE FROM dbo.ApplicationPackages
OUTPUT deleted.Id
WHERE ApplicationId = @ApplicationId
AND PackageId = @PackageId;
`);
return result.recordset.length > 0;
}
async function getPageData(currentUser) {
const [stats, packageRows, applications, activity] = await Promise.all([
getStats(),
listPackages(),
listApplications(),
getActivity()
]);
const packages = await attachPackageVersions(packageRows);
return {
currentUser,
stats,
packages,
applications,
activity
};
}
module.exports = {
authenticateUser,
confirmEmailToken,
createEmailConfirmationToken,
createUser,
deleteUser,
getUserById,
getPendingUserByEmail,
getRegistrationConflict,
listUsers,
updateUser,
updateUserAccess,
getPageData,
listPackages,
listApplications,
getApplicationManifest,
getPackageById,
getApplicationById,
createPackageWithVersion,
addPackageVersion,
deletePackage,
setLatestPackageVersion,
deletePackageVersion,
createApplication,
updateApplication,
updateApplicationStatus,
deleteApplication,
removeApplicationPackage
};