Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion cronus/routes/v1/auth.js
Original file line number Diff line number Diff line change
Expand Up @@ -907,7 +907,7 @@ router.get("/github-callback", async (req, res) => {

router.get("/user", auth, async (req, res) => {
try {
const [users] = await db.query("SELECT id, username, slug, avatar, description, created_at, isVerified, telegram_id, github_id, isRole, social_links FROM users WHERE id = ?", [req.user.id]);
const [users] = await db.query("SELECT id, username, slug, avatar, cover, description, created_at, isVerified, telegram_id, github_id, isRole, social_links FROM users WHERE id = ?", [req.user.id]);

if(!users.length) {
return res.status(404).json({ message: "User not found" });
Expand Down
9 changes: 8 additions & 1 deletion cronus/routes/v1/moderation.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ const { clickhouse } = require("../../config/clickhouse");
const auth = require("../../middleware/auth");
const { sanitizePlainText } = require("../../utils/sanitize");
const { fanoutVersionReleaseNotifications, sendVersionApprovedOwnerNotification } = require("../../utils/versionNotifications");
const { awardFirstApprovedProjectAchievement } = require("../../utils/achievements");
const router = express.Router();

const isModeratorRole = (role) => role === "admin" || role === "moderator";
Expand Down Expand Up @@ -582,7 +583,7 @@ router.post("/:id/moderate", auth, async (req, res) => {
if(status === "approved") {
try {
const [projectRows] = await db.query(
`SELECT p.slug, p.title, p.summary, p.icon_url, u.username
`SELECT p.id, p.user_id, p.slug, p.title, p.summary, p.icon_url, u.username
FROM projects p
LEFT JOIN users u ON p.user_id = u.id
WHERE p.id = ?
Expand All @@ -595,6 +596,12 @@ router.post("/:id/moderate", auth, async (req, res) => {
if(!project) {
console.warn(`Project ${id} not found after approval, skipping published-mod notification`);
} else {
await awardFirstApprovedProjectAchievement(db, {
projectId: project.id,
userId: project.user_id,
awardedByUserId: moderatorId,
});

await fetch("https://api.hytalemodd.ing/published-mod", {
method: "POST",
headers: {
Expand Down
25 changes: 25 additions & 0 deletions cronus/routes/v1/projects.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ const { getCacheJson, setCacheJson, deleteCacheByPattern } = require("../../util
const { getProjectCacheVersion, bumpProjectCacheVersion, bumpProjectCacheVersionById, shouldSkipProjectCacheBump } = require("../../utils/projectCache");
const { fanoutVersionReleaseNotifications } = require("../../utils/versionNotifications");
const { notifyArgusAboutVersion } = require("../../utils/argus");
const { awardFirstApprovedProjectAchievement, awardProjectDownloadAchievements } = require("../../utils/achievements");
const router = express.Router();

const parseJsonArrayField = (value) => {
Expand Down Expand Up @@ -2609,6 +2610,14 @@ const trackProjectDownload = async ({ slug, versionId, ipAddress, countryCode, u
[projectSlug]
);

if(shouldCount) {
await awardProjectDownloadAchievements(db, {
projectId: project[0].id,
userId: project[0].user_id,
totalDownloads,
});
}

return {
status: 200,
body: {
Expand Down Expand Up @@ -2682,6 +2691,22 @@ router.post("/:id/moderate", auth, async (req, res) => {
try {
await db.query("UPDATE projects SET status = ? WHERE id = ?", [status, id]);
await bumpProjectCacheVersionById(db, id);

if(status === "approved") {
const [projectRows] = await db.query(
"SELECT id, user_id FROM projects WHERE id = ? LIMIT 1",
[id]
);

if(projectRows[0]) {
await awardFirstApprovedProjectAchievement(db, {
projectId: projectRows[0].id,
userId: projectRows[0].user_id,
awardedByUserId: req.user.id,
});
}
}

res.json({ success: true });
} catch (error) {
console.error("Error moderating project:", error);
Expand Down
77 changes: 70 additions & 7 deletions cronus/routes/v1/users.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,18 +20,18 @@ const storage = multer.diskStorage({
});

const fileFilter = (req, file, cb) => {
const allowedTypes = ["image/jpeg", "image/png", "image/gif"];
const allowedTypes = ["image/jpeg", "image/png", "image/gif", "image/webp"];
if(allowedTypes.includes(file.mimetype)) {
cb(null, true);
} else {
cb(new Error("Invalid file type. Only images (JPEG, PNG, GIF) are allowed."), false);
cb(new Error("Invalid file type. Only images (JPEG, PNG, GIF, WEBP) are allowed."), false);
}
};

const upload = multer({
storage,
limits: {
fileSize: 20 * 1024 * 1024,
fileSize: 10 * 1024 * 1024,
},
fileFilter,
});
Expand Down Expand Up @@ -132,15 +132,20 @@ const convertImageToWebp = async (file) => {
};
};

router.put("/me", auth, upload.fields([{ name: "avatar" }]), async (req, res) => {
router.put("/me", auth, upload.fields([{ name: "avatar" }, { name: "cover" }]), async (req, res) => {
const { username, slug, description, social_links } = req.body;
let avatarFile = req.files?.avatar?.[0];
let coverFile = req.files?.cover?.[0];

try {
if(avatarFile) {
avatarFile = await convertImageToWebp(avatarFile);
}

if(coverFile) {
coverFile = await convertImageToWebp(coverFile);
}

const [currentUserRows] = await db.query("SELECT slug FROM users WHERE id = ? LIMIT 1", [req.user.id]);
const currentUser = currentUserRows[0];

Expand All @@ -158,6 +163,10 @@ router.put("/me", auth, upload.fields([{ name: "avatar" }]), async (req, res) =>
updates.avatar = `https://media.modifold.com/${avatarFile.filename}`;
}

if(coverFile) {
updates.cover = `https://media.modifold.com/${coverFile.filename}`;
}

if(description !== undefined) {
updates.description = description ? sanitizePlainText(description, { preserveNewlines: true }) : "";
}
Expand Down Expand Up @@ -192,7 +201,7 @@ router.put("/me", auth, upload.fields([{ name: "avatar" }]), async (req, res) =>

await db.query("UPDATE users SET ? WHERE id = ?", [updates, req.user.id]);

const [updatedUser] = await db.query("SELECT id, username, slug, avatar, description, created_at, social_links FROM users WHERE id = ?", [req.user.id]);
const [updatedUser] = await db.query("SELECT id, username, slug, avatar, cover, description, created_at, social_links FROM users WHERE id = ?", [req.user.id]);

if(updatedUser[0]?.social_links) {
updatedUser[0].social_links = JSON.parse(updatedUser[0].social_links);
Expand Down Expand Up @@ -369,6 +378,12 @@ router.get("/:username/projects", async (req, res) => {
try {
const { username } = req.params;
const { page = 1, limit = 20 } = req.query;
const sort = ["downloads", "recent", "updated"].includes(req.query.sort) ? req.query.sort : "downloads";
const orderBy = {
downloads: "p.downloads DESC, p.updated_at DESC",
recent: "p.created_at DESC",
updated: "p.updated_at DESC",
}[sort];

if(isNaN(page) || page < 1) {
return res.status(400).json({ message: "Invalid page number" });
Expand Down Expand Up @@ -398,7 +413,7 @@ router.get("/:username/projects", async (req, res) => {
WHERE pm.project_id = p.id AND member_user.slug = ?
)
)
ORDER BY p.downloads DESC
ORDER BY ${orderBy}
LIMIT ? OFFSET ?
`;

Expand Down Expand Up @@ -458,6 +473,7 @@ router.get("/:username/projects", async (req, res) => {
totalProjects: Number(total || 0),
totalDownloads: Number(totalDownloads || 0),
currentPage: Number(page),
sort,
});
} catch (error) {
console.error("Error fetching user projects:", error);
Expand Down Expand Up @@ -570,9 +586,55 @@ router.get("/:username/organizations", async (req, res) => {
}
});

router.get("/:username/achievements", async (req, res) => {
try {
const { username } = req.params;
const [userRows] = await db.query("SELECT id FROM users WHERE slug = ? LIMIT 1", [username]);
if(!userRows.length) {
return res.status(404).json({ message: "User not found" });
}

const [rows] = await db.query(
`SELECT
ua.id,
ua.awarded_at,
ua.context_type,
ua.context_id,
ua.note,
a.code,
a.name,
a.description,
a.icon_url
FROM user_achievements ua
INNER JOIN achievements a ON a.id = ua.achievement_id
WHERE ua.user_id = ?
AND a.is_active = 1
ORDER BY ua.id ASC`,
[userRows[0].id]
);

return res.json({
achievements: rows.map((row) => ({
id: row.id,
code: row.code,
name: row.name,
description: row.description,
icon_url: row.icon_url,
awarded_at: Number(row.awarded_at || 0),
context_type: row.context_type || null,
context_id: row.context_id || null,
note: row.note || null,
})),
});
} catch (error) {
console.error("Error fetching user achievements:", error);
return res.status(500).json({ message: "Error fetching user achievements", error: error.message });
}
});

router.get("/:username", async (req, res) => {
try {
const [user] = await db.query("SELECT id, username, slug, description, avatar, created_at, isVerified, isRole, social_links FROM users WHERE slug = ?", [req.params.username]);
const [user] = await db.query("SELECT id, username, slug, description, avatar, cover, created_at, isVerified, isRole, social_links FROM users WHERE slug = ?", [req.params.username]);

if(!user.length) {
return res.status(404).json({ message: "User not found" });
Expand All @@ -589,6 +651,7 @@ router.get("/:username", async (req, res) => {
slug: user[0].slug,
description: user[0].description,
avatar: user[0].avatar,
cover: user[0].cover,
created_at: user[0].created_at,
isVerified: user[0].isVerified,
isRole: user[0].isRole,
Expand Down
135 changes: 135 additions & 0 deletions cronus/utils/achievements.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
const ACHIEVEMENT_CODES = {
FIRST_PROJECT: "first_project",
DOWNLOADS_100: "downloads_100",
DOWNLOADS_500: "downloads_500",
DOWNLOADS_10000: "downloads_10000",
};

const DOWNLOAD_ACHIEVEMENT_THRESHOLDS = [
{ minDownloads: 100, code: ACHIEVEMENT_CODES.DOWNLOADS_100 },
{ minDownloads: 500, code: ACHIEVEMENT_CODES.DOWNLOADS_500 },
{ minDownloads: 10000, code: ACHIEVEMENT_CODES.DOWNLOADS_10000 },
];

const getAchievementLockName = (userId, code) => {
return `achievement:${String(userId).slice(0, 32)}:${code}`;
};

const withAchievementLock = async (db, userId, code, callback) => {
if(typeof db.getConnection !== "function") {
return callback(db);
}

const connection = await db.getConnection();

try {
const [[lockRow]] = await connection.query(
"SELECT GET_LOCK(?, 5) AS lockAcquired",
[getAchievementLockName(userId, code)]
);

if(Number(lockRow?.lockAcquired || 0) !== 1) {
return false;
}

try {
return await callback(connection);
} finally {
await connection.query("SELECT RELEASE_LOCK(?)", [getAchievementLockName(userId, code)]);
}
} finally {
connection.release();
}
};

const awardAchievementToUser = async (db, { userId, code, contextType = null, contextId = null, awardedByUserId = null, note = null }) => {
if(!userId || !code) {
return false;
}

return withAchievementLock(db, userId, code, async (connection) => {
const [result] = await connection.query(
`INSERT IGNORE INTO user_achievements
(user_id, achievement_id, awarded_at, awarded_by_user_id, context_type, context_id, note)
SELECT ?, a.id, ?, ?, ?, ?, ?
FROM achievements a
LEFT JOIN user_achievements ua
ON ua.user_id = ?
AND ua.achievement_id = a.id
WHERE a.code = ?
AND a.is_active = 1
AND ua.id IS NULL
LIMIT 1`,
[
userId,
Math.floor(Date.now() / 1000),
awardedByUserId || null,
contextType,
contextId ? String(contextId) : null,
note,
userId,
code,
]
);

return result.affectedRows > 0;
});
};

const awardFirstApprovedProjectAchievement = async (db, { projectId, userId, awardedByUserId = null }) => {
if(!projectId || !userId) {
return false;
}

const [[{ approvedProjects }]] = await db.query(
"SELECT COUNT(*) AS approvedProjects FROM projects WHERE user_id = ? AND status = 'approved'",
[userId]
);

if(Number(approvedProjects || 0) !== 1) {
return false;
}

return awardAchievementToUser(db, {
userId,
code: ACHIEVEMENT_CODES.FIRST_PROJECT,
contextType: "project",
contextId: projectId,
awardedByUserId,
});
};

const awardProjectDownloadAchievements = async (db, { projectId, userId, totalDownloads }) => {
if(!projectId || !userId) {
return [];
}

const downloads = Math.max(0, Number(totalDownloads) || 0);
const awardedCodes = [];

for(const threshold of DOWNLOAD_ACHIEVEMENT_THRESHOLDS) {
if(downloads < threshold.minDownloads) {
continue;
}

const awarded = await awardAchievementToUser(db, {
userId,
code: threshold.code,
contextType: "project",
contextId: projectId,
});

if(awarded) {
awardedCodes.push(threshold.code);
}
}

return awardedCodes;
};

module.exports = {
ACHIEVEMENT_CODES,
awardAchievementToUser,
awardFirstApprovedProjectAchievement,
awardProjectDownloadAchievements,
};
Loading
Loading