From 8360d4fc9d0d7d4190f9750a643384e0a8aadc9e Mon Sep 17 00:00:00 2001 From: confused-Techie Date: Sat, 20 Jan 2024 02:29:32 -0800 Subject: [PATCH 1/3] Migrate DB into tiny modules --- src/database.js | 1789 ----------------- src/database/_clause.js | 83 + src/database/_constants.js | 8 + src/database/_export.js | 128 ++ src/database/_utils.js | 27 + src/database/applyFeatures.js | 89 + src/database/authCheckAndDeleteStateKey.js | 56 + src/database/authStoreStateKey.js | 26 + src/database/getFeaturedPackages.js | 24 + src/database/getFeaturedThemes.js | 22 + src/database/getPackageByName.js | 51 + src/database/getPackageByNameSimple.js | 26 + src/database/getPackageCollectionByID.js | 25 + src/database/getPackageCollectionByName.js | 31 + .../getPackageVersionByNameAndVersion.js | 28 + src/database/getSortedPackages.js | 86 + src/database/getStarredPointersByUserID.js | 29 + src/database/getStarringUsersByPointer.js | 35 + src/database/getUserByID.js | 33 + src/database/getUserByName.js | 25 + src/database/getUserByNodeID.js | 33 + src/database/getUserCollectionById.js | 37 + src/database/insertNewPackage.js | 113 ++ src/database/insertNewPackageName.js | 80 + src/database/insertNewPackageVersion.js | 159 ++ src/database/insertNewUser.js | 28 + src/database/packageNameAvailability.js | 33 + src/database/removePackageByName.js | 83 + src/database/removePackageVersion.js | 78 + src/database/updateDecrementStar.js | 68 + src/database/updateIncrementStar.js | 72 + .../updatePackageDecrementDownloadByName.js | 28 + .../updatePackageIncrementDownloadByName.js | 28 + src/database/updatePackageStargazers.js | 48 + src/server.js | 2 +- 35 files changed, 1721 insertions(+), 1790 deletions(-) delete mode 100644 src/database.js create mode 100644 src/database/_clause.js create mode 100644 src/database/_constants.js create mode 100644 src/database/_export.js create mode 100644 src/database/_utils.js create mode 100644 src/database/applyFeatures.js create mode 100644 src/database/authCheckAndDeleteStateKey.js create mode 100644 src/database/authStoreStateKey.js create mode 100644 src/database/getFeaturedPackages.js create mode 100644 src/database/getFeaturedThemes.js create mode 100644 src/database/getPackageByName.js create mode 100644 src/database/getPackageByNameSimple.js create mode 100644 src/database/getPackageCollectionByID.js create mode 100644 src/database/getPackageCollectionByName.js create mode 100644 src/database/getPackageVersionByNameAndVersion.js create mode 100644 src/database/getSortedPackages.js create mode 100644 src/database/getStarredPointersByUserID.js create mode 100644 src/database/getStarringUsersByPointer.js create mode 100644 src/database/getUserByID.js create mode 100644 src/database/getUserByName.js create mode 100644 src/database/getUserByNodeID.js create mode 100644 src/database/getUserCollectionById.js create mode 100644 src/database/insertNewPackage.js create mode 100644 src/database/insertNewPackageName.js create mode 100644 src/database/insertNewPackageVersion.js create mode 100644 src/database/insertNewUser.js create mode 100644 src/database/packageNameAvailability.js create mode 100644 src/database/removePackageByName.js create mode 100644 src/database/removePackageVersion.js create mode 100644 src/database/updateDecrementStar.js create mode 100644 src/database/updateIncrementStar.js create mode 100644 src/database/updatePackageDecrementDownloadByName.js create mode 100644 src/database/updatePackageIncrementDownloadByName.js create mode 100644 src/database/updatePackageStargazers.js diff --git a/src/database.js b/src/database.js deleted file mode 100644 index 6ff5d6c3..00000000 --- a/src/database.js +++ /dev/null @@ -1,1789 +0,0 @@ -/** - * @module database - * @desc Provides an interface of a large collection of functions to interact - * with and retrieve data from the cloud hosted database instance. - */ - -const fs = require("fs"); -const postgres = require("postgres"); -const storage = require("./storage.js"); -const logger = require("./logger.js"); -const { - DB_HOST, - DB_USER, - DB_PASS, - DB_DB, - DB_PORT, - DB_SSL_CERT, - paginated_amount, -} = require("./config.js").getConfig(); - -const defaultEngine = { atom: "*" }; -const defaultLicense = "NONE"; - -let sqlStorage; // SQL object, to interact with the DB. -// It is set after the first call with logical nullish assignment -// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Logical_nullish_assignment - -/** - * @function setupSQL - * @desc Initialize the connection to the PostgreSQL database. - * In order to avoid the initialization multiple times, - * the logical nullish assignment (??=) can be used in the caller. - * Exceptions thrown here should be caught and handled in the caller. - * @returns {object} PostgreSQL connection object. - */ -function setupSQL() { - return process.env.PULSAR_STATUS === "dev" && process.env.MOCK_DB !== "false" - ? postgres({ - host: DB_HOST, - username: DB_USER, - database: DB_DB, - port: DB_PORT, - }) - : postgres({ - host: DB_HOST, - username: DB_USER, - password: DB_PASS, - database: DB_DB, - port: DB_PORT, - ssl: { - rejectUnauthorized: true, - ca: fs.readFileSync(DB_SSL_CERT).toString(), - }, - }); -} - -/** - * @function shutdownSQL - * @desc Ensures any Database connection is properly, and safely closed before exiting. - */ -async function shutdownSQL() { - if (sqlStorage !== undefined) { - sqlStorage.end(); - logger.generic(1, "SQL Server Shutdown!"); - } -} - -/** - * @async - * @function packageNameAvailability - * @desc Determines if a name is ready to be used for a new package. Useful in the stage of the publication - * of a new package where checking if the package exists is not enough because a name could be not - * available if a deleted package was using it in the past. - * Useful also to check if a name is available for the renaming of a published package. - * This function simply checks if the provided name is present in "names" table. - * @param {string} name - The candidate name for a new package. - * @returns {object} A Server Status Object. - */ -async function packageNameAvailability(name) { - try { - sqlStorage ??= setupSQL(); - - const command = await sqlStorage` - SELECT name FROM names - WHERE name = ${name}; - `; - - return command.count === 0 - ? { - ok: true, - content: `${name} is available to be used for a new package.`, - } - : { - ok: false, - content: `${name} is not available to be used for a new package.`, - short: "not_found", - }; - } catch (err) { - return { - ok: false, - content: "Generic Error", - short: "server_error", - error: err, - }; - } -} - -/** - * @async - * @function insertNewPackage - * @desc Insert a new package inside the DB taking a `Server Object Full` as argument. - * @param {object} pack - The `Server Object Full` package. - * @returns {object} A Server Status Object. - */ -async function insertNewPackage(pack) { - sqlStorage ??= setupSQL(); - - // Since this operation involves multiple queries, we perform a - // PostgreSQL transaction executing a callback on begin(). - // All data is committed into the database only if no errors occur. - return await sqlStorage - .begin(async (sqlTrans) => { - const packageType = - typeof pack.metadata.theme === "string" && - pack.metadata.theme.match(/^(?:syntax|ui)$/i) !== null - ? "theme" - : "package"; - - // Populate packages table - let pointer = null; - let insertNewPack = {}; - try { - // No need to specify downloads and stargazers. They default at 0 on creation. - // TODO: data column deprecated; to be removed - insertNewPack = await sqlTrans` - INSERT INTO packages (name, creation_method, data, package_type, owner) - VALUES (${pack.name}, ${pack.creation_method}, ${pack}, ${packageType}, ${pack.owner}) - RETURNING pointer; - `; - } catch (e) { - throw `A constraint has been violated while inserting ${ - pack.name - } in packages table: ${e.toString()}`; - } - - if (!insertNewPack?.count) { - throw `Cannot insert ${pack.name} in packages table`; - } - - // Retrieve package pointer - pointer = insertNewPack[0].pointer; - - // Populate names table - let insertNewName = {}; - try { - insertNewName = await sqlTrans` - INSERT INTO names (name, pointer) - VALUES (${pack.name}, ${pointer}) - RETURNING name; - `; - } catch (e) { - throw `A constraint has been violated while inserting ${pack.name} in names table`; - } - - if (!insertNewName?.count) { - throw `Cannot insert ${pack.name} in names table`; - } - - // Populate versions table - let versionCount = 0; - const pv = pack.versions; - // TODO: status column deprecated; to be removed. - const status = "published"; - for (const ver of Object.keys(pv)) { - // Since many packages don't define an engine field, - // we will do it for them if not present, - // following suit with what Atom internal packages do. - const engine = pv[ver].engines ?? defaultEngine; - - // It's common practice for packages to not specify license, - // therefore set it as NONE if undefined. - const license = pv[ver].license ?? defaultLicense; - - let insertNewVersion = {}; - try { - insertNewVersion = await sqlTrans` - INSERT INTO versions (package, status, semver, license, engine, meta) - VALUES (${pointer}, ${status}, ${ver}, ${license}, ${engine}, ${pv[ver]}) - RETURNING id; - `; - } catch (e) { - throw `A constraint is violated while inserting ${ver} version for ${pack.name} in versions table`; - } - - if (!insertNewVersion?.count) { - throw `Cannot insert ${ver} version for ${pack.name} package in versions table`; - } - versionCount++; - } - - if (versionCount === 0) { - throw `${pack.name} package does not contain any version.`; - } - - return { ok: true, content: pointer }; - }) - .catch((err) => { - return typeof err === "string" - ? { ok: false, content: err, short: "server_error" } - : { - ok: false, - content: `A generic error occurred while inserting ${pack.name} package`, - short: "server_error", - error: err, - }; - }); -} - -/** - * @async - * @function insertNewPackageVersion - * @desc Adds a new package version to the db. - * @param {object} packJSON - A full `package.json` file for the wanted version. - * @param {string|null} oldName - If provided, the old name to be replaced for the renaming of the package. - * @returns {object} A server status object. - */ -async function insertNewPackageVersion(packJSON, oldName = null) { - sqlStorage ??= setupSQL(); - - // We are expected to receive a standard `package.json` file. - // Note that, if oldName is provided, here we can be sure oldName !== packJSON.name - // because the comparison has been already done in postPackagesVersion() - return await sqlStorage - .begin(async (sqlTrans) => { - const rename = typeof oldName === "string"; - - // On renaming, search the package pointer using the oldName, - // otherwise use the name in the package object directly. - let packName = rename ? oldName : packJSON.name; - - const pack = await getPackageByName(packName); - - if (!pack.ok) { - return pack; - } - - const pointer = pack.content.pointer; - - if (packJSON.owner !== pack.owner) { - // The package owner has changed. Whether or not this is plausible in - // the real world, it's a good idea to handle it here. - let updateOwner = {}; - let ownerUpdateFailed = false; - try { - updateOwner = await sqlTrans` - UPDATE PACKAGES - SET owner = ${packJSON.owner} - WHERE pointer = ${pointer} - RETURNING owner; - `; - } catch (e) { - // There aren't constraints on the `owner` field, so if this were to - // fail, it wouldn't be clear why. But we're handling it anyway! - ownerUpdateFailed = true; - } - if (!updateOwner?.count || ownerUpdateFailed) { - throw `Unable to update the package owner to ${packJSON.owner}.`; - } - } - - if (rename) { - // The flow for renaming the package. - // Before inserting the new name, we try to update it into the `packages` table - // since we want that column to contain the current name. - let updateNewName = {}; - try { - updateNewName = await sqlTrans` - UPDATE packages - SET name = ${packJSON.name} - WHERE pointer = ${pointer} - RETURNING name; - `; - } catch (e) { - throw `Unable to update the package name. ${packJSON.name} is already used by another package.`; - } - - if (!updateNewName?.count) { - throw `Unable to update the package name.`; - } - - // Now we can finally insert the new name inside the `names` table. - let newInsertedName = {}; - try { - newInsertedName = await sqlTrans` - INSERT INTO names - (name, pointer) VALUES - (${packJSON.name}, ${pointer}) - RETURNING name; - `; - } catch (e) { - throw `Unable to add the new name: ${packJSON.name} is already used.`; - } - - if (!newInsertedName?.count) { - throw `Unable to add the new name: ${packJSON.name}`; - } - - // After renaming, we can use packJSON.name as the package name. - packName = packJSON.name; - } - - // We used to check if the new version was higher than the latest, but this is - // too cumbersome to do and the publisher has the responsibility to push an - // higher version to be signaled in Pulsar for the update, so we just try to - // insert whatever we got. - // The only requirement is that the provided semver is not already present - // in the database for the targeted package. - - const license = packJSON.metadata.license ?? defaultLicense; - const engine = packJSON.metadata.engines ?? defaultEngine; - - let addVer = {}; - try { - // TODO: status column deprecated; to be removed - addVer = await sqlTrans` - INSERT INTO versions (package, status, semver, license, engine, meta) - VALUES(${pointer}, 'published', ${packJSON.metadata.version}, ${license}, ${engine}, ${packJSON.metadata}) - RETURNING semver, status; - `; - } catch (e) { - // This occurs when the (package, semver) unique constraint is violated. - throw `Not allowed to publish a version already present for ${packName}`; - } - - if (!addVer?.count) { - throw `Unable to create a new version for ${packName}`; - } - - // Now to update the data field for the package, to update the readme and - // latest version - let addPackMeta = {}; - try { - addPackMeta = await sqlTrans` - UPDATE packages - SET data = ${packJSON} - WHERE pointer = ${pointer} - RETURNING name; - `; - } catch (e) { - throw `Unable to update the package's metadata for ${packName}`; - } - - if (!addPackMeta?.count) { - throw `Failed to update the package's metadata for ${packName}`; - } - - return { - ok: true, - content: `Successfully added new version: ${packName}@${packJSON.metadata.version}`, - }; - }) - .catch((err) => { - return typeof err === "string" - ? { ok: false, content: err, short: "server_error" } - : { - ok: false, - content: `A generic error occured while inserting the new package version ${packJSON.name}`, - short: "server_error", - error: err, - }; - }); -} - -/** - * @async - * @function applyFeatures - * @desc Takes a Feature Object, and applies it's data to the appropriate package - * @param {object} featureObj - The object containing all feature declarations. - * @param {boolean} featureObj.hasGrammar - If present, and true, means this - * package version provides a grammar. - * @param {boolean} featureObj.hasSnippets - If present, and true, means this - * package version provides snippets. - * @param {string[]} featureObj.supportedLanguages - If present, defines an array - * of strings specifying the extensions, or file names supported by this grammar. - * @param {string} packName - The name of the package to be affected. - * @param {string} packVersion - The regular semver version of the package - */ -async function applyFeatures(featureObj, packName, packVersion) { - try { - sqlStorage ??= setupSQL(); - - const packID = await getPackageByNameSimple(packName); - - if (!packID.ok) { - return { - ok: false, - content: `Unable to find the pointer of ${packName}`, - short: "not_found", - }; - } - - const pointer = packID.content.pointer; - - if (featureObj.hasSnippets) { - const addSnippetCommand = await sqlStorage` - UPDATE versions - SET has_snippets = TRUE - WHERE package = ${pointer} AND semver = ${packVersion}; - `; - - if (addSnippetCommand.count === 0) { - return { - ok: false, - content: `Unable to set 'has_snippets' flag to true for ${packName}`, - short: "server_error", - }; - } - } - - if (featureObj.hasGrammar) { - const addGrammarCommand = await sqlStorage` - UPDATE versions - SET has_grammar = TRUE - WHERE package = ${pointer} AND semver = ${packVersion}; - `; - - if (addGrammarCommand.count === 0) { - return { - ok: false, - content: `Unable to set 'has_grammar' flag to true for ${packName}`, - short: "server_error", - }; - } - } - - if ( - Array.isArray(featureObj.supportedLanguages) && - featureObj.supportedLanguages.length > 0 - ) { - // Add the supported languages - const addLangCommand = await sqlStorage` - UPDATE versions - SET supported_languages = ${featureObj.supportedLanguages} - WHERE package = ${pointer} AND semver = ${packVersion}; - `; - - if (addLangCommand.count === 0) { - return { - ok: false, - content: `Unable to add supportedLanguages to ${packName}`, - short: "server_error", - }; - } - } - - return { - ok: true, - }; - } catch (err) { - return { - ok: false, - content: "Generic Error", - short: "server_error", - error: err.toString(), // TODO this must be implemented within the logger - // seems the custom PostgreSQL error object doesn't have native to string methods - // or otherwise logging the error within an object doesn't trigger the toString method - }; - } -} - -/** - * @async - * @function insertNewPackageName - * @desc Insert a new package name with the same pointer as the old name. - * This essentially renames an existing package. - * @param {string} newName - The new name to create in the DB. - * @param {string} oldName - The original name of which to use the pointer of. - * @returns {object} A server status object. - * @todo This function has been left only for testing purpose since it has been integrated - * inside insertNewPackageVersion, so it should be removed when we can test the rename process - * directly on the endpoint. - */ -async function insertNewPackageName(newName, oldName) { - sqlStorage ??= setupSQL(); - - return await sqlStorage - .begin(async (sqlTrans) => { - // Retrieve the package pointer - const packID = await getPackageByNameSimple(oldName); - - if (!packID.ok) { - // Return Not Found - return { - ok: false, - content: `Unable to find the original pointer of ${oldName}`, - short: "not_found", - }; - } - - const pointer = packID.content.pointer; - - // Before inserting the new name, we try to update it into the `packages` table - // since we want that column to contain the current name. - try { - const updateNewName = await sqlTrans` - UPDATE packages - SET name = ${newName} - WHERE pointer = ${pointer} - RETURNING name; - `; - - if (updateNewName.count === 0) { - throw `Unable to update the package name.`; - } - } catch (e) { - throw `Unable to update the package name. ${newName} is already used by another package.`; - } - - // Now we can finally insert the new name inside the `names` table. - try { - const newInsertedName = await sqlTrans` - INSERT INTO names - (name, pointer) VALUES - (${newName}, ${pointer}) - RETURNING name; - `; - - if (newInsertedName.count === 0) { - throw `Unable to add the new name: ${newName}`; - } - } catch (e) { - throw `Unable to add the new name: ${newName} is already used.`; - } - - return { ok: true, content: `Successfully inserted ${newName}.` }; - }) - .catch((err) => { - return typeof err === "string" - ? { ok: false, content: err, short: "Server Error" } - : { - ok: false, - content: `A generic error occurred while inserting the new package name ${newName}`, - short: "server_error", - error: err, - }; - }); -} - -/** - * @async - * @function insertNewUser - * @desc Insert a new user into the database. - * @param {string} username - Username of the user. - * @param {object} id - Identifier code of the user. - * @param {object} avatar - The avatar of the user. - * @returns {object} A server status object. - */ -async function insertNewUser(username, id, avatar) { - try { - sqlStorage ??= setupSQL(); - - const command = await sqlStorage` - INSERT INTO users (username, node_id, avatar) - VALUES (${username}, ${id}, ${avatar}) - RETURNING *; - `; - - return command.count !== 0 - ? { ok: true, content: command[0] } - : { - ok: false, - content: `Unable to create user: ${username}`, - short: "Server Error", - }; - } catch (err) { - return { - ok: false, - content: "Generic Error", - short: "server_error", - error: err, - }; - } -} - -/** - * @async - * @function getPackageByName - * @desc Takes a package name and returns the raw SQL package with all its versions. - * This module is also used to get the data to be sent to utils.constructPackageObjectFull() - * in order to convert the query result in Package Object Full format. - * In that case it's recommended to set the user flag as true for security reasons. - * @param {string} name - The name of the package. - * @param {bool} user - Whether the packages has to be exposed outside or not. - * If true, all sensitive data like primary and foreign keys are not selected. - * Even if the keys are ignored by utils.constructPackageObjectFull(), it's still - * safe to not inclue them in case, by mistake, we publish the return of this module. - * @returns {object} A server status object. - */ -async function getPackageByName(name, user = false) { - try { - sqlStorage ??= setupSQL(); - - const command = await sqlStorage` - SELECT - ${ - user ? sqlStorage`` : sqlStorage`p.pointer,` - } p.name, p.created, p.updated, p.creation_method, p.downloads, p.data, p.owner, - (p.stargazers_count + p.original_stargazers) AS stargazers_count, - JSONB_AGG( - JSON_BUILD_OBJECT( - ${ - user - ? sqlStorage`` - : sqlStorage`'id', v.id, 'package', v.package,` - } 'semver', v.semver, 'license', v.license, 'engine', v.engine, 'meta', v.meta, - 'hasGrammar', v.has_grammar, 'hasSnippets', v.has_snippets, - 'supportedLanguages', v.supported_languages - ) - ORDER BY v.semver_v1 DESC, v.semver_v2 DESC, v.semver_v3 DESC, v.created DESC - ) AS versions - FROM packages AS p - INNER JOIN names AS n ON (p.pointer = n.pointer AND n.name = ${name}) - INNER JOIN versions AS v ON (p.pointer = v.package AND v.deleted IS FALSE) - GROUP BY p.pointer; - `; - - return command.count !== 0 - ? { ok: true, content: command[0] } - : { - ok: false, - content: `package ${name} not found.`, - short: "not_found", - }; - } catch (err) { - return { - ok: false, - content: err, - short: "server_error", - }; - } -} - -/** - * @async - * @function getPackageByNameSimple - * @desc Internal util used by other functions in this module to get the package row by the given name. - * It's like getPackageByName(), but with a simple and faster query. - * @param {string} name - The name of the package. - * @returns {object} A server status object. - */ -async function getPackageByNameSimple(name) { - try { - sqlStorage ??= setupSQL(); - - const command = await sqlStorage` - SELECT pointer FROM names - WHERE name = ${name}; - `; - - return command.count !== 0 - ? { ok: true, content: command[0] } - : { - ok: false, - content: `Package ${name} not found.`, - short: "not_found", - }; - } catch (err) { - return { - ok: false, - content: "Generic Error", - short: "server_error", - error: err, - }; - } -} - -/** - * @async - * @function getPackageVersionByNameAndVersion - * @desc Uses the name of a package and it's version to return the version info. - * @param {string} name - The name of the package to query. - * @param {string} version - The version of the package to query. - * @returns {object} A server status object. - */ -async function getPackageVersionByNameAndVersion(name, version) { - try { - sqlStorage ??= setupSQL(); - - const command = await sqlStorage` - SELECT v.semver, v.license, v.engine, v.meta - FROM packages AS p - INNER JOIN names AS n ON (p.pointer = n.pointer AND n.name = ${name}) - INNER JOIN versions AS v ON (p.pointer = v.package AND v.semver = ${version} AND v.deleted IS FALSE); - `; - - return command.count !== 0 - ? { ok: true, content: command[0] } - : { - ok: false, - content: `Package ${name} and Version ${version} not found.`, - short: "not_found", - }; - } catch (err) { - return { - ok: false, - content: "Generic Error", - short: "server_error", - error: err, - }; - } -} - -/** - * @async - * @function getPackageCollectionByName - * @desc Takes a package name array, and returns an array of the package objects. - * You must ensure that the packArray passed is compatible. This function does not coerce compatibility. - * @param {string[]} packArray - An array of package name strings. - * @returns {object} A server status object. - */ -async function getPackageCollectionByName(packArray) { - try { - sqlStorage ??= setupSQL(); - - // Since this function is invoked by getFeaturedThemes and getFeaturedPackages - // which process the returned content with constructPackageObjectShort(), - // we select only the needed columns. - const command = await sqlStorage` - SELECT DISTINCT ON (p.name) p.name, v.semver, p.downloads, p.owner, - (p.stargazers_count + p.original_stargazers) AS stargazers_count, p.data - FROM packages AS p - INNER JOIN names AS n ON (p.pointer = n.pointer AND n.name IN ${sqlStorage( - packArray - )}) - INNER JOIN versions AS v ON (p.pointer = v.package AND v.deleted IS FALSE) - ORDER BY p.name, v.semver_v1 DESC, v.semver_v2 DESC, v.semver_v3 DESC, v.created DESC; - `; - - return command.count !== 0 - ? { ok: true, content: command } - : { ok: false, content: "No packages found.", short: "not_found" }; - } catch (err) { - return { - ok: false, - content: "Generic Error", - short: "server_error", - error: err, - }; - } -} - -/** - * @async - * @function getPackageCollectionByID - * @desc Takes a package pointer array, and returns an array of the package objects. - * @param {int[]} packArray - An array of package id. - * @returns {object} A server status object. - */ -async function getPackageCollectionByID(packArray) { - try { - sqlStorage ??= setupSQL(); - - const command = await sqlStorage` - SELECT DISTINCT ON (p.name) p.name, v.semver, p.downloads, - (p.stargazers_count + p.original_stargazers) AS stargazers_count, p.data - FROM packages AS p - INNER JOIN versions AS v ON (p.pointer = v.package AND v.deleted IS FALSE) - WHERE pointer IN ${sqlStorage(packArray)} - ORDER BY p.name, v.semver_v1 DESC, v.semver_v2 DESC, v.semver_v3 DESC, v.created DESC; - `; - - return command.count !== 0 - ? { ok: true, content: command } - : { ok: false, content: "No packages found.", short: "Not Found" }; - } catch (err) { - return { - ok: false, - content: "Generic Error", - short: "server_error", - error: err, - }; - } -} - -/** - * @async - * @function updatePackageStargazers - * @description Internal util that uses the package name (or pointer if provided) to update its stargazers count. - * @param {string} name - The package name. - * @param {string} pointer - The package id (if given, the search by name is skipped). - * @returns {object} The effected server status object. - */ -async function updatePackageStargazers(name, pointer = null) { - try { - sqlStorage ??= setupSQL(); - - if (pointer === null) { - const packID = await getPackageByNameSimple(name); - - if (!packID.ok) { - return packID; - } - - pointer = packID.content.pointer; - } - - const countStars = await sqlStorage` - SELECT COUNT(*) AS stars - FROM stars - WHERE package = ${pointer}; - `; - - const starCount = countStars.count !== 0 ? countStars[0].stars : 0; - - const updateStar = await sqlStorage` - UPDATE packages - SET stargazers_count = ${starCount} - WHERE pointer = ${pointer} - RETURNING name, (stargazers_count + original_stargazers) AS stargazers_count; - `; - - return updateStar.count !== 0 - ? { ok: true, content: updateStar[0] } - : { - ok: false, - content: "Unable to Update Package Stargazers", - short: "server_error", - }; - } catch (err) { - return { - ok: false, - content: "Generic Error", - short: "server_error", - error: err, - }; - } -} - -/** - * @async - * @function updatePackageIncrementDownloadByName - * @description Uses the package name to increment the download count by one. - * @param {string} name - The package name. - * @returns {object} The modified server status object. - */ -async function updatePackageIncrementDownloadByName(name) { - try { - sqlStorage ??= setupSQL(); - - const command = await sqlStorage` - UPDATE packages AS p - SET downloads = p.downloads + 1 - FROM names AS n - WHERE n.pointer = p.pointer AND n.name = ${name} - RETURNING p.name, p.downloads; - `; - - return command.count !== 0 - ? { ok: true, content: command[0] } - : { - ok: false, - content: "Unable to Update Package Download", - short: "Server Error", - }; - } catch (err) { - return { - ok: false, - content: "Generic Error", - short: "server_error", - error: err, - }; - } -} - -/** - * @async - * @function updatePackageDecrementDownloadByName - * @description Uses the package name to decrement the download count by one. - * @param {string} name - The package name. - * @returns {object} The modified server status object. - */ -async function updatePackageDecrementDownloadByName(name) { - try { - sqlStorage ??= setupSQL(); - - const command = await sqlStorage` - UPDATE packages AS p - SET downloads = GREATEST(p.downloads - 1, 0) - FROM names AS n - WHERE n.pointer = p.pointer AND n.name = ${name} - RETURNING p.name, p.downloads; - `; - - return command.count !== 0 - ? { ok: true, content: command[0] } - : { - ok: false, - content: "Unable to decrement Package Download Count", - short: "server_error", - }; - } catch (err) { - return { - ok: false, - content: "Generic Error", - short: "server_error", - error: err, - }; - } -} - -/** - * @async - * @function removePackageByName - * @description Given a package name, removes its record alongside its names, versions, stars. - * @param {string} name - The package name. - * @param {boolean} exterminate - A flag that if true will totally remove the package. - * Including the normally reserved name. Should never be used in production, enables - * a supply chain vulnerability. - * @returns {object} A server status object. - */ -async function removePackageByName(name, exterminate = false) { - sqlStorage ??= setupSQL(); - - return await sqlStorage - .begin(async (sqlTrans) => { - // Retrieve the package pointer - const packID = await getPackageByNameSimple(name); - - if (!packID.ok) { - // The package does not exist, but we return ok since it's like - // it has been deleted. - return { ok: true, content: `${name} package does not exist.` }; - } - - const pointer = packID.content.pointer; - - // Remove versions of the package - const commandVers = await sqlTrans` - DELETE FROM versions - WHERE package = ${pointer} - RETURNING semver; - `; - - if (commandVers.count === 0) { - throw `Failed to delete any versions for: ${name}`; - } - - // Remove stars assigned to the package - await sqlTrans` - DELETE FROM stars - WHERE package = ${pointer} - RETURNING userid; - `; - - const commandPack = await sqlTrans` - DELETE FROM packages - WHERE pointer = ${pointer} - RETURNING name; - `; - - if (commandPack.count === 0) { - // nothing was returning, the delete probably failed - throw `Failed to Delete Package for: ${name}`; - } - - if (commandPack[0].name !== name) { - throw `Attempted to delete ${commandPack[0].name} rather than ${name}`; - } - - if (exterminate) { - const commandName = await sqlTrans` - DELETE FROM names - WHERE pointer = ${pointer} - `; // We can't return name here, since it's set to null on package deletion - } - - return { ok: true, content: `Successfully Deleted Package: ${name}` }; - }) - .catch((err) => { - return typeof err === "string" - ? { ok: false, content: err, short: "server_error" } - : { - ok: false, - content: `A generic error occurred while inserting ${name} package`, - short: "server_error", - error: err, - }; - }); -} - -/** - * @async - * @function removePackageVersion - * @description Mark a version of a specific package as deleted. This does not delete the record, - * just mark the boolean deleted flag as true, but only if one published version remains available. - * This also makes sure that a new latest version is selected in case the previous one is removed. - * @param {string} packName - The package name. - * @param {string} semVer - The version to remove. - * @returns {object} A server status object. - */ -async function removePackageVersion(packName, semVer) { - sqlStorage ??= setupSQL(); - - return await sqlStorage - .begin(async (sqlTrans) => { - // Retrieve the package pointer - const packID = await getPackageByNameSimple(packName); - - if (!packID.ok) { - // Return Not Found - return { - ok: false, - content: `Unable to find the pointer of ${packName}`, - short: "not_found", - }; - } - - const pointer = packID.content.pointer; - - // Retrieve all non-removed versions to count them - const getVersions = await sqlTrans` - SELECT id - FROM versions - WHERE package = ${pointer} AND deleted IS FALSE; - `; - - const versionCount = getVersions.count; - if (versionCount < 2) { - throw `${packName} package has less than 2 published versions: deletion not allowed.`; - } - - // We can remove the targeted semVer. - const markDeletedVersion = await sqlTrans` - UPDATE versions - SET DELETED = TRUE - WHERE package = ${pointer} AND semver = ${semVer} - RETURNING id; - `; - - if (markDeletedVersion.count === 0) { - // Do not use throw here because we specify Not Found reason. - return { - ok: false, - content: `Unable to remove ${semVer} version of ${packName} package.`, - short: "not_found", - }; - } - - return { - ok: true, - content: `Successfully removed ${semVer} version of ${packName} package.`, - }; - }) - .catch((err) => { - return typeof err === "string" - ? { ok: false, content: err, short: "server_error" } - : { - ok: false, - content: `A generic error occurred while inserting ${packName} package`, - short: "server_error", - error: err, - }; - }); -} - -/** - * @async - * @function getFeaturedPackages - * @desc Collects the hardcoded featured packages array from the storage.js - * module. Then uses this.getPackageCollectionByName to retrieve details of the - * package. - * @returns {object} A server status object. - */ -async function getFeaturedPackages() { - let featuredArray = await storage.getFeaturedPackages(); - - if (!featuredArray.ok) { - return featuredArray; - } - - return await getPackageCollectionByName(featuredArray.content); -} - -/** - * @async - * @function getFeaturedThemes - * @desc Collects the hardcoded featured themes array from the storage.js module. - * Then uses this.getPackageCollectionByName to retrieve details of the package. - * @returns {object} A server status object. - */ -async function getFeaturedThemes() { - let featuredThemeArray = await storage.getFeaturedThemes(); - - if (!featuredThemeArray.ok) { - return featuredThemeArray; - } - - return await getPackageCollectionByName(featuredThemeArray.content); -} - -/** - * @async - * @function getUserByName - * @description Get a users details providing their username. - * @param {string} username - User name string. - * @returns {object} A server status object. - */ -async function getUserByName(username) { - try { - sqlStorage ??= setupSQL(); - - const command = await sqlStorage` - SELECT * FROM users - WHERE username = ${username}; - `; - - return command.count !== 0 - ? { ok: true, content: command[0] } - : { - ok: false, - content: `Unable to query for user: ${username}`, - short: "not_found", - }; - } catch (err) { - return { - ok: false, - content: err, - short: "server_error", - }; - } -} - -/** - * @async - * @function getUserByNodeID - * @description Get user details providing their Node ID. - * @param {string} id - Users Node ID. - * @returns {object} A server status object. - */ -async function getUserByNodeID(id) { - try { - sqlStorage ??= setupSQL(); - - const command = await sqlStorage` - SELECT * FROM users - WHERE node_id = ${id}; - `; - - if (command.count === 0) { - return { - ok: false, - content: `Unable to get User By NODE_ID: ${id}`, - short: "not_found", - }; - } - - return command.count !== 0 - ? { ok: true, content: command[0] } - : { - ok: false, - content: `Unable to get User By NODE_ID: ${id}`, - short: "server_error", - }; - } catch (err) { - return { - ok: false, - content: "Generic Error", - short: "server_error", - error: err, - }; - } -} - -/** - * @async - * @function getUserByID - * @desc Get user details providing their ID. - * @param {int} id - User ID - * @returns {object} A Server status Object. - */ -async function getUserByID(id) { - try { - sqlStorage ??= setupSQL(); - - const command = await sqlStorage` - SELECT * FROM users - WHERE id = ${id}; - `; - - if (command.count === 0) { - return { - ok: false, - content: `Unable to get user by ID: ${id}`, - short: "server_error", - }; - } - - return command.count !== 0 - ? { ok: true, content: command[0] } - : { - ok: false, - content: `Unable to get user by ID: ${id}`, - short: "server_error", - }; - } catch (err) { - return { - ok: false, - content: "Generic Error", - short: "server_error", - error: err, - }; - } -} - -/** - * @async - * @function updateIncrementStar - * @description Register the star given by a user to a package. - * @param {int} user - A User Object that should star the package. - * @param {string} pack - Package name that get the new star. - * @returns {object} A server status object. - */ -async function updateIncrementStar(user, pack) { - try { - sqlStorage ??= setupSQL(); - - const packID = await getPackageByNameSimple(pack); - - if (!packID.ok) { - return { - ok: false, - content: `Unable to find package ${pack} to star.`, - short: "not_found", - }; - } - - const pointer = packID.content.pointer; - - try { - const commandStar = await sqlStorage` - INSERT INTO stars - (package, userid) VALUES - (${pointer}, ${user.id}) - RETURNING *; - `; - - // Now we expect to get our data right back, and can check the - // validity to know if this happened successfully or not. - if ( - pointer !== commandStar[0].package || - user.id !== commandStar[0].userid - ) { - return { - ok: false, - content: `Failed to Star the Package`, - short: "server_error", - }; - } - - // Now update the stargazers count into the packages table - const updatePack = await updatePackageStargazers(pack, pointer); - - if (!updatePack.ok) { - return updatePack; - } - - return { - ok: true, - content: `Package Successfully Starred`, - }; - } catch (e) { - // TODO: While the comment below is accurate - // It's also worth noting that this catch will return success - // If the starring user does not exist. Resulting in a false positive - // Catch the primary key violation on (package, userid), - // Sinche the package is already starred by the user, we return ok. - return { - ok: true, - content: `Package Already Starred`, - }; - } - } catch (err) { - return { - ok: false, - content: "Generic Error", - short: "server_error", - error: err, - }; - } -} - -/** - * @async - * @function updateDecrementStar - * @description Register the removal of the star on a package by a user. - * @param {int} user - User Object who remove the star. - * @param {string} pack - Package name that get the star removed. - * @returns {object} A server status object. - */ -async function updateDecrementStar(user, pack) { - try { - sqlStorage ??= setupSQL(); - - const packID = await getPackageByNameSimple(pack); - - if (!packID.ok) { - return { - ok: false, - content: `Unable to find package ${pack} to unstar.`, - short: "not_found", - }; - } - - const pointer = packID.content.pointer; - - const commandUnstar = await sqlStorage` - DELETE FROM stars - WHERE (package = ${pointer}) AND (userid = ${user.id}) - RETURNING *; - `; - - if (commandUnstar.count === 0) { - // We know user and package exist both, so the fail is because - // the star was already missing, - // The user expects its star is not given, so we return ok. - return { - ok: true, - content: "The Star is Already Missing", - }; - } - - // If the return does not match our input, it failed. - if ( - user.id !== commandUnstar[0].userid || - pointer !== commandUnstar[0].package - ) { - return { - ok: false, - content: "Failed to Unstar the Package", - short: "server_error", - }; - } - - // Now update the stargazers count into the packages table - const updatePack = await updatePackageStargazers(pack, pointer); - - if (!updatePack.ok) { - return updatePack; - } - - return { - ok: true, - content: "Package Successfully Unstarred", - }; - } catch (err) { - return { - ok: false, - content: "Generic Error", - short: "server_error", - error: err, - }; - } -} - -/** - * @async - * @function getStarredPointersByUserID - * @description Get all packages which the user gave the star. - * The result of this function should not be returned to the user because it contains pointers UUID. - * @param {int} userid - ID of the user. - * @returns {object} A server status object. - */ -async function getStarredPointersByUserID(userid) { - try { - sqlStorage ??= setupSQL(); - - const command = await sqlStorage` - SELECT ARRAY ( - SELECT package FROM stars WHERE userid = ${userid} - ); - `; - - // It is likely safe to assume that if nothing matches the userid, - // then the user hasn't given any star. So instead of server error - // here we will non-traditionally return an empty array. - const packArray = - command.count !== 0 && Array.isArray(command[0].array) - ? command[0].array - : []; - - return { ok: true, content: packArray }; - } catch (err) { - return { - ok: false, - content: "Generic Error", - short: "server_error", - error: err, - }; - } -} - -/** - * @async - * @function getStarringUsersByPointer - * @description Use the pointer of a package to collect all users that have starred it. - * @param {string} pointer - The ID of the package. - * @returns {object} A server status object. - */ -async function getStarringUsersByPointer(pointer) { - try { - sqlStorage ??= setupSQL(); - - const command = await sqlStorage` - SELECT ARRAY ( - SELECT userid FROM stars WHERE package = ${pointer.pointer} - ); - `; - - let userArray = command[0].array; - - if (command.count === 0) { - // It is likely safe to assume that if nothing matches the packagepointer, - // then the package pointer has no stars. So instead of server error - // here we will non-traditionally return an empty array. - logger.generic( - 3, - `No Stars for ${pointer} found, assuming 0 star value.` - ); - userArray = []; - } - - return { ok: true, content: userArray }; - } catch (err) { - return { - ok: false, - content: "Generic Error", - short: "server_error", - error: err, - }; - } -} - -/** - * @async - * @function getUserCollectionById - * @description Returns an array of Users and their associated data via the ids. - * @param {array} ids - The IDs of users to collect the data of. - * @returns {object} A server status object with the array of users collected. - */ -async function getUserCollectionById(ids) { - let userArray = []; - - for (let i = 0; i < ids.length; i++) { - let user = await getUserByID(ids[i]); - - if (!user.ok) { - logger.generic(3, "Unable to find user id: ", { - type: "object", - obj: ids[i], - }); - logger.generic(3, "Details on Not Found User: ", { - type: "object", - obj: user, - }); - continue; - } - - userArray.push({ login: user.content.username }); - } - - return { ok: true, content: userArray }; -} - -let emptyClause; - -function getEmptyClause() { - emptyClause ??= sqlStorage``; - return emptyClause; -} - -function queryClause(opts) { - if (typeof opts.query !== "string") { - return getEmptyClause(); - } - - // We obtain the lowercase version of the query since names should be in - // lowercase format (see atom-backend issue #86) - const lcterm = opts.query.toLowerCase(); - - const wordSeparators = /[-. ]/g; // Word Separators: - . SPACE - - const searchTerm = lcterm.replace(wordSeparators, "_"); - // Replaces all word separators with '_' which matches any single character - - return sqlStorage`AND p.name LIKE ${"%" + searchTerm + "%"}`; -} - -function filterClause(opts) { - if (typeof opts.filter !== "string") { - return getEmptyClause(); - } - - if (opts.filter === "theme") { - return sqlStorage`AND p.package_type = 'theme'`; - } else if (opts.filter === "package") { - // Since our fork from Atom, we have made the choice to return themes and packages - // on basic searches, meaning that `ppm`s filter of `package` has always returned - // packages and themes. - // If we decide to change this, uncomment the below line. - //return sqlStorage`AND p.package_type = 'package'`; - return getEmptyClause(); - } else { - return getEmptyClause(); - } -} - -function ownerClause(opts) { - if (typeof opts.owner !== "string") { - return getEmptyClause(); - } - return sqlStorage`AND p.owner = ${opts.owner}`; -} - -function serviceClause(opts) { - if ( - typeof opts.service !== "string" || - typeof opts.serviceType !== "string" - ) { - return getEmptyClause(); - } - let versionClause; - if (typeof opts.serviceVersion !== "string") { - versionClause = sqlStorage`IS NOT NULL`; - } else { - versionClause = sqlStorage`-> 'versions' -> ${opts.serviceVersion} IS NOT NULL`; - } - - return sqlStorage`AND v.meta -> ${opts.serviceType} -> ${opts.service} ${versionClause}`; -} - -function fileExtensionClause(opts) { - if (typeof opts.fileExtension !== "string") { - return getEmptyClause(); - } - - return sqlStorage`AND ${opts.fileExtension}=ANY(v.supported_languages)`; -} - -/** - * @async - * @function getSortedPackages - * @desc Takes the page, direction, and sort method returning the raw sql package - * data for each. This monolithic function handles trunication of the packages, - * and sorting, aiming to provide back the raw data, and allow later functions to - * then reconstruct the JSON as needed. - * @param {int} page - Page number. - * @param {string} dir - String flag for asc/desc order. - * @param {string} method - The sort method. - * @param {boolean} [themes=false] - Optional Parameter to specify if this should only return themes. - * @returns {object} A server status object containing the results and the pagination object. - */ -async function getSortedPackages(opts, themes = false) { - // Here will be a monolithic function for returning sortable packages arrays. - // We must keep in mind that all the endpoint handler knows is the - // page, sort method, and direction. We must figure out the rest here. - // only knowing we have a valid sort method provided. - - const limit = paginated_amount; - const offset = opts.page > 1 ? (opts.page - 1) * limit : 0; - - try { - sqlStorage ??= setupSQL(); - - const orderType = getOrderField(opts.sort, sqlStorage); - - if (orderType === null) { - logger.generic(3, `Unrecognized Sorting Method Provided: ${opts.sort}`); - return { - ok: false, - content: `Unrecognized Sorting Method Provided: ${opts.sort}`, - short: "Server Error", - }; - } - - const command = await sqlStorage` - WITH latest_versions AS ( - SELECT DISTINCT ON (p.name) p.name, p.data, p.downloads, p.owner, - (p.stargazers_count + p.original_stargazers) AS stargazers_count, - v.semver, p.created, v.updated, p.creation_method - FROM packages AS p - INNER JOIN versions AS v ON (p.pointer = v.package AND v.deleted IS FALSE - ${queryClause(opts)} - ${filterClause(opts)} - ${themes === true ? filterClause({ filter: "theme" }) : sqlStorage``}) - - WHERE p.name IS NOT NULL - - ${serviceClause(opts)} - ${fileExtensionClause(opts)} - ${ownerClause(opts)} - - ORDER BY p.name, v.semver_v1 DESC, v.semver_v2 DESC, v.semver_v3 DESC, v.created DESC - ) - SELECT *, COUNT(*) OVER() AS query_result_count - FROM latest_versions - ORDER BY ${orderType} ${ - opts.direction === "desc" ? sqlStorage`DESC` : sqlStorage`ASC` - } - LIMIT ${limit} - OFFSET ${offset}; - `; - - const resultCount = command[0]?.query_result_count ?? 0; - const quotient = Math.trunc(resultCount / limit); - const remainder = resultCount % limit; - const totalPages = quotient + (remainder > 0 ? 1 : 0); - - return { - ok: true, - content: command, - pagination: { - count: resultCount, - page: opts.page < totalPages ? opts.page : totalPages, - total: totalPages, - limit, - }, - }; - } catch (err) { - return { - ok: false, - content: "Generic Error", - short: "server_error", - error: err.toString(), - }; - } -} - -/** - * @async - * @function getOrderField - * @description Internal method to parse the sort method and return the related database field/column. - * @param {string} method - The sort method. - * @param {object} sqlStorage - The database class instance used parse the proper field. - * @returns {object|null} The string field associated to the sort method or null if the method is not recognized. - */ -function getOrderField(method, sqlStorage) { - switch (method) { - case "relevance": - case "downloads": - return sqlStorage`downloads`; - case "created_at": - return sqlStorage`created`; - case "updated_at": - return sqlStorage`updated`; - case "stars": - return sqlStorage`stargazers_count`; - default: - return null; - } -} - -/** - * @async - * @function authStoreStateKey - * @desc Gets a state key from login process and saves it on the database. - * @param {string} stateKey - The key code string. - * @returns {object} A server status object. - */ -async function authStoreStateKey(stateKey) { - try { - sqlStorage ??= setupSQL(); - - const command = await sqlStorage` - INSERT INTO authstate (keycode) - VALUES (${stateKey}) - RETURNING keycode; - `; - - return command.count !== 0 - ? { ok: true, content: command[0].keycode } - : { - ok: false, - content: `The state key has not been saved on the database.`, - short: "server_error", - }; - } catch (err) { - return { - ok: false, - content: "Generic Error", - short: "server_error", - error: err, - }; - } -} - -/** - * @async - * @function authCheckAndDeleteStateKey - * @desc Gets a state key from oauth process and delete it from the database. - * It's used to verify if the request for the authentication is valid. The code should be first generated in the - * initial stage of the login and then deleted by this function. - * If the deletion is successful, the returned record is used to retrieve the created timestamp of the state key - * and check if it's not expired (considering a specific timeout). - * A custom timestamp can be passed as argument for testing purpose, otherwise the current timestamp is considered. - * @param {string} stateKey - The key code string to delete. - * @param {string} timestamp - A string in SQL timestamp format to check against the created timestamp of the - * given state key. If not provided, the current UNIX timestamp is used. - * @returns {object} A server status object. - */ -async function authCheckAndDeleteStateKey(stateKey, timestamp = null) { - try { - sqlStorage ??= setupSQL(); - - // We need to compare the created timestamp with Date.now() timestamp which - // is returned in milliseconds. - // We convert the created SQL timestamp to UNIX timestamp which has an - // high resolution up to microseconds, but it's returned in seconds with a - // fractional part. - // So we first convert it to milliseconds, then cast to BIGINT to remove the - // unneeded remaining fractional part. - // BIGINT has to be used because ms UNIX timestamp does not fit into - // PostgreSQL INT type. - const command = await sqlStorage` - DELETE FROM authstate - WHERE keycode = ${stateKey} - RETURNING keycode, CAST(extract(epoch from created) * 1000 AS BIGINT) AS created; - `; - - if (command.count === 0) { - return { - ok: false, - content: "The provided state key was not set for the auth login.", - short: "not_found", - }; - } - - const created = BigInt(command[0].created); - const timeout = 600000n; // 10*60*1000 => 10 minutes in ms - const now = timestamp ?? Date.now(); - - if (now > created + timeout) { - return { - ok: false, - content: "The provided state key is expired for the auth login.", - short: "not_found", - }; - } - - return { ok: true, content: command[0].keycode }; - } catch (err) { - return { - ok: false, - content: "Generic Error", - short: "server_error", - error: err, - }; - } -} - -module.exports = { - shutdownSQL, - packageNameAvailability, - insertNewPackage, - getPackageByName, - getPackageCollectionByName, - getPackageCollectionByID, - removePackageByName, - removePackageVersion, - getFeaturedPackages, - getSortedPackages, - getUserByName, - getUserByNodeID, - getUserByID, - getStarredPointersByUserID, - getStarringUsersByPointer, - getUserCollectionById, - getPackageVersionByNameAndVersion, - updatePackageIncrementDownloadByName, - updatePackageDecrementDownloadByName, - getFeaturedThemes, - updateIncrementStar, - updateDecrementStar, - insertNewUser, - insertNewPackageName, - insertNewPackageVersion, - authStoreStateKey, - authCheckAndDeleteStateKey, - applyFeatures, -}; diff --git a/src/database/_clause.js b/src/database/_clause.js new file mode 100644 index 00000000..68583ce6 --- /dev/null +++ b/src/database/_clause.js @@ -0,0 +1,83 @@ +let emptyClause; + +function getEmptyClause(sql) { + emptyClause ??= sql``; + return emptyClause; +} + +function queryClause(sql, opts) { + if (typeof opts.query !== "string") { + return getEmptyClause(sql); + } + + // We obtain the lowercase version of the query since names should be in + // lowercase format (see atom-backend issue #86) + const lcterm = opts.query.toLowerCase(); + + const wordSeparators = /[-. ]/g; // Word Separators: - . SPACE + + const searchTerm = lcterm.replace(wordSeparators, "_"); + // Replaces all word separators with '_' which matches any single character + + return sql`AND p.name LIKE ${"%" + searchTerm + "%"}`; +} + +function filterClause(sql, opts) { + if (typeof opts.filter !== "string") { + return getEmptyClause(sql); + } + + if (opts.filter === "theme") { + return sql`AND p.package_type = 'theme'`; + } else if (opts.filter === "package") { + // Since our fork from Atom, we have made the choice to return themes and packages + // on basic searches, meaning that `ppm`s filter of `package` has always returned + // packages and themes. + // If we decide to change this, uncomment the below line. + //return sqlStorage`AND p.package_type = 'package'`; + return getEmptyClause(sql); + } else { + return getEmptyClause(sql); + } +} + +function ownerClause(sql, opts) { + if (typeof opts.owner !== "string") { + return getEmptyClause(sql); + } + return sql`AND p.owner = ${opts.owner}`; +} + +function serviceClause(sql, opts) { + if ( + typeof opts.service !== "string" || + typeof opts.serviceType !== "string" + ) { + return getEmptyClause(sql); + } + let versionClause; + if (typeof opts.serviceVersion !== "string") { + versionClause = sql`IS NOT NULL`; + } else { + versionClause = sql`-> 'versions' -> ${opts.serviceVersion} IS NOT NULL`; + } + + return sql`AND v.meta -> ${opts.serviceType} -> ${opts.service} ${versionClause}`; +} + +function fileExtensionClause(sql, opts) { + if (typeof opts.fileExtension !== "string") { + return getEmptyClause(sql); + } + + return sql`AND ${opts.fileExtension}=ANY(v.supported_languages)`; +} + +module.exports = { + getEmptyClause, + queryClause, + filterClause, + ownerClause, + serviceClause, + fileExtensionClause +} diff --git a/src/database/_constants.js b/src/database/_constants.js new file mode 100644 index 00000000..27db1b8d --- /dev/null +++ b/src/database/_constants.js @@ -0,0 +1,8 @@ +// Constants that relate to the database modules + +module.exports = { + defaults: { + engine: { atom: "*" }, + license: "NONE" + } +}; diff --git a/src/database/_export.js b/src/database/_export.js new file mode 100644 index 00000000..fc1bf68b --- /dev/null +++ b/src/database/_export.js @@ -0,0 +1,128 @@ +/** + * @desc Exposes all functions for the database, while also providing some default + * values to each module. + */ + +const fs = require("fs"); +const postgres = require("postgres"); +const logger = require("../logger.js"); +const { + DB_HOST, + DB_USER, + DB_PASS, + DB_DB, + DB_PORT, + DB_SSL_CERT +} = require("../config.js").getConfig(); + +// While the below method of exporting the additional database modules is nonstandard +// it lets us accomplish several things: +// - Consumers can call database.func() without issue while passing their own values +// - Consumers can call database.func.value to get constants about the function +// - Modules don't have to import the `sqlStorage` object into each one +// - We can wrap all database modules in a `try...catch` to avoid having to do it +// each time. + +let sqlStorage; // SQL Object to interact with the DB +// It is set after the first call with logical nullish assignment + +function setupSQL() { + return process.env.PULSAR_STATUS === "dev" && process.env.MOCK_DB !== "false" + ? postgres({ + host: DB_HOST, + username: DB_USER, + database: DB_DB, + port: DB_PORT + }) + : postgres({ + host: DB_HOST, + username: DB_USER, + password: DB_PASS, + database: DB_DB, + port: DB_PORT, + ssl: { + rejectUnauthorized: true, + ca: fs.readFileSync(DB_SSL_CERT).toString() + } + }); +} + +function getSqlStorage() { + return sqlStorage ??= setupSQL(); +} + +function wrapper(modToUse) { + // Return this function passing all args based on what module we need to use + return async (...args) => { + // Wrap all function calls in a try catch with a singular error handler + try { + + // Call the function passing the `sqlStorage` object can other provided params + return modToUse.exec(getSqlStorage(), ...args); + + } catch(err) { + // Generic Error Catcher for all database modules + return { + ok: false, + content: "Generic Error", + short: "server_error", + error: err + }; + + } + }; +}; + +const exportObj = { + shutdownSQL: async () => { + if (sqlStorage !== undefined) { + await sqlStorage.end(); + logger.generic(1, "SQL Server Shutdown!"); + } + } +}; + +// Add all other modules here: +// - First require only once on startup rather than during the command +// - Then add the function as the default export of the object key +// - Then we add the safe value to the object key + +const keys = [ + "applyFeatures", + "authCheckAndDeleteStateKey", + "authStoreStateKey", + "getFeaturedPackages", + "getFeaturedThemes", + "getPackageByName", + "getPackageByNameSimple", + "getPackageCollectionByID", + "getPackageCollectionByName", + "getPackageVersionByNameAndVersion", + "getSortedPackages", + "getStarredPointersByUserID", + "getPackageCollectionByName", + "getUserByID", + "getUserByName", + "getUserByNodeID", + "getUserCollectionById", + "insertNewPackage", + "insertNewPackageName", + "insertNewPackageVersion", + "insertNewUser", + "packageNameAvailability", + "removePackageByName", + "removePackageVersion", + "updateDecrementStar", + "updateIncrementStar", + "updatePackageDecrementDownloadByName", + "updatePackageIncrementDownloadByName", + "updatePackageStargazers", +]; + +for (const key of keys) { + let tmp = require(`./${key}.js`); + exportObj[key] = wrapper(tmp); + exportObj[key].safe = tmp.safe; +} + +module.exports = exportObj; diff --git a/src/database/_utils.js b/src/database/_utils.js new file mode 100644 index 00000000..27304491 --- /dev/null +++ b/src/database/_utils.js @@ -0,0 +1,27 @@ +/** + * @async + * @function getOrderField + * @description Internal method to parse the sort method and return the related database field/column. + * @param {string} method - The sort method. + * @param {object} sqlStorage - The database class instance used parse the proper field. + * @returns {object|null} The string field associated to the sort method or null if the method is not recognized. + */ +function getOrderField(method, sql) { + switch (method) { + case "relevance": + case "downloads": + return sql`downloads`; + case "created_at": + return sql`created`; + case "updated_at": + return sql`updated`; + case "stars": + return sql`stargazers_count`; + default: + return null; + } +} + +module.exports = { + getOrderField +}; diff --git a/src/database/applyFeatures.js b/src/database/applyFeatures.js new file mode 100644 index 00000000..c0d2900d --- /dev/null +++ b/src/database/applyFeatures.js @@ -0,0 +1,89 @@ +/** + * @async + * @function applyFeatures + * @desc Takes a Feature Object, and applies it's data to the appropriate package + * @param {object} featureObj - The object containing all feature declarations. + * @param {boolean} featureObj.hasGrammar - If present, and true, means this + * package version provides a grammar. + * @param {boolean} featureObj.hasSnippets - If present, and true, means this + * package version provides snippets. + * @param {string[]} featureObj.supportedLanguages - If present, defines an array + * of strings specifying the extensions, or file names supported by this grammar. + * @param {string} packName - The name of the package to be affected. + * @param {string} packVersion - The regular semver version of the package + */ + +const getPackageByNameSimple = require("./getPackageByNameSimple.js").exec; + +module.exports = { + safe: false, + exec: async (sql, featureObj, packName, packVersion) => { + const packID = await getPackageByNameSimple(sql, packName); + + if (!packID.ok) { + return { + ok: false, + content: `Unable to find the pointer of ${packName}`, + short: "not_found", + }; + } + + const pointer = packID.content.pointer; + + if (featureObj.hasSnippets) { + const addSnippetCommand = await sql` + UPDATE versions + SET has_snippets = TRUE + WHERE package = ${pointer} AND semver = ${packVersion}; + `; + + if (addSnippetCommand.count === 0) { + return { + ok: false, + content: `Unable to set 'has_snippets' flag to true for ${packName}`, + short: "server_error", + }; + } + } + + if (featureObj.hasGrammar) { + const addGrammarCommand = await sql` + UPDATE versions + SET has_grammar = TRUE + WHERE package = ${pointer} AND semver = ${packVersion}; + `; + + if (addGrammarCommand.count === 0) { + return { + ok: false, + content: `Unable to set 'has_grammar' flag to true for ${packName}`, + short: "server_error", + }; + } + } + + if ( + Array.isArray(featureObj.supportedLanguages) && + featureObj.supportedLanguages.length > 0 + ) { + // Add the supported languages + const addLangCommand = await sql` + UPDATE versions + SET supported_languages = ${featureObj.supportedLanguages} + WHERE package = ${pointer} AND semver = ${packVersion}; + `; + + if (addLangCommand.count === 0) { + return { + ok: false, + content: `Unable to add supportedLanguages to ${packName}`, + short: "server_error", + }; + } + } + + return { + ok: true, + }; + } +}; diff --git a/src/database/authCheckAndDeleteStateKey.js b/src/database/authCheckAndDeleteStateKey.js new file mode 100644 index 00000000..d8f387f5 --- /dev/null +++ b/src/database/authCheckAndDeleteStateKey.js @@ -0,0 +1,56 @@ +/** + * @async + * @function authCheckAndDeleteStateKey + * @desc Gets a state key from oauth process and delete it from the database. + * It's used to verify if the request for the authentication is valid. The code should be first generated in the + * initial stage of the login and then deleted by this function. + * If the deletion is successful, the returned record is used to retrieve the created timestamp of the state key + * and check if it's not expired (considering a specific timeout). + * A custom timestamp can be passed as argument for testing purpose, otherwise the current timestamp is considered. + * @param {string} stateKey - The key code string to delete. + * @param {string} timestamp - A string in SQL timestamp format to check against the created timestamp of the + * given state key. If not provided, the current UNIX timestamp is used. + * @returns {object} A server status object. + */ + +module.exports = { + safe: false, + exec: async (sql, stateKey, timestamp = null) => { + // We need to compare the created timestamp with Date.now() timestamp which + // is returned in milliseconds. + // We convert the created SQL timestamp to UNIX timestamp which has an + // high resolution up to microseconds, but it's returned in seconds with a + // fractional part. + // So we first convert it to milliseconds, then cast to BIGINT to remove the + // unneeded remaining fractional part. + // BIGINT has to be used because ms UNIX timestamp does not fit into + // PostgreSQL INT type. + const command = await sql` + DELETE FROM authstate + WHERE keycode = ${stateKey} + RETURNING keycode, CAST(extract(epoch from created) * 1000 AS BIGINT) AS created; + `; + + if (command.count === 0) { + return { + ok: false, + content: "The provided state key was not set for the auth login.", + short: "not_found", + }; + } + + const created = BigInt(command[0].created); + const timeout = 600000n; // 10*60*1000 => 10 minutes in ms + const now = timestamp ?? Date.now(); + + if (now > created + timeout) { + return { + ok: false, + content: "The provided state key is expired for the auth login.", + short: "not_found", + }; + } + + return { ok: true, content: command[0].keycode }; + } +}; diff --git a/src/database/authStoreStateKey.js b/src/database/authStoreStateKey.js new file mode 100644 index 00000000..cd54bce9 --- /dev/null +++ b/src/database/authStoreStateKey.js @@ -0,0 +1,26 @@ +/** + * @async + * @function authStoreStateKey + * @desc Gets a state key from login process and saves it on the database. + * @param {string} stateKey - The key code string. + * @returns {object} A server status object. + */ + +module.exports = { + safe: false, + exec: async (sql, stateKey) => { + const command = await sql` + INSERT INTO authstate (keycode) + VALUES (${stateKey}) + RETURNING keycode; + `; + + return command.count !== 0 + ? { ok: true, content: command[0].keycode } + : { + ok: false, + content: `The state key has not been saved on the database.`, + short: "server_error", + }; + } +}; diff --git a/src/database/getFeaturedPackages.js b/src/database/getFeaturedPackages.js new file mode 100644 index 00000000..ef3566d8 --- /dev/null +++ b/src/database/getFeaturedPackages.js @@ -0,0 +1,24 @@ +/** + * @async + * @function getFeaturedPackages + * @desc Collects the hardcoded featured packages array from the storage.js + * module. Then uses this.getPackageCollectionByName to retrieve details of the + * package. + * @returns {object} A server status object. + */ + +const storage = require("../storage.js"); +const getPackageCollectionByName = require("./getPackageCollectionByName.js").exec; + +module.exports = { + safe: false, + exec: async (sql) => { + let featuredArray = await storage.getFeaturedPackages(); + + if (!featuredArray.ok) { + return featuredArray; + } + + return await getPackageCollectionByName(sql, featuredArray.content); + } +}; diff --git a/src/database/getFeaturedThemes.js b/src/database/getFeaturedThemes.js new file mode 100644 index 00000000..908498d6 --- /dev/null +++ b/src/database/getFeaturedThemes.js @@ -0,0 +1,22 @@ +/** + * @async + * @function getFeaturedThemes + * @desc Collects the hardcoded featured themes array from the storage.js module. + * Then uses this.getPackageCollectionByName to retrieve details of the package. + * @returns {object} A server status object. + */ +const storage = require("../storage.js"); +const getPackageCollectionByName = require("./getPackageCollectionByName.js").exec; + +module.exports = { + safe: false, + exec: async (sql) => { + let featuredThemeArray = await storage.getFeaturedThemes(); + + if (!featuredThemeArray.ok) { + return featuredThemeArray; + } + + return await getPackageCollectionByName(sql, featuredThemeArray.content); + } +}; diff --git a/src/database/getPackageByName.js b/src/database/getPackageByName.js new file mode 100644 index 00000000..0f68747c --- /dev/null +++ b/src/database/getPackageByName.js @@ -0,0 +1,51 @@ +/** + * @async + * @function getPackageByName + * @desc Takes a package name and returns the raw SQL package with all its versions. + * This module is also used to get the data to be sent to utils.constructPackageObjectFull() + * in order to convert the query result in Package Object Full format. + * In that case it's recommended to set the user flag as true for security reasons. + * @param {string} name - The name of the package. + * @param {bool} user - Whether the packages has to be exposed outside or not. + * If true, all sensitive data like primary and foreign keys are not selected. + * Even if the keys are ignored by utils.constructPackageObjectFull(), it's still + * safe to not inclue them in case, by mistake, we publish the return of this module. + * @returns {object} A server status object. + */ + +module.exports = { + safe: false, + exec: async (sql, name, user = false) => { + const command = await sql` + SELECT + ${ + user ? sql`` : sql`p.pointer,` + } p.name, p.created, p.updated, p.creation_method, p.downloads, p.data, p.owner, + (p.stargazers_count + p.original_stargazers) AS stargazers_count, + JSONB_AGG( + JSON_BUILD_OBJECT( + ${ + user + ? sql`` + : sql`'id', v.id, 'package', v.package,` + } 'semver', v.semver, 'license', v.license, 'engine', v.engine, 'meta', v.meta, + 'hasGrammar', v.has_grammar, 'hasSnippets', v.has_snippets, + 'supportedLanguages', v.supported_languages + ) + ORDER BY v.semver_v1 DESC, v.semver_v2 DESC, v.semver_v3 DESC, v.created DESC + ) AS versions + FROM packages AS p + INNER JOIN names AS n ON (p.pointer = n.pointer AND n.name = ${name}) + INNER JOIN versions AS v ON (p.pointer = v.package AND v.deleted IS FALSE) + GROUP BY p.pointer; + `; + + return command.count !== 0 + ? { ok: true, content: command[0] } + : { + ok: false, + content: `package ${name} not found.`, + short: "not_found", + }; + } +}; diff --git a/src/database/getPackageByNameSimple.js b/src/database/getPackageByNameSimple.js new file mode 100644 index 00000000..65e5f796 --- /dev/null +++ b/src/database/getPackageByNameSimple.js @@ -0,0 +1,26 @@ +/** + * @async + * @function getPackageByNameSimple + * @desc Internal util used by other functions in this module to get the package row by the given name. + * It's like getPackageByName(), but with a simple and faster query. + * @param {string} name - The name of the package. + * @returns {object} A server status object. + */ + +module.exports = { + safe: false, + exec: async (sql, name) => { + const command = await sql` + SELECT pointer FROM names + WHERE name = ${name}; + `; + + return command.count !== 0 + ? { ok: true, content: command[0] } + : { + ok: false, + content: `Package ${name} not found.`, + short: "not_found", + }; + } +}; diff --git a/src/database/getPackageCollectionByID.js b/src/database/getPackageCollectionByID.js new file mode 100644 index 00000000..ecb31d48 --- /dev/null +++ b/src/database/getPackageCollectionByID.js @@ -0,0 +1,25 @@ +/** + * @async + * @function getPackageCollectionByID + * @desc Takes a package pointer array, and returns an array of the package objects. + * @param {int[]} packArray - An array of package id. + * @returns {object} A server status object. + */ + +module.exports = { + safe: false, + exec: async (sql, packArray) => { + const command = await sql` + SELECT DISTINCT ON (p.name) p.name, v.semver, p.downloads, + (p.stargazers_count + p.original_stargazers) AS stargazers_count, p.data + FROM packages AS p + INNER JOIN versions AS v ON (p.pointer = v.package AND v.deleted IS FALSE) + WHERE pointer IN ${sql(packArray)} + ORDER BY p.name, v.semver_v1 DESC, v.semver_v2 DESC, v.semver_v3 DESC, v.created DESC; + `; + + return command.count !== 0 + ? { ok: true, content: command } + : { ok: false, content: "No packages found.", short: "Not Found" }; + } +}; diff --git a/src/database/getPackageCollectionByName.js b/src/database/getPackageCollectionByName.js new file mode 100644 index 00000000..5ab58058 --- /dev/null +++ b/src/database/getPackageCollectionByName.js @@ -0,0 +1,31 @@ +/** + * @async + * @function getPackageCollectionByName + * @desc Takes a package name array, and returns an array of the package objects. + * You must ensure that the packArray passed is compatible. This function does not coerce compatibility. + * @param {string[]} packArray - An array of package name strings. + * @returns {object} A server status object. + */ + +module.exports = { + safe: false, + exec: async (sql, packArray) => { + // Since this function is invoked by getFeaturedThemes and getFeaturedPackages + // which process the returned content with constructPackageObjectShort(), + // we select only the needed columns. + const command = await sql` + SELECT DISTINCT ON (p.name) p.name, v.semver, p.downloads, p.owner, + (p.stargazers_count + p.original_stargazers) AS stargazers_count, p.data + FROM packages AS p + INNER JOIN names AS n ON (p.pointer = n.pointer AND n.name IN ${sql( + packArray + )}) + INNER JOIN versions AS v ON (p.pointer = v.package AND v.deleted IS FALSE) + ORDER BY p.name, v.semver_v1 DESC, v.semver_v2 DESC, v.semver_v3 DESC, v.created DESC; + `; + + return command.count !== 0 + ? { ok: true, content: command } + : { ok: false, content: "No packages found.", short: "not_found" }; + } +}; diff --git a/src/database/getPackageVersionByNameAndVersion.js b/src/database/getPackageVersionByNameAndVersion.js new file mode 100644 index 00000000..2c7bc68c --- /dev/null +++ b/src/database/getPackageVersionByNameAndVersion.js @@ -0,0 +1,28 @@ +/** + * @async + * @function getPackageVersionByNameAndVersion + * @desc Uses the name of a package and it's version to return the version info. + * @param {string} name - The name of the package to query. + * @param {string} version - The version of the package to query. + * @returns {object} A server status object. + */ + +module.exports = { + safe: false, + exec: async (sql, name, version) => { + const command = await sql` + SELECT v.semver, v.license, v.engine, v.meta + FROM packages AS p + INNER JOIN names AS n ON (p.pointer = n.pointer AND n.name = ${name}) + INNER JOIN versions AS v ON (p.pointer = v.package AND v.semver = ${version} AND v.deleted IS FALSE); + `; + + return command.count !== 0 + ? { ok: true, content: command[0] } + : { + ok: false, + content: `Package ${name} and Version ${version} not found.`, + short: "not_found", + }; + } +}; diff --git a/src/database/getSortedPackages.js b/src/database/getSortedPackages.js new file mode 100644 index 00000000..fa6658de --- /dev/null +++ b/src/database/getSortedPackages.js @@ -0,0 +1,86 @@ +/** + * @async + * @function getSortedPackages + * @desc Takes the page, direction, and sort method returning the raw sql package + * data for each. This monolithic function handles trunication of the packages, + * and sorting, aiming to provide back the raw data, and allow later functions to + * then reconstruct the JSON as needed. + * @param {int} page - Page number. + * @param {string} dir - String flag for asc/desc order. + * @param {string} method - The sort method. + * @param {boolean} [themes=false] - Optional Parameter to specify if this should only return themes. + * @returns {object} A server status object containing the results and the pagination object. + */ + +const clause = require("./_clause.js"); +const utils = require("./_utils.js"); +const logger = require("../logger.js"); +const { paginated_amount } = require("../config.js").getConfig(); + +module.exports = { + safe: false, + exec: async (sql, opts, themes = false) => { + // Here will be a monolithic function for returning sortable packages arrays. + // We must keep in mind that all the endpoint handler knows is the + // page, sort method, and direction. We must figure out the rest here. + // only knowing we have a valid sort method provided. + + const limit = paginated_amount; + const offset = opts.page > 1 ? (opts.page - 1) * limit : 0; + + const orderType = utils.getOrderField(opts.sort, sql); + + if (orderType === null) { + logger.generic(3, `Unrecognized Sorting Method Provided: ${opts.sort}`); + return { + ok: false, + content: `Unrecognized Sorting Method Provided: ${opts.sort}`, + short: "Server Error", + }; + } + + const command = await sql` + WITH latest_versions AS ( + SELECT DISTINCT ON (p.name) p.name, p.data, p.downloads, p.owner, + (p.stargazers_count + p.original_stargazers) AS stargazers_count, + v.semver, p.created, v.updated, p.creation_method + FROM packages AS p + INNER JOIN versions AS v ON (p.pointer = v.package AND v.deleted IS FALSE + ${clause.queryClause(sql, opts)} + ${clause.filterClause(sql, opts)} + ${themes === true ? clause.filterClause(sql, { filter: "theme" }) : sql``}) + + WHERE p.name IS NOT NULL + + ${clause.serviceClause(sql, opts)} + ${clause.fileExtensionClause(sql, opts)} + ${clause.ownerClause(sql, opts)} + + ORDER BY p.name, v.semver_v1 DESC, v.semver_v2 DESC, v.semver_v3 DESC, v.created DESC + ) + SELECT *, COUNT(*) OVER() AS query_result_count + FROM latest_versions + ORDER BY ${orderType} ${ + opts.direction === "desc" ? sql`DESC` : sql`ASC` + } + LIMIT ${limit} + OFFSET ${offset}; + `; + + const resultCount = command[0]?.query_result_count ?? 0; + const quotient = Math.trunc(resultCount / limit); + const remainder = resultCount % limit; + const totalPages = quotient + (remainder > 0 ? 1 : 0); + + return { + ok: true, + content: command, + pagination: { + count: resultCount, + page: opts.page < totalPages ? opts.page : totalPages, + total: totalPages, + limit, + }, + }; + } +}; diff --git a/src/database/getStarredPointersByUserID.js b/src/database/getStarredPointersByUserID.js new file mode 100644 index 00000000..77f03c65 --- /dev/null +++ b/src/database/getStarredPointersByUserID.js @@ -0,0 +1,29 @@ +/** + * @async + * @function getStarredPointersByUserID + * @description Get all packages which the user gave the star. + * The result of this function should not be returned to the user because it contains pointers UUID. + * @param {int} userid - ID of the user. + * @returns {object} A server status object. + */ + +module.exports = { + safe: false, + exec: async (sql, userid) => { + const command = await sql` + SELECT ARRAY ( + SELECT package FROM stars WHERE userid = ${userid} + ); + `; + + // It is likely safe to assume that if nothing matches the userid, + // then the user hasn't given any star. So instead of server error + // here we will non-traditionally return an empty array. + const packArray = + command.count !== 0 && Array.isArray(command[0].array) + ? command[0].array + : []; + + return { ok: true, content: packArray }; + } +}; diff --git a/src/database/getStarringUsersByPointer.js b/src/database/getStarringUsersByPointer.js new file mode 100644 index 00000000..9c5cd739 --- /dev/null +++ b/src/database/getStarringUsersByPointer.js @@ -0,0 +1,35 @@ +/** + * @async + * @function getStarringUsersByPointer + * @description Use the pointer of a package to collect all users that have starred it. + * @param {string} pointer - The ID of the package. + * @returns {object} A server status object. + */ + +const logger = require("../logger.js"); + +module.exports = { + safe: false, + exec: async (sql, pointer) => { + const command = await sql` + SELECT ARRAY ( + SELECT userid FROM stars WHERE package = ${pointer.pointer} + ); + `; + + let userArray = command[0].array; + + if (command.count === 0) { + // It is likely safe to assume that if nothing matches the packagepointer, + // then the package pointer has no stars. So instead of server error + // here we will non-traditionally return an empty array. + logger.generic( + 3, + `No Stars for ${pointer} found, assuming 0 star value.` + ); + userArray = []; + } + + return { ok: true, content: userArray }; + } +}; diff --git a/src/database/getUserByID.js b/src/database/getUserByID.js new file mode 100644 index 00000000..87f456a3 --- /dev/null +++ b/src/database/getUserByID.js @@ -0,0 +1,33 @@ +/** + * @async + * @function getUserByID + * @desc Get user details providing their ID. + * @param {int} id - User ID + * @returns {object} A Server status Object. + */ + +module.exports = { + safe: false, + exec: async (sql, id) => { + const command = await sql` + SELECT * FROM users + WHERE id = ${id}; + `; + + if (command.count === 0) { + return { + ok: false, + content: `Unable to get user by ID: ${id}`, + short: "server_error", + }; + } + + return command.count !== 0 + ? { ok: true, content: command[0] } + : { + ok: false, + content: `Unable to get user by ID: ${id}`, + short: "server_error", + }; + } +}; diff --git a/src/database/getUserByName.js b/src/database/getUserByName.js new file mode 100644 index 00000000..f41a7ad4 --- /dev/null +++ b/src/database/getUserByName.js @@ -0,0 +1,25 @@ +/** + * @async + * @function getUserByName + * @description Get a users details providing their username. + * @param {string} username - User name string. + * @returns {object} A server status object. + */ + +module.exports = { + safe: false, + exec: async (sql, username) => { + const command = await sql` + SELECT * FROM users + WHERE username = ${username}; + `; + + return command.count !== 0 + ? { ok: true, content: command[0] } + : { + ok: false, + content: `Unable to query for user: ${username}`, + short: "not_found", + }; + } +}; diff --git a/src/database/getUserByNodeID.js b/src/database/getUserByNodeID.js new file mode 100644 index 00000000..885c1365 --- /dev/null +++ b/src/database/getUserByNodeID.js @@ -0,0 +1,33 @@ +/** + * @async + * @function getUserByNodeID + * @description Get user details providing their Node ID. + * @param {string} id - Users Node ID. + * @returns {object} A server status object. + */ + +module.exports = { + safe: false, + exec: async (sql, id) => { + const command = await sql` + SELECT * FROM users + WHERE node_id = ${id}; + `; + + if (command.count === 0) { + return { + ok: false, + content: `Unable to get User By NODE_ID: ${id}`, + short: "not_found", + }; + } + + return command.count !== 0 + ? { ok: true, content: command[0] } + : { + ok: false, + content: `Unable to get User By NODE_ID: ${id}`, + short: "server_error", + }; + } +}; diff --git a/src/database/getUserCollectionById.js b/src/database/getUserCollectionById.js new file mode 100644 index 00000000..134eaf05 --- /dev/null +++ b/src/database/getUserCollectionById.js @@ -0,0 +1,37 @@ +/** + * @async + * @function getUserCollectionById + * @description Returns an array of Users and their associated data via the ids. + * @param {array} ids - The IDs of users to collect the data of. + * @returns {object} A server status object with the array of users collected. + */ + +const getUserByID = require("./getUserByID.js").exec; +const logger = require("../logger.js"); + +module.exports = { + safe: false, + exec: async (sql, ids) => { + let userArray = []; + + for (let i = 0; i < ids.length; i++) { + let user = await getUserByID(sql, ids[i]); + + if (!user.ok) { + logger.generic(3, "Unable to find user id: ", { + type: "object", + obj: ids[i], + }); + logger.generic(3, "Details on Not Found User: ", { + type: "object", + obj: user, + }); + continue; + } + + userArray.push({ login: user.content.username }); + } + + return { ok: true, content: userArray }; + } +}; diff --git a/src/database/insertNewPackage.js b/src/database/insertNewPackage.js new file mode 100644 index 00000000..9709d16f --- /dev/null +++ b/src/database/insertNewPackage.js @@ -0,0 +1,113 @@ +/** + * @async + * @function insertNewPackage + * @desc Insert a new package inside the DB taking a `Server Object Full` as argument. + * @param {object} pack - The `Server Object Full` package. + * @returns {object} A Server Status Object. + */ +const _constants = require("./_constants.js"); + +module.exports = { + safe: false, + exec: async (sql, pack) => { + // Since this operation involves multiple queries, we perform a + // PostgreSQL transaction executing a callback on begin(). + // All data is committed into the database only if no errors occur. + return await sql + .begin(async (sqlTrans) => { + const packageType = + typeof pack.metadata.theme === "string" && + pack.metadata.theme.match(/^(?:syntax|ui)$/i) !== null + ? "theme" + : "package"; + + // Populate packages table + let pointer = null; + let insertNewPack = {}; + try { + // No need to specify downloads and stargazers. They default at 0 on creation. + // TODO: data column deprecated; to be removed + insertNewPack = await sqlTrans` + INSERT INTO packages (name, creation_method, data, package_type, owner) + VALUES (${pack.name}, ${pack.creation_method}, ${pack}, ${packageType}, ${pack.owner}) + RETURNING pointer; + `; + } catch (e) { + throw `A constraint has been violated while inserting ${ + pack.name + } in packages table: ${e.toString()}`; + } + + if (!insertNewPack?.count) { + throw `Cannot insert ${pack.name} in packages table`; + } + + // Retrieve package pointer + pointer = insertNewPack[0].pointer; + + // Populate names table + let insertNewName = {}; + try { + insertNewName = await sqlTrans` + INSERT INTO names (name, pointer) + VALUES (${pack.name}, ${pointer}) + RETURNING name; + `; + } catch (e) { + throw `A constraint has been violated while inserting ${pack.name} in names table`; + } + + if (!insertNewName?.count) { + throw `Cannot insert ${pack.name} in names table`; + } + + // Populate versions table + let versionCount = 0; + const pv = pack.versions; + // TODO: status column deprecated; to be removed. + const status = "published"; + for (const ver of Object.keys(pv)) { + // Since many packages don't define an engine field, + // we will do it for them if not present, + // following suit with what Atom internal packages do. + const engine = pv[ver].engines ?? _constants.defaults.engine; + + // It's common practice for packages to not specify license, + // therefore set it as NONE if undefined. + const license = pv[ver].license ?? _constants.defaults.license; + + let insertNewVersion = {}; + try { + insertNewVersion = await sqlTrans` + INSERT INTO versions (package, status, semver, license, engine, meta) + VALUES (${pointer}, ${status}, ${ver}, ${license}, ${engine}, ${pv[ver]}) + RETURNING id; + `; + } catch (e) { + throw `A constraint is violated while inserting ${ver} version for ${pack.name} in versions table`; + } + + if (!insertNewVersion?.count) { + throw `Cannot insert ${ver} version for ${pack.name} package in versions table`; + } + versionCount++; + } + + if (versionCount === 0) { + throw `${pack.name} package does not contain any version.`; + } + + return { ok: true, content: pointer }; + }) + .catch((err) => { + return typeof err === "string" + ? { ok: false, content: err, short: "server_error" } + : { + ok: false, + content: `A generic error occurred while inserting ${pack.name} package`, + short: "server_error", + error: err, + }; + }); + } +}; diff --git a/src/database/insertNewPackageName.js b/src/database/insertNewPackageName.js new file mode 100644 index 00000000..9ae2b4d0 --- /dev/null +++ b/src/database/insertNewPackageName.js @@ -0,0 +1,80 @@ +/** + * @async + * @function insertNewPackageName + * @desc Insert a new package name with the same pointer as the old name. + * This essentially renames an existing package. + * @param {string} newName - The new name to create in the DB. + * @param {string} oldName - The original name of which to use the pointer of. + * @returns {object} A server status object. + * @todo This function has been left only for testing purpose since it has been integrated + * inside insertNewPackageVersion, so it should be removed when we can test the rename process + * directly on the endpoint. + */ +const getPackageByNameSimple = require("./getPackageByNameSimple.js").exec; + +module.exports = { + safe: false, + exec: async (sql, newName, oldName) => { + return await sql + .begin(async (sqlTrans) => { + // Retrieve the package pointer + const packID = await getPackageByNameSimple(sql, oldName); + + if (!packID.ok) { + // Return Not Found + return { + ok: false, + content: `Unable to find the original pointer of ${oldName}`, + short: "not_found", + }; + } + + const pointer = packID.content.pointer; + + // Before inserting the new name, we try to update it into the `packages` table + // since we want that column to contain the current name. + try { + const updateNewName = await sqlTrans` + UPDATE packages + SET name = ${newName} + WHERE pointer = ${pointer} + RETURNING name; + `; + + if (updateNewName.count === 0) { + throw `Unable to update the package name.`; + } + } catch (e) { + throw `Unable to update the package name. ${newName} is already used by another package.`; + } + + // Now we can finally insert the new name inside the `names` table. + try { + const newInsertedName = await sqlTrans` + INSERT INTO names + (name, pointer) VALUES + (${newName}, ${pointer}) + RETURNING name; + `; + + if (newInsertedName.count === 0) { + throw `Unable to add the new name: ${newName}`; + } + } catch (e) { + throw `Unable to add the new name: ${newName} is already used.`; + } + + return { ok: true, content: `Successfully inserted ${newName}.` }; + }) + .catch((err) => { + return typeof err === "string" + ? { ok: false, content: err, short: "Server Error" } + : { + ok: false, + content: `A generic error occurred while inserting the new package name ${newName}`, + short: "server_error", + error: err, + }; + }); + } +}; diff --git a/src/database/insertNewPackageVersion.js b/src/database/insertNewPackageVersion.js new file mode 100644 index 00000000..439abfc3 --- /dev/null +++ b/src/database/insertNewPackageVersion.js @@ -0,0 +1,159 @@ +/** + * @async + * @function insertNewPackageVersion + * @desc Adds a new package version to the db. + * @param {object} packJSON - A full `package.json` file for the wanted version. + * @param {string|null} oldName - If provided, the old name to be replaced for the renaming of the package. + * @returns {object} A server status object. + */ + +const _constants = require("./_constants.js"); +const getPackageByName = require("./getPackageByName.js").exec; + +module.exports = { + safe: false, + exec: async (sql, packJSON, oldName = null) => { + // We are expected to receive a standard `package.json` file. + // Note that, if oldName is provided, here we can be sure oldName !== packJSON.name + // because the comparison has been already done in postPackagesVersion() + return await sql + .begin(async (sqlTrans) => { + const rename = typeof oldName === "string"; + + // On renaming, search the package pointer using the oldName, + // otherwise use the name in the package object directly. + let packName = rename ? oldName : packJSON.name; + + const pack = await getPackageByName(sql, packName); + + if (!pack.ok) { + return pack; + } + + const pointer = pack.content.pointer; + + if (packJSON.owner !== pack.owner) { + // The package owner has changed. Whether or not this is plausible in + // the real world, it's a good idea to handle it here. + let updateOwner = {}; + let ownerUpdateFailed = false; + try { + updateOwner = await sqlTrans` + UPDATE PACKAGES + SET owner = ${packJSON.owner} + WHERE pointer = ${pointer} + RETURNING owner; + `; + } catch (e) { + // There aren't constraints on the `owner` field, so if this were to + // fail, it wouldn't be clear why. But we're handling it anyway! + ownerUpdateFailed = true; + } + if (!updateOwner?.count || ownerUpdateFailed) { + throw `Unable to update the package owner to ${packJSON.owner}.`; + } + } + + if (rename) { + // The flow for renaming the package. + // Before inserting the new name, we try to update it into the `packages` table + // since we want that column to contain the current name. + let updateNewName = {}; + try { + updateNewName = await sqlTrans` + UPDATE packages + SET name = ${packJSON.name} + WHERE pointer = ${pointer} + RETURNING name; + `; + } catch (e) { + throw `Unable to update the package name. ${packJSON.name} is already used by another package.`; + } + + if (!updateNewName?.count) { + throw `Unable to update the package name.`; + } + + // Now we can finally insert the new name inside the `names` table. + let newInsertedName = {}; + try { + newInsertedName = await sqlTrans` + INSERT INTO names + (name, pointer) VALUES + (${packJSON.name}, ${pointer}) + RETURNING name; + `; + } catch (e) { + throw `Unable to add the new name: ${packJSON.name} is already used.`; + } + + if (!newInsertedName?.count) { + throw `Unable to add the new name: ${packJSON.name}`; + } + + // After renaming, we can use packJSON.name as the package name. + packName = packJSON.name; + } + + // We used to check if the new version was higher than the latest, but this is + // too cumbersome to do and the publisher has the responsibility to push an + // higher version to be signaled in Pulsar for the update, so we just try to + // insert whatever we got. + // The only requirement is that the provided semver is not already present + // in the database for the targeted package. + + const license = packJSON.metadata.license ?? _constants.defaults.license; + const engine = packJSON.metadata.engines ?? _constants.defaults.engine; + + let addVer = {}; + try { + // TODO: status column deprecated; to be removed + addVer = await sqlTrans` + INSERT INTO versions (package, status, semver, license, engine, meta) + VALUES(${pointer}, 'published', ${packJSON.metadata.version}, ${license}, ${engine}, ${packJSON.metadata}) + RETURNING semver, status; + `; + } catch (e) { + // This occurs when the (package, semver) unique constraint is violated. + throw `Not allowed to publish a version already present for ${packName}`; + } + + if (!addVer?.count) { + throw `Unable to create a new version for ${packName}`; + } + + // Now to update the data field for the package, to update the readme and + // latest version + let addPackMeta = {}; + try { + addPackMeta = await sqlTrans` + UPDATE packages + SET data = ${packJSON} + WHERE pointer = ${pointer} + RETURNING name; + `; + } catch (e) { + throw `Unable to update the package's metadata for ${packName}`; + } + + if (!addPackMeta?.count) { + throw `Failed to update the package's metadata for ${packName}`; + } + + return { + ok: true, + content: `Successfully added new version: ${packName}@${packJSON.metadata.version}`, + }; + }) + .catch((err) => { + return typeof err === "string" + ? { ok: false, content: err, short: "server_error" } + : { + ok: false, + content: `A generic error occured while inserting the new package version ${packJSON.name}`, + short: "server_error", + error: err, + }; + }); + } +}; diff --git a/src/database/insertNewUser.js b/src/database/insertNewUser.js new file mode 100644 index 00000000..ecc544f8 --- /dev/null +++ b/src/database/insertNewUser.js @@ -0,0 +1,28 @@ +/** + * @async + * @function insertNewUser + * @desc Insert a new user into the database. + * @param {string} username - Username of the user. + * @param {object} id - Identifier code of the user. + * @param {object} avatar - The avatar of the user. + * @returns {object} A server status object. + */ + +module.exports = { + safe: false, + exec: async (sql, username, id, avatar) => { + const command = await sql` + INSERT INTO users (username, node_id, avatar) + VALUES (${username}, ${id}, ${avatar}) + RETURNING *; + `; + + return command.count !== 0 + ? { ok: true, content: command[0] } + : { + ok: false, + content: `Unable to create user: ${username}`, + short: "Server Error", + }; + } +}; diff --git a/src/database/packageNameAvailability.js b/src/database/packageNameAvailability.js new file mode 100644 index 00000000..ab9ff205 --- /dev/null +++ b/src/database/packageNameAvailability.js @@ -0,0 +1,33 @@ +/** + * @async + * @function packageNameAvailability + * @desc Determines if a name is ready to be used for a new package. Useful in the stage of the publication + * of a new package where checking if the package exists is not enough because a name could be not + * available if a deleted package was using it in the past. + * Useful also to check if a name is available for the renaming of a published package. + * This function simply checks if the provided name is present in "names" table. + * @param {string} name - The candidate name for a new package. + * @returns {object} A Server Status Object. + */ + +module.exports = { + safe: true, + exec: async (sql, name) => { + const command = await sql` + SELECT name FROM names + WHERE name = ${name}; + `; + + return command.count === 0 + ? { + ok: true, + content: `${name} is available to be used for a new package.` + } + : { + ok: false, + content: `${name} is not available to be used for a new package.`, + short: "not_found" + }; + + } +}; diff --git a/src/database/removePackageByName.js b/src/database/removePackageByName.js new file mode 100644 index 00000000..80ea6bfc --- /dev/null +++ b/src/database/removePackageByName.js @@ -0,0 +1,83 @@ +/** + * @async + * @function removePackageByName + * @description Given a package name, removes its record alongside its names, versions, stars. + * @param {string} name - The package name. + * @param {boolean} exterminate - A flag that if true will totally remove the package. + * Including the normally reserved name. Should never be used in production, enables + * a supply chain vulnerability. + * @returns {object} A server status object. + */ + +const getPackageByNameSimple = require("./getPackageByNameSimple.js").exec; + +module.exports = { + safe: false, + exec: async (sql, name, exterminate = false) => { + return await sql + .begin(async (sqlTrans) => { + // Retrieve the package pointer + const packID = await getPackageByNameSimple(sql, name); + + if (!packID.ok) { + // The package does not exist, but we return ok since it's like + // it has been deleted. + return { ok: true, content: `${name} package does not exist.` }; + } + + const pointer = packID.content.pointer; + + // Remove versions of the package + const commandVers = await sqlTrans` + DELETE FROM versions + WHERE package = ${pointer} + RETURNING semver; + `; + + if (commandVers.count === 0) { + throw `Failed to delete any versions for: ${name}`; + } + + // Remove stars assigned to the package + await sqlTrans` + DELETE FROM stars + WHERE package = ${pointer} + RETURNING userid; + `; + + const commandPack = await sqlTrans` + DELETE FROM packages + WHERE pointer = ${pointer} + RETURNING name; + `; + + if (commandPack.count === 0) { + // nothing was returning, the delete probably failed + throw `Failed to Delete Package for: ${name}`; + } + + if (commandPack[0].name !== name) { + throw `Attempted to delete ${commandPack[0].name} rather than ${name}`; + } + + if (exterminate) { + const commandName = await sqlTrans` + DELETE FROM names + WHERE pointer = ${pointer} + `; // We can't return name here, since it's set to null on package deletion + } + + return { ok: true, content: `Successfully Deleted Package: ${name}` }; + }) + .catch((err) => { + return typeof err === "string" + ? { ok: false, content: err, short: "server_error" } + : { + ok: false, + content: `A generic error occurred while inserting ${name} package`, + short: "server_error", + error: err, + }; + }); + } +}; diff --git a/src/database/removePackageVersion.js b/src/database/removePackageVersion.js new file mode 100644 index 00000000..196cf49e --- /dev/null +++ b/src/database/removePackageVersion.js @@ -0,0 +1,78 @@ +/** + * @async + * @function removePackageVersion + * @description Mark a version of a specific package as deleted. This does not delete the record, + * just mark the boolean deleted flag as true, but only if one published version remains available. + * This also makes sure that a new latest version is selected in case the previous one is removed. + * @param {string} packName - The package name. + * @param {string} semVer - The version to remove. + * @returns {object} A server status object. + */ + +const getPackageByNameSimple = require("./getPackageByNameSimple.js").exec; + +module.exports = { + safe: false, + exec: async (sql, packName, semVer) => { + return await sql + .begin(async (sqlTrans) => { + // Retrieve the package pointer + const packID = await getPackageByNameSimple(sql, packName); + + if (!packID.ok) { + // Return Not Found + return { + ok: false, + content: `Unable to find the pointer of ${packName}`, + short: "not_found", + }; + } + + const pointer = packID.content.pointer; + + // Retrieve all non-removed versions to count them + const getVersions = await sqlTrans` + SELECT id + FROM versions + WHERE package = ${pointer} AND deleted IS FALSE; + `; + + const versionCount = getVersions.count; + if (versionCount < 2) { + throw `${packName} package has less than 2 published versions: deletion not allowed.`; + } + + // We can remove the targeted semVer. + const markDeletedVersion = await sqlTrans` + UPDATE versions + SET DELETED = TRUE + WHERE package = ${pointer} AND semver = ${semVer} + RETURNING id; + `; + + if (markDeletedVersion.count === 0) { + // Do not use throw here because we specify Not Found reason. + return { + ok: false, + content: `Unable to remove ${semVer} version of ${packName} package.`, + short: "not_found", + }; + } + + return { + ok: true, + content: `Successfully removed ${semVer} version of ${packName} package.`, + }; + }) + .catch((err) => { + return typeof err === "string" + ? { ok: false, content: err, short: "server_error" } + : { + ok: false, + content: `A generic error occurred while inserting ${packName} package`, + short: "server_error", + error: err, + }; + }); + } +}; diff --git a/src/database/updateDecrementStar.js b/src/database/updateDecrementStar.js new file mode 100644 index 00000000..385c1bb6 --- /dev/null +++ b/src/database/updateDecrementStar.js @@ -0,0 +1,68 @@ +/** + * @async + * @function updateDecrementStar + * @description Register the removal of the star on a package by a user. + * @param {int} user - User Object who remove the star. + * @param {string} pack - Package name that get the star removed. + * @returns {object} A server status object. + */ + +const getPackageByNameSimple = require("./getPackageByNameSimple.js").exec; +const updatePackageStargazers = require("./updatePackageStargazers.js").exec; + +module.exports = { + safe: false, + exec: async (sql, user, pack) => { + const packID = await getPackageByNameSimple(sql, pack); + + if (!packID.ok) { + return { + ok: false, + content: `Unable to find package ${pack} to unstar.`, + short: "not_found", + }; + } + + const pointer = packID.content.pointer; + + const commandUnstar = await sql` + DELETE FROM stars + WHERE (package = ${pointer}) AND (userid = ${user.id}) + RETURNING *; + `; + + if (commandUnstar.count === 0) { + // We know user and package exist both, so the fail is because + // the star was already missing, + // The user expects its star is not given, so we return ok. + return { + ok: true, + content: "The Star is Already Missing", + }; + } + + // If the return does not match our input, it failed. + if ( + user.id !== commandUnstar[0].userid || + pointer !== commandUnstar[0].package + ) { + return { + ok: false, + content: "Failed to Unstar the Package", + short: "server_error", + }; + } + + // Now update the stargazers count into the packages table + const updatePack = await updatePackageStargazers(sql, pack, pointer); + + if (!updatePack.ok) { + return updatePack; + } + + return { + ok: true, + content: "Package Successfully Unstarred", + }; + } +}; diff --git a/src/database/updateIncrementStar.js b/src/database/updateIncrementStar.js new file mode 100644 index 00000000..a72b94c0 --- /dev/null +++ b/src/database/updateIncrementStar.js @@ -0,0 +1,72 @@ +/** + * @async + * @function updateIncrementStar + * @description Register the star given by a user to a package. + * @param {int} user - A User Object that should star the package. + * @param {string} pack - Package name that get the new star. + * @returns {object} A server status object. + */ + +const getPackageByNameSimple = require("./getPackageByNameSimple.js").exec; +const updatePackageStargazers = require("./updatePackageStargazers.js").exec; + +module.exports = { + safe: false, + exec: async (sql, user, pack) => { + const packID = await getPackageByNameSimple(sql, pack); + + if (!packID.ok) { + return { + ok: false, + content: `Unable to find package ${pack} to star.`, + short: "not_found", + }; + } + + const pointer = packID.content.pointer; + + try { + const commandStar = await sql` + INSERT INTO stars + (package, userid) VALUES + (${pointer}, ${user.id}) + RETURNING *; + `; + + // Now we expect to get our data right back, and can check the + // validity to know if this happened successfully or not. + if ( + pointer !== commandStar[0].package || + user.id !== commandStar[0].userid + ) { + return { + ok: false, + content: `Failed to Star the Package`, + short: "server_error", + }; + } + + // Now update the stargazers count into the packages table + const updatePack = await updatePackageStargazers(sql, pack, pointer); + + if (!updatePack.ok) { + return updatePack; + } + + return { + ok: true, + content: `Package Successfully Starred`, + }; + } catch (e) { + // TODO: While the comment below is accurate + // It's also worth noting that this catch will return success + // If the starring user does not exist. Resulting in a false positive + // Catch the primary key violation on (package, userid), + // Sinche the package is already starred by the user, we return ok. + return { + ok: true, + content: `Package Already Starred`, + }; + } + } +}; diff --git a/src/database/updatePackageDecrementDownloadByName.js b/src/database/updatePackageDecrementDownloadByName.js new file mode 100644 index 00000000..b16416c3 --- /dev/null +++ b/src/database/updatePackageDecrementDownloadByName.js @@ -0,0 +1,28 @@ +/** + * @async + * @function updatePackageDecrementDownloadByName + * @description Uses the package name to decrement the download count by one. + * @param {string} name - The package name. + * @returns {object} The modified server status object. + */ + +module.exports = { + safe: false, + exec: async (sql, name) => { + const command = await sql` + UPDATE packages AS p + SET downloads = GREATEST(p.downloads - 1, 0) + FROM names AS n + WHERE n.pointer = p.pointer AND n.name = ${name} + RETURNING p.name, p.downloads; + `; + + return command.count !== 0 + ? { ok: true, content: command[0] } + : { + ok: false, + content: "Unable to decrement Package Download Count", + short: "server_error", + }; + } +}; diff --git a/src/database/updatePackageIncrementDownloadByName.js b/src/database/updatePackageIncrementDownloadByName.js new file mode 100644 index 00000000..62f9ae88 --- /dev/null +++ b/src/database/updatePackageIncrementDownloadByName.js @@ -0,0 +1,28 @@ +/** + * @async + * @function updatePackageIncrementDownloadByName + * @description Uses the package name to increment the download count by one. + * @param {string} name - The package name. + * @returns {object} The modified server status object. + */ + +module.exports = { + safe: false, + exec: async (sql, name) => { + const command = await sql` + UPDATE packages AS p + SET downloads = p.downloads + 1 + FROM names AS n + WHERE n.pointer = p.pointer AND n.name = ${name} + RETURNING p.name, p.downloads; + `; + + return command.count !== 0 + ? { ok: true, content: command[0] } + : { + ok: false, + content: "Unable to Update Package Download", + short: "Server Error", + }; + } +}; diff --git a/src/database/updatePackageStargazers.js b/src/database/updatePackageStargazers.js new file mode 100644 index 00000000..aa1a4b7e --- /dev/null +++ b/src/database/updatePackageStargazers.js @@ -0,0 +1,48 @@ +/** + * @async + * @function updatePackageStargazers + * @description Internal util that uses the package name (or pointer if provided) to update its stargazers count. + * @param {string} name - The package name. + * @param {string} pointer - The package id (if given, the search by name is skipped). + * @returns {object} The effected server status object. + */ + +const getPackageByNameSimple = require("./getPackageByNameSimple.js"); + +module.exports = { + safe: false, + exec: async (sql, name, pointer = null) => { + if (pointer === null) { + const packID = await getPackageByNameSimple(sql, name); + + if (!packID.ok) { + return packID; + } + + pointer = packID.content.pointer; + } + + const countStars = await sql` + SELECT COUNT(*) AS stars + FROM stars + WHERE package = ${pointer}; + `; + + const starCount = countStars.count !== 0 ? countStars[0].stars : 0; + + const updateStar = await sql` + UPDATE packages + SET stargazers_count = ${starCount} + WHERE pointer = ${pointer} + RETURNING name, (stargazers_count + original_stargazers) AS stargazers_count; + `; + + return updateStar.count !== 0 + ? { ok: true, content: updateStar[0] } + : { + ok: false, + content: "Unable to Update Package Stargazers", + short: "server_error", + }; + } +}; diff --git a/src/server.js b/src/server.js index 8cbd667e..21c9a035 100644 --- a/src/server.js +++ b/src/server.js @@ -7,7 +7,7 @@ const app = require("./setupEndpoints.js"); const { port } = require("./config.js").getConfig(); const logger = require("./logger.js"); -const database = require("./database.js"); +const database = require("./database/_export.js"); if (process.env.PULSAR_STATUS === "dev") { logger.generic(3, "Pulsar Server is in Development Mode!"); From c63b093da85ff175f5a7e5960066b396f390e37a Mon Sep 17 00:00:00 2001 From: confused-Techie Date: Sat, 20 Jan 2024 02:29:53 -0800 Subject: [PATCH 2/3] Update tests and logic to confirm to new modularization --- src/context.js | 2 +- src/dev_server.js | 2 +- tests/database/applyFeatures.test.js | 2 +- tests/database/database.test.js | 2 +- tests/database/extensionFilter.test.js | 2 +- tests/http/deletePackagesPackageName.test.js | 2 +- ...etePackagesPackageNameVersionsVersionName.test.js | 2 +- tests/http/getOwnersOwnerName.test.js | 2 +- tests/http/getPackages.test.js | 2 +- tests/http/getPackagesFeatured.test.js | 2 +- tests/http/getPackagesPackageName.test.js | 12 +----------- tests/http/getPackagesSearch.test.js | 2 +- tests/http/getThemes.test.js | 2 +- tests/http/getThemesFeatured.test.js | 2 +- tests/http/getThemesSearch.test.js | 2 +- tests/http/getUsers.test.js | 2 +- tests/http/getUsersLogin.test.js | 12 +----------- tests/http/postPackages.test.js | 2 +- tests/http/postPackagesPackageNameStar.test.js | 2 +- tests/http/postPackagesPackageNameVersions.test.js | 2 +- 20 files changed, 20 insertions(+), 40 deletions(-) diff --git a/src/context.js b/src/context.js index 863b72c3..67ac9ad9 100644 --- a/src/context.js +++ b/src/context.js @@ -3,7 +3,7 @@ // but greater control in mocking these later on module.exports = { logger: require("./logger.js"), - database: require("./database.js"), + database: require("./database/_export.js"), webhook: require("./webhook.js"), server_version: require("../package.json").version, query: require("./query_parameters/index.js").logic, diff --git a/src/dev_server.js b/src/dev_server.js index 4bc139a2..535d44c9 100644 --- a/src/dev_server.js +++ b/src/dev_server.js @@ -43,7 +43,7 @@ async function test() { const app = require("./setupEndpoints.js"); const logger = require("./logger.js"); - const database = require("./database.js"); + const database = require("./database/_export.js"); // We can only require these items after we have set our env variables if (process.env.MOCK_DB !== "false") { diff --git a/tests/database/applyFeatures.test.js b/tests/database/applyFeatures.test.js index 567c6018..a8a68133 100644 --- a/tests/database/applyFeatures.test.js +++ b/tests/database/applyFeatures.test.js @@ -1,4 +1,4 @@ -const database = require("../../src/database.js"); +const database = require("../../src/database/_export.js"); afterAll(async () => { await database.shutdownSQL(); diff --git a/tests/database/database.test.js b/tests/database/database.test.js index 3782e6d8..5a187057 100644 --- a/tests/database/database.test.js +++ b/tests/database/database.test.js @@ -9,7 +9,7 @@ // Or at the very least that if there is a failure within these, it will not result in // bad data being entered into the database in production. -let database = require("../../src/database.js"); +let database = require("../../src/database/_export.js"); let utils = require("../../src/utils.js"); afterAll(async () => { diff --git a/tests/database/extensionFilter.test.js b/tests/database/extensionFilter.test.js index a7b5b417..103ea87c 100644 --- a/tests/database/extensionFilter.test.js +++ b/tests/database/extensionFilter.test.js @@ -1,4 +1,4 @@ -const database = require("../../src/database.js"); +const database = require("../../src/database/_export.js"); afterAll(async () => { await database.shutdownSQL(); diff --git a/tests/http/deletePackagesPackageName.test.js b/tests/http/deletePackagesPackageName.test.js index 940801d5..1cec7ba1 100644 --- a/tests/http/deletePackagesPackageName.test.js +++ b/tests/http/deletePackagesPackageName.test.js @@ -1,5 +1,5 @@ const endpoint = require("../../src/controllers/deletePackagesPackageName.js"); -const database = require("../../src/database.js"); +const database = require("../../src/database/_export.js"); const context = require("../../src/context.js"); const genPackage = require("../helpers/package.jest.js"); diff --git a/tests/http/deletePackagesPackageNameVersionsVersionName.test.js b/tests/http/deletePackagesPackageNameVersionsVersionName.test.js index 3bccbb2e..dc180bfc 100644 --- a/tests/http/deletePackagesPackageNameVersionsVersionName.test.js +++ b/tests/http/deletePackagesPackageNameVersionsVersionName.test.js @@ -1,5 +1,5 @@ const endpoint = require("../../src/controllers/deletePackagesPackageNameVersionsVersionName.js"); -const database = require("../../src/database.js"); +const database = require("../../src/database/_export.js"); const context = require("../../src/context.js"); const genPackage = require("../helpers/package.jest.js"); diff --git a/tests/http/getOwnersOwnerName.test.js b/tests/http/getOwnersOwnerName.test.js index 94598b9c..73e14259 100644 --- a/tests/http/getOwnersOwnerName.test.js +++ b/tests/http/getOwnersOwnerName.test.js @@ -1,5 +1,5 @@ const endpoint = require("../../src/controllers/getOwnersOwnerName.js"); -const database = require("../../src/database.js"); +const database = require("../../src/database/_export.js"); const context = require("../../src/context.js"); const genPackage = require("../helpers/package.jest.js"); diff --git a/tests/http/getPackages.test.js b/tests/http/getPackages.test.js index 843e68bd..00cc689b 100644 --- a/tests/http/getPackages.test.js +++ b/tests/http/getPackages.test.js @@ -1,5 +1,5 @@ const endpoint = require("../../src/controllers/getPackages.js"); -const database = require("../../src/database.js"); +const database = require("../../src/database/_export.js"); const context = require("../../src/context.js"); const genPackage = require("../helpers/package.jest.js"); diff --git a/tests/http/getPackagesFeatured.test.js b/tests/http/getPackagesFeatured.test.js index 5215b0fc..0fd5b6f5 100644 --- a/tests/http/getPackagesFeatured.test.js +++ b/tests/http/getPackagesFeatured.test.js @@ -1,5 +1,5 @@ const endpoint = require("../../src/controllers/getPackagesFeatured.js"); -const database = require("../../src/database.js"); +const database = require("../../src/database/_export.js"); const context = require("../../src/context.js"); const genPackage = require("../helpers/package.jest.js"); diff --git a/tests/http/getPackagesPackageName.test.js b/tests/http/getPackagesPackageName.test.js index ec7bc818..f9642b84 100644 --- a/tests/http/getPackagesPackageName.test.js +++ b/tests/http/getPackagesPackageName.test.js @@ -1,20 +1,10 @@ const endpoint = require("../../src/controllers/getPackagesPackageName.js"); -const database = require("../../src/database.js"); +const database = require("../../src/database/_export.js"); const context = require("../../src/context.js"); const genPackage = require("../helpers/package.jest.js"); describe("Behaves as expected", () => { - test("Calls the correct function", async () => { - const localContext = context; - const spy = jest.spyOn(localContext.database, "getPackageByName"); - - await endpoint.logic({}, localContext); - - expect(spy).toBeCalledTimes(1); - - spy.mockClear(); - }); test("Returns 'not_found' when package doesn't exist", async () => { const sso = await endpoint.logic( diff --git a/tests/http/getPackagesSearch.test.js b/tests/http/getPackagesSearch.test.js index 103c4a88..ee0e6cbb 100644 --- a/tests/http/getPackagesSearch.test.js +++ b/tests/http/getPackagesSearch.test.js @@ -1,5 +1,5 @@ const endpoint = require("../../src/controllers/getPackagesSearch.js"); -const database = require("../../src/database.js"); +const database = require("../../src/database/_export.js"); const context = require("../../src/context.js"); const genPackage = require("../helpers/package.jest.js"); diff --git a/tests/http/getThemes.test.js b/tests/http/getThemes.test.js index 469cdbcf..72d1b2a6 100644 --- a/tests/http/getThemes.test.js +++ b/tests/http/getThemes.test.js @@ -1,5 +1,5 @@ const endpoint = require("../../src/controllers/getThemes.js"); -const database = require("../../src/database.js"); +const database = require("../../src/database/_export.js"); const context = require("../../src/context.js"); const genPackage = require("../helpers/package.jest.js"); diff --git a/tests/http/getThemesFeatured.test.js b/tests/http/getThemesFeatured.test.js index 7544d7ca..df1189b3 100644 --- a/tests/http/getThemesFeatured.test.js +++ b/tests/http/getThemesFeatured.test.js @@ -1,5 +1,5 @@ const endpoint = require("../../src/controllers/getThemesFeatured.js"); -const database = require("../../src/database.js"); +const database = require("../../src/database/_export.js"); const context = require("../../src/context.js"); const genPackage = require("../helpers/package.jest.js"); diff --git a/tests/http/getThemesSearch.test.js b/tests/http/getThemesSearch.test.js index 76beac88..97411084 100644 --- a/tests/http/getThemesSearch.test.js +++ b/tests/http/getThemesSearch.test.js @@ -1,5 +1,5 @@ const endpoint = require("../../src/controllers/getThemesSearch.js"); -const database = require("../../src/database.js"); +const database = require("../../src/database/_export.js"); const context = require("../../src/context.js"); const genPackage = require("../helpers/package.jest.js"); diff --git a/tests/http/getUsers.test.js b/tests/http/getUsers.test.js index 410b994a..9d15ec0d 100644 --- a/tests/http/getUsers.test.js +++ b/tests/http/getUsers.test.js @@ -1,5 +1,5 @@ const endpoint = require("../../src/controllers/getUsers.js"); -const database = require("../../src/database.js"); +const database = require("../../src/database/_export.js"); const context = require("../../src/context.js"); const userObject = require("../models/userObjectPrivate.js"); diff --git a/tests/http/getUsersLogin.test.js b/tests/http/getUsersLogin.test.js index 194259ef..cd30303b 100644 --- a/tests/http/getUsersLogin.test.js +++ b/tests/http/getUsersLogin.test.js @@ -1,19 +1,9 @@ const endpoint = require("../../src/controllers/getUsersLogin.js"); -const database = require("../../src/database.js"); +const database = require("../../src/database/_export.js"); const context = require("../../src/context.js"); const userObject = require("../models/userObjectPublic.js"); describe("Behaves as expected", () => { - test("Calls the correct db function", async () => { - const localContext = context; - const spy = jest.spyOn(localContext.database, "getUserByName"); - - const res = await endpoint.logic({}, localContext); - - expect(spy).toBeCalledTimes(1); - - spy.mockClear(); - }); test("Returns bad SSO on failure", async () => { const userName = "our-test-user"; diff --git a/tests/http/postPackages.test.js b/tests/http/postPackages.test.js index e17ce2cc..958b58ac 100644 --- a/tests/http/postPackages.test.js +++ b/tests/http/postPackages.test.js @@ -1,5 +1,5 @@ const endpoint = require("../../src/controllers/postPackages.js"); -const database = require("../../src/database.js"); +const database = require("../../src/database/_export.js"); const context = require("../../src/context.js"); describe("POST /api/packages Behaves as expected", () => { diff --git a/tests/http/postPackagesPackageNameStar.test.js b/tests/http/postPackagesPackageNameStar.test.js index 8bbff80e..4924538f 100644 --- a/tests/http/postPackagesPackageNameStar.test.js +++ b/tests/http/postPackagesPackageNameStar.test.js @@ -1,5 +1,5 @@ const endpoint = require("../../src/controllers/postPackagesPackageNameStar.js"); -const database = require("../../src/database.js"); +const database = require("../../src/database/_export.js"); const context = require("../../src/context.js"); describe("POST /api/packages/:packageName/star", () => { diff --git a/tests/http/postPackagesPackageNameVersions.test.js b/tests/http/postPackagesPackageNameVersions.test.js index 61657c45..00ec1f95 100644 --- a/tests/http/postPackagesPackageNameVersions.test.js +++ b/tests/http/postPackagesPackageNameVersions.test.js @@ -1,5 +1,5 @@ const endpoint = require("../../src/controllers/postPackagesPackageNameVersions.js"); -const database = require("../../src/database.js"); +const database = require("../../src/database/_export.js"); const context = require("../../src/context.js"); describe("POST /api/packages/:packageName/versions", () => { From 818853a49bee2795fd10b0da75b588ca78ebe86f Mon Sep 17 00:00:00 2001 From: confused-Techie Date: Sat, 20 Jan 2024 03:08:52 -0800 Subject: [PATCH 3/3] Fix unused variable warning --- src/database/removePackageByName.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/database/removePackageByName.js b/src/database/removePackageByName.js index 80ea6bfc..259ed14e 100644 --- a/src/database/removePackageByName.js +++ b/src/database/removePackageByName.js @@ -61,7 +61,7 @@ module.exports = { } if (exterminate) { - const commandName = await sqlTrans` + await sqlTrans` DELETE FROM names WHERE pointer = ${pointer} `; // We can't return name here, since it's set to null on package deletion