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 };