Files
InstallerRobot/web-server/src/repository.js
2026-05-29 14:15:06 +07:00

1591 lines
47 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;
let applicationOpenUrlSchemaPromise;
function padDatePart(value) {
return String(value).padStart(2, '0');
}
function getLocalDateInputValue(value = new Date()) {
const date = value instanceof Date ? value : new Date(value);
const safeDate = Number.isNaN(date.getTime()) ? new Date() : date;
return [
safeDate.getFullYear(),
padDatePart(safeDate.getMonth() + 1),
padDatePart(safeDate.getDate())
].join('-');
}
function getUtcDateInputValue(value) {
return [
value.getUTCFullYear(),
padDatePart(value.getUTCMonth() + 1),
padDatePart(value.getUTCDate())
].join('-');
}
function parseDateInputValue(value) {
if (typeof value !== 'string') return '';
const match = /^(\d{4})-(\d{2})-(\d{2})(?:T.*)?$/.exec(value.trim());
if (!match) return '';
const year = Number(match[1]);
const month = Number(match[2]);
const day = Number(match[3]);
const date = new Date(Date.UTC(year, month - 1, day));
if (
date.getUTCFullYear() !== year
|| date.getUTCMonth() !== month - 1
|| date.getUTCDate() !== day
) {
return '';
}
return `${match[1]}-${match[2]}-${match[3]}`;
}
function toDateInputValue(value) {
if (!value) return '';
const parsedDateInput = parseDateInputValue(value);
if (parsedDateInput) return parsedDateInput;
const date = value instanceof Date ? value : new Date(value);
if (Number.isNaN(date.getTime())) return '';
return getUtcDateInputValue(date);
}
function toReleaseDateValue(value) {
return toDateInputValue(value) || getLocalDateInputValue();
}
function toSqlDateTime2Date(value) {
const [year, month, day] = toReleaseDateValue(value).split('-').map(Number);
return new Date(Date.UTC(year, month - 1, day));
}
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;
}
async function ensureApplicationOpenUrlSchema() {
if (!applicationOpenUrlSchemaPromise) {
applicationOpenUrlSchemaPromise = getPool().then((pool) => pool.request().query(`
IF COL_LENGTH(N'dbo.Applications', N'OpenUrl') IS NULL
BEGIN
ALTER TABLE dbo.Applications
ADD OpenUrl NVARCHAR(500) NULL;
END;
EXEC(N'
CREATE OR ALTER VIEW dbo.vw_ApplicationList
AS
SELECT
a.Id,
a.AppCode,
a.AppName,
a.AppVersion,
a.Description,
a.Status,
a.Notes,
a.OpenUrl,
a.CreatedAt,
a.UpdatedAt,
a.CreatedByUserId,
u.Username AS CreatedByUsername,
COUNT_BIG(ap.Id) AS PackageCount
FROM dbo.Applications AS a
INNER JOIN dbo.Users AS u
ON u.Id = a.CreatedByUserId
LEFT JOIN dbo.ApplicationPackages AS ap
ON ap.ApplicationId = a.Id
GROUP BY
a.Id,
a.AppCode,
a.AppName,
a.AppVersion,
a.Description,
a.Status,
a.Notes,
a.OpenUrl,
a.CreatedAt,
a.UpdatedAt,
a.CreatedByUserId,
u.Username;
');
EXEC(N'
CREATE OR ALTER VIEW dbo.vw_ApplicationPackageDetails
AS
SELECT
ap.Id,
ap.ApplicationId,
a.AppCode,
a.AppName,
a.AppVersion,
a.OpenUrl AS AppOpenUrl,
ap.PackageId,
p.PackageCode,
p.PackageName,
p.PackageType,
ap.SelectedVersionId,
pv.Version AS SelectedVersion,
pv.FilePath,
pv.DockerImage,
ap.AddedAt,
ap.Notes
FROM dbo.ApplicationPackages AS ap
INNER JOIN dbo.Applications AS a
ON a.Id = ap.ApplicationId
INNER JOIN dbo.Packages AS p
ON p.Id = ap.PackageId
LEFT JOIN dbo.PackageVersions AS pv
ON pv.Id = ap.SelectedVersionId;
');
`));
}
return applicationOpenUrlSchemaPromise;
}
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 || '',
openUrl: row.OpenUrl || '',
packageCount: Number(row.PackageCount || 0),
packages: []
};
}
function mapApplicationPackageRow(row) {
return {
id: String(row.Id),
applicationId: String(row.ApplicationId),
appOpenUrl: row.AppOpenUrl || '',
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 isLoopbackHost(hostname) {
const host = String(hostname || '').toLowerCase();
return host === 'localhost' || host === '127.0.0.1' || host === '::1';
}
function toAbsoluteUrl(baseUrl, filePath) {
if (!filePath) return '';
const normalizedBaseUrl = String(baseUrl || '').replace(/\/+$/, '');
if (/^https?:\/\//i.test(filePath)) {
try {
const parsed = new URL(filePath);
if (isLoopbackHost(parsed.hostname) && parsed.pathname.startsWith('/uploads/')) {
return `${normalizedBaseUrl}${parsed.pathname}${parsed.search}`;
}
} catch {
return filePath;
}
return filePath;
}
const normalizedPath = String(filePath).startsWith('/') ? filePath : `/${filePath}`;
return `${normalizedBaseUrl}${normalizedPath}`;
}
function inferDockerPortsFromOpenUrl(openUrl) {
if (!openUrl) return [];
try {
const parsed = new URL(openUrl);
if (!isLoopbackHost(parsed.hostname) || !parsed.port) {
return [];
}
return [`${parsed.port}:${parsed.port}`];
} catch {
return [];
}
}
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() {
await ensureApplicationOpenUrlSchema();
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) {
await ensureApplicationOpenUrlSchema();
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) {
await ensureApplicationOpenUrlSchema();
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) {
await ensureApplicationOpenUrlSchema();
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, OpenUrl
FROM dbo.Applications
WHERE (CONVERT(NVARCHAR(36), Id) = @AppCode OR 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.Id, latest_version.Id) AS PackageVersionId,
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 dockerRows = componentResult.recordset.filter((row) => row.PackageType === 'docker');
const inferredDockerPorts = dockerRows.length === 1 ? inferDockerPortsFromOpenUrl(appRow.OpenUrl) : [];
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,
ports: inferredDockerPorts
};
}
return {
componentId: row.PackageCode,
type: 'deb',
installOrder,
required: true,
packageName: row.PackageCode,
version: row.Version || '',
downloadUrl: `${String(baseUrl || '').replace(/\/+$/, '')}/api/package-versions/${encodeURIComponent(String(row.PackageVersionId))}/download`,
sha256: row.FileChecksumSha256 || ''
};
});
return {
schemaVersion: '1.0',
appId: String(appRow.Id),
appName: appRow.AppName,
version: appRow.AppVersion,
openUrl: appRow.OpenUrl || '',
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 getPackageVersionDownload(packageVersionId) {
const pool = await getPool();
const result = await pool.request()
.input('Id', sql.NVarChar(100), String(packageVersionId || '').trim())
.query(`
SELECT TOP (1)
pv.Id,
pv.PackageId,
pv.Version,
pv.FilePath,
pv.FileChecksumSha256,
pv.FileSizeBytes,
p.PackageCode,
p.PackageName,
p.PackageType
FROM dbo.PackageVersions AS pv
INNER JOIN dbo.Packages AS p
ON p.Id = pv.PackageId
WHERE CONVERT(NVARCHAR(36), pv.Id) = @Id;
`);
const row = result.recordset[0];
if (!row) return null;
return {
id: String(row.Id),
packageId: String(row.PackageId),
packageCode: row.PackageCode,
packageName: row.PackageName,
packageType: row.PackageType,
version: row.Version,
filePath: row.FilePath || '',
checksum: row.FileChecksumSha256 || '',
fileSizeBytes: Number(row.FileSizeBytes || 0)
};
}
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, toSqlDateTime2Date(input.releaseDate))
.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, toSqlDateTime2Date(input.releaseDate))
.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 replacePackageVersionArtifact(input) {
const pool = await getPool();
const result = 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, toSqlDateTime2Date(input.releaseDate))
.query(`
UPDATE dbo.PackageVersions
SET FilePath = @FilePath,
DockerImage = @DockerImage,
FileChecksumSha256 = @FileChecksumSha256,
FileSizeBytes = @FileSizeBytes,
ChangeLog = COALESCE(@ChangeLog, ChangeLog),
ReleaseDate = @ReleaseDate,
UploadedAt = SYSUTCDATETIME(),
IsDeprecated = 0
OUTPUT inserted.Id
WHERE PackageId = @PackageId
AND Version = @Version;
`);
const row = result.recordset[0];
if (!row) return null;
const versionId = String(row.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) {
await ensureApplicationOpenUrlSchema();
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('OpenUrl', sql.NVarChar(500), input.openUrl || null)
.input('CreatedByUserId', sql.UniqueIdentifier, input.createdByUserId)
.input('Status', sql.NVarChar(50), normalizeApplicationStatus(input.status))
.query(`
INSERT dbo.Applications (AppCode, AppName, AppVersion, Notes, OpenUrl, CreatedByUserId, Status)
OUTPUT inserted.Id
VALUES (@AppCode, @AppName, @AppVersion, @Notes, @OpenUrl, @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) {
await ensureApplicationOpenUrlSchema();
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('OpenUrl', sql.NVarChar(500), input.openUrl || null)
.input('Status', sql.NVarChar(50), normalizeApplicationStatus(input.status))
.query(`
UPDATE dbo.Applications
SET AppCode = @AppCode,
AppName = @AppName,
AppVersion = @AppVersion,
Notes = @Notes,
OpenUrl = @OpenUrl,
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,
getPackageVersionDownload,
getPackageById,
getApplicationById,
createPackageWithVersion,
addPackageVersion,
replacePackageVersionArtifact,
deletePackage,
setLatestPackageVersion,
deletePackageVersion,
createApplication,
updateApplication,
updateApplicationStatus,
deleteApplication,
removeApplicationPackage
};