diff --git a/src/community/challenges.osl b/src/community/challenges.osl new file mode 100644 index 0000000..327d73d --- /dev/null +++ b/src/community/challenges.osl @@ -0,0 +1,485 @@ +def challengeArray(object space, string field) array ( + if space.contains(field) and space[field] != null ( + return space[field].assertElse(array, []) + ) + return [] +) + +def challengeCriteria(object space) array ( + array criteria = challengeArray(space, "criteria") + if criteria.len == 0 ( + return [{id: "overall", name: "Overall", description: "How strong is the entry as a whole?", weight: 1}] + ) + array normalized = [] + for i criteria.len ( + object criterion = criteria[i].assert(object) + criterion.weight = clampNumber(criterion.weight.toNum(), 1, 5) + normalized.append(criterion) + ) + return normalized +) + +def challengePhase(object space) string ( + if space.contains("resultsPublishedAt") and space.resultsPublishedAt.toNum() > 0 ( + return "results" + ) + number startsAt = space.startsAt.toNum() + number endsAt = space.endsAt.toNum() + number judgingEndsAt = 0 + if space.contains("judgingEndsAt") ( + judgingEndsAt = space.judgingEndsAt.toNum() + ) + if startsAt > 0 and timestamp < startsAt ( + return "upcoming" + ) + if endsAt == 0 or timestamp <= endsAt ( + return "submissions" + ) + if judgingEndsAt == 0 or timestamp <= judgingEndsAt ( + return "judging" + ) + return "awaiting-results" +) + +def challengeVotingMode(object space) string ( + if space.contains("votingMode") and space.votingMode.toStr() == "audience" ( + return "audience" + ) + return "judges" +) + +def challengeAudienceJudged(object space) boolean ( + return challengeVotingMode(space) == "audience" +) + +def challengeAudienceVotingEnabled(object space) boolean ( + return challengeAudienceJudged(space) or (space.contains("communityVoting") and space.communityVoting == true) +) + +def challengeJudges(object space) array ( + if challengeAudienceJudged(space) ( + return [] + ) + array judges = challengeArray(space, "judges") + string tag = groupTagOf(space) + if tag != "" ( + array members = groupMemberNames(roturGroupSnapshot(tag)) + for i members.len ( + string member = members[i].toStr() + if !stringArrayContainsUser(judges, member) ( + judges.append(member) + ) + ) + ) + return judges +) + +def isChallengeJudge(object space, string username) boolean ( + return stringArrayContainsUser(challengeJudges(space), username) +) + +def isChallengeJudgeInvitee(object space, string username) boolean ( + array invites = challengeArray(space, "judgeInvites") + for i invites.len ( + object invite = invites[i].assert(object) + if normalizeUsername(invite.username.toStr()) == normalizeUsername(username) ( + return true + ) + ) + return false +) + +def challengeScoreSummary(object space, string projectId, string viewer) object ( + array criteria = challengeCriteria(space) + array scores = challengeArray(space, "scores") + number total = 0 + number scoreCount = 0 + object myScore = {} + array entryScores = [] + for i scores.len ( + object score = scores[i].assert(object) + if score.projectId.toStr() == projectId ( + entryScores.append(score) + number weighted = 0 + number weightTotal = 0 + array ratings = score.ratings.assertElse(array, []) + for r ratings.len ( + object rating = ratings[r].assert(object) + for k criteria.len ( + object criterion = criteria[k].assert(object) + if criterion.id.toStr() == rating.criterionId.toStr() ( + number weight = criterion.weight.toNum() + weighted += (clampNumber(rating.value.toNum(), 1, 10) * weight) + weightTotal += weight + ) + ) + ) + if weightTotal > 0 ( + total += (weighted / weightTotal) + scoreCount += 1 + ) + if normalizeUsername(score.judge.toStr()) == normalizeUsername(viewer) ( + myScore = score + ) + ) + ) + number audienceTotal = 0 + number audienceCount = 0 + number myVote = 0 + array votes = challengeArray(space, "audienceVotes") + for i votes.len ( + object vote = votes[i].assert(object) + if vote.projectId.toStr() == projectId ( + audienceTotal += vote.value.toNum() + audienceCount += 1 + if normalizeUsername(vote.username.toStr()) == normalizeUsername(viewer) ( + myVote = vote.value.toNum() + ) + ) + ) + number judgeScore = 0 + number audienceScore = 0 + if scoreCount > 0 ( + judgeScore = total / scoreCount + ) + if audienceCount > 0 ( + audienceScore = audienceTotal / audienceCount + ) + return {judgeScore: judgeScore, judgeScoreCount: scoreCount, audienceScore: audienceScore, audienceVoteCount: audienceCount, myScore: myScore, myVote: myVote, scores: entryScores} +) + +def challengeProjects(object space,*serve.Context c) array ( + array projects = [] + array ids = spaceProjectIds(space) + boolean showResults = (space.contains("resultsPublishedAt") and space.resultsPublishedAt.toNum() > 0) or canManageSpace(space, c) + boolean audienceJudged = challengeAudienceJudged(space) + string phase = challengePhase(space) + boolean revealRatings = phase == "awaiting-results" or phase == "results" or canManageSpace(space, c) + for i ids.len ( + auto loaded = loadProject(ids[i].toStr()) + if loaded.ok == true ( + object project = loaded.project.assert(object) + if project.shared == true or projectVisibility(project) == "unlisted" or canManageSpace(space, c) ( + object entry = indexEntryFromProject(project) + object summary = challengeScoreSummary(space, project.id.toStr(), contextString(c, "username")) + entry.judgeScore = if showResults then summary.judgeScore else 0 + entry.judgeScoreCount = summary.judgeScoreCount + entry.audienceScore = if revealRatings then summary.audienceScore else 0 + entry.audienceVoteCount = if revealRatings then summary.audienceVoteCount else 0 + entry.rankScore = entry.judgeScore + if audienceJudged and showResults ( + entry.rankScore = (((summary.audienceScore.toNum() * 100) + 0.5).toInt() * 1000000) + clampNumber(summary.audienceVoteCount.toNum(), 0, 999999) + ) + entry.myVote = summary.myVote + entry.myScore = if isChallengeJudge(space, contextString(c, "username")) then summary.myScore else {} + entry.scoreBreakdown = if canManageSpace(space, c) then summary.scores else [] + projects.append(entry) + ) + ) + ) + projects = projectsVisibleTo(projects, c) + if showResults ( + projects = rankChallengeEntries(projects) + ) + return projects +) + +def challengeWinner(object space, array projects) object ( + if challengePhase(space) != "results" ( + return {} + ) + for i projects.len ( + object entry = projects[i].assert(object) + if entry.contains("place") and entry.place.toNum() == 1 ( + boolean audienceJudged = challengeAudienceJudged(space) + return { + id: entry.id, + title: entry.title, + owner: entry.owner, + thumbUrl: entry.thumbUrl, + vanitySlug: entry.vanitySlug, + score: if audienceJudged then entry.audienceScore else entry.judgeScore, + scoreOutOf: if audienceJudged then 5 else 10, + voteCount: entry.audienceVoteCount + } + ) + ) + return {} +) + +def handleInviteChallengeJudge(*serve.Context c) ( + object space = (spaceDocs.findById(c.param("id")) ?? {}) + if space.len == 0 or space.kind.toStr() != "challenge" ( + c.notFound("challenge not found") + return + ) + if !canManageSpace(space, c) ( + c.forbidden("you cannot manage this challenge") + return + ) + if challengeAudienceJudged(space) ( + c.badRequest("this challenge is decided by audience ratings") + return + ) + string requested = requestJSON(c).username.toStr().strip() + string usernameKey = normalizeUsername(requested) + object profile = (profileSearchDocs.findById(usernameKey) ?? {}) + if usernameKey == "" or profile.len == 0 ( + c.notFound("user not found") + return + ) + if normalizeUsername(space.owner.toStr()) == usernameKey or isChallengeJudge(space, requested) or isChallengeJudgeInvitee(space, requested) ( + c.badRequest("this user is already on the challenge team") + return + ) + object invite = {username: profile.username, invitedBy: contextString(c, "username"), created: timestamp} + array invites = challengeArray(space, "judgeInvites") + invites.append(invite) + space.judgeInvites = invites + space.edited = timestamp + putSpaceDocument(space) + addNotification(profile.username.toStr(), contextString(c, "username"), "challenge_judge_invite", {spaceId: space["_id"], spaceTitle: space.title}) + c.json(201, {ok: true, invitation: invite}) +) + +def handleJoinChallenge(*serve.Context c) ( + object space = (spaceDocs.findById(c.param("id")) ?? {}) + if space.len == 0 or space.kind.toStr() != "challenge" or !canViewSpace(space, c) ( + c.notFound("challenge not found") + return + ) + string phase = challengePhase(space) + if phase != "upcoming" and phase != "submissions" ( + c.badRequest("this challenge is no longer open to new participants") + return + ) + array participants = challengeArray(space, "participants") + if !stringArrayContainsUser(participants, contextString(c, "username")) ( + participants.append(contextString(c, "username")) + space.participants = participants + space.edited = timestamp + putSpaceDocument(space) + if !isSpaceOwner(space, c) ( + addNotification(space.owner.toStr(), contextString(c, "username"), "challenge_join", {spaceId: space["_id"], spaceTitle: space.title}) + ) + ) + c.json(200, {ok: true, joined: true, participantCount: participants.len}) +) + +def handleLeaveChallenge(*serve.Context c) ( + object space = (spaceDocs.findById(c.param("id")) ?? {}) + if space.len == 0 or space.kind.toStr() != "challenge" ( + c.notFound("challenge not found") + return + ) + if challengePhase(space) != "upcoming" and challengePhase(space) != "submissions" ( + c.badRequest("participants are locked for this challenge") + return + ) + string username = contextString(c, "username") + array participants = challengeArray(space, "participants") + if !stringArrayContainsUser(participants, username) ( + c.json(200, {ok: true, joined: false, participantCount: participants.len}) + return + ) + array ids = spaceProjectIds(space) + for i ids.len ( + object project = (projectSearchDocs.findById(ids[i].toStr()) ?? {}) + if project.len > 0 and searchEntryOwnedByContext(project, c) ( + c.badRequest("remove your submission before leaving the challenge") + return + ) + ) + space.participants = removeUser(participants, username) + space.edited = timestamp + putSpaceDocument(space) + c.json(200, {ok: true, joined: false, participantCount: space.participants.len}) +) + +def handleRespondChallengeJudgeInvite(*serve.Context c) ( + object space = (spaceDocs.findById(c.param("id")) ?? {}) + if space.len == 0 or !isChallengeJudgeInvitee(space, contextString(c, "username")) ( + c.notFound("judge invitation not found") + return + ) + array invites = challengeArray(space, "judgeInvites") + array nextInvites = [] + for i invites.len ( + object invite = invites[i].assert(object) + if normalizeUsername(invite.username.toStr()) != normalizeUsername(contextString(c, "username")) ( + nextInvites.append(invite) + ) + ) + space.judgeInvites = nextInvites + if requestJSON(c).accepted == true ( + array judges = challengeArray(space, "judges") + if !stringArrayContainsUser(judges, contextString(c, "username")) ( + judges.append(contextString(c, "username")) + ) + space.judges = judges + addNotification(space.owner.toStr(), contextString(c, "username"), "challenge_judge_accepted", {spaceId: space["_id"], spaceTitle: space.title}) + ) + space.edited = timestamp + putSpaceDocument(space) + c.json(200, {ok: true, space: spaceResponse(space, c)}) +) + +def handleRemoveChallengeJudge(*serve.Context c) ( + object space = (spaceDocs.findById(c.param("id")) ?? {}) + if space.len == 0 or !canManageSpace(space, c) ( + c.forbidden("you cannot manage this challenge") + return + ) + string username = c.param("username") + array judges = challengeArray(space, "judges") + boolean changed = stringArrayContainsUser(judges, username) + space.judges = removeUser(judges, username) + array scores = challengeArray(space, "scores") + array nextScores = [] + for i scores.len ( + object score = scores[i].assert(object) + if normalizeUsername(score.judge.toStr()) != normalizeUsername(username) ( + nextScores.append(score) + ) + ) + if nextScores.len != scores.len ( + changed = true + ) + space.scores = nextScores + if changed ( + space.edited = timestamp + putSpaceDocument(space) + ) + c.json(200, {ok: true}) +) + +def validChallengeCriterion(object space, string criterionId) boolean ( + array criteria = challengeCriteria(space) + for i criteria.len ( + object criterion = criteria[i].assert(object) + if criterion.id.toStr() == criterionId ( + return true + ) + ) + return false +) + +def handleScoreChallengeEntry(*serve.Context c) ( + object space = (spaceDocs.findById(c.param("id")) ?? {}) + string projectId = c.param("project") + if space.len == 0 or space.kind.toStr() != "challenge" or !spaceProjectIds(space).contains(projectId) ( + c.notFound("challenge entry not found") + return + ) + if !isChallengeJudge(space, contextString(c, "username")) ( + c.forbidden("only invited judges can score entries") + return + ) + if challengePhase(space) != "judging" ( + c.badRequest("judging is not open") + return + ) + object project = (projectSearchDocs.findById(projectId) ?? {}) + if project.len > 0 and searchEntryOwnedByContext(project, c) ( + c.forbidden("you cannot score your own entry") + return + ) + object body = requestJSON(c) + array rawRatings = body.ratings.assertElse(array, []) + array ratings = [] + array ratedCriteria = [] + for i rawRatings.len ( + object rawRating = rawRatings[i].assert(object) + string criterionId = rawRating.criterionId.toStr() + number value = rawRating.value.toNum() + if validChallengeCriterion(space, criterionId) and !ratedCriteria.contains(criterionId) and value >= 1 and value <= 10 ( + ratings.append({criterionId: criterionId, value: value}) + ratedCriteria.append(criterionId) + ) + ) + if ratings.len != challengeCriteria(space).len ( + c.badRequest("score every criterion from 1 to 10") + return + ) + string feedback = body.feedback.toStr().strip() + if feedback.len > 2000 ( + c.badRequest("feedback is too long") + return + ) + string judge = contextString(c, "username") + array scores = challengeArray(space, "scores") + array nextScores = [] + for i scores.len ( + object existing = scores[i].assert(object) + if existing.projectId.toStr() != projectId or normalizeUsername(existing.judge.toStr()) != normalizeUsername(judge) ( + nextScores.append(existing) + ) + ) + object score = {projectId: projectId, judge: judge, ratings: ratings, feedback: feedback, edited: timestamp} + nextScores.append(score) + space.scores = nextScores + space.edited = timestamp + putSpaceDocument(space) + c.json(200, {ok: true, score: score}) +) + +def handleVoteChallengeEntry(*serve.Context c) ( + object space = (spaceDocs.findById(c.param("id")) ?? {}) + string projectId = c.param("project") + if space.len == 0 or space.kind.toStr() != "challenge" or !spaceProjectIds(space).contains(projectId) ( + c.notFound("challenge entry not found") + return + ) + if !challengeAudienceVotingEnabled(space) or challengePhase(space) != "judging" ( + c.badRequest("community voting is not open") + return + ) + object project = (projectSearchDocs.findById(projectId) ?? {}) + if project.len > 0 and searchEntryOwnedByContext(project, c) ( + c.forbidden("you cannot vote for your own entry") + return + ) + number value = requestJSON(c).value.toNum() + if value < 1 or value > 5 ( + c.badRequest("rating must be between 1 and 5") + return + ) + string username = contextString(c, "username") + array votes = challengeArray(space, "audienceVotes") + array nextVotes = [] + for i votes.len ( + object vote = votes[i].assert(object) + if vote.projectId.toStr() != projectId or normalizeUsername(vote.username.toStr()) != normalizeUsername(username) ( + nextVotes.append(vote) + ) + ) + nextVotes.append({projectId: projectId, username: username, value: value, edited: timestamp}) + space.audienceVotes = nextVotes + putSpaceDocument(space) + c.json(200, {ok: true, value: value}) +) + +def handlePublishChallengeResults(*serve.Context c) ( + object space = (spaceDocs.findById(c.param("id")) ?? {}) + if space.len == 0 or space.kind.toStr() != "challenge" or !canManageSpace(space, c) ( + c.forbidden("you cannot manage this challenge") + return + ) + if timestamp <= space.endsAt.toNum() ( + c.badRequest("submissions must close before results can be published") + return + ) + if challengeAudienceJudged(space) ( + if challengeArray(space, "audienceVotes").len == 0 ( + c.badRequest("at least one entry needs an audience rating") + return + ) + ) else if challengeArray(space, "scores").len == 0 ( + c.badRequest("at least one entry needs a judge score") + return + ) + space.resultsPublishedAt = timestamp + space.edited = timestamp + putSpaceDocument(space) + c.json(200, {ok: true, space: spaceResponse(space, c)}) +) diff --git a/src/community/community-tools.osl b/src/community/community-tools.osl deleted file mode 100644 index 481d662..0000000 --- a/src/community/community-tools.osl +++ /dev/null @@ -1,3039 +0,0 @@ -export { - creditPlaytimeSession, - migrateSpaceMemberships, - purgeUserCommunityRecords, - purgeUserFromSpaces, - recordProjectDiagnostic, - removeProjectFromSpaces, - removeProjectsFromSpaces, - stringArrayContainsUser, - syncSpaceMemberships, - warmShareCard, - xmlEscape -} - -def stringArrayContainsUser(array users, string username) boolean ( - string wanted = normalizeUsername(username) - for i users.len ( - if normalizeUsername(users[i].toStr()) == wanted ( - return true - ) - ) - return false -) - -def removeUser(array users, string username) array ( - string wanted = normalizeUsername(username) - array next = [] - for i users.len ( - if normalizeUsername(users[i].toStr()) != wanted ( - next.append(users[i]) - ) - ) - return next -) - -def validSpaceKind(string kind) boolean ( - return (kind == "studio") or (kind == "collection") or (kind == "challenge") -) - -def validSpaceVisibility(string visibility) boolean ( - return (visibility == "public") or (visibility == "unlisted") or (visibility == "private") -) - -def spaceManagers(object space) array ( - if space.contains("managers") ( - return space["managers"].assertElse(array, []) - ) - return [] -) - -def spaceCuratorInvites(object space) array ( - if space.contains("curatorInvites") ( - return space["curatorInvites"].assertElse(array, []) - ) - return [] -) - -def spaceLikes(object space) array ( - if space.contains("likes") ( - return space["likes"].assertElse(array, []) - ) - return [] -) - -def spaceBrokenHearts(object space) array ( - if space.contains("brokenHearts") ( - return space["brokenHearts"].assertElse(array, []) - ) - return [] -) - -def spaceProjectIds(object space) array ( - if space.contains("projects") and space.projects != null ( - return space.projects.assertElse(array, []) - ) - return [] -) - -def spaceFollowers(object space) array ( - if space.contains("followers") and space.followers != null ( - return space.followers.assertElse(array, []) - ) - return [] -) - -def spaceCommentKey(string id) string ( - return "space-" ++ id -) - -def spaceThumbnailPath(string id) string ( - return thumbnailsDir ++ "space-" ++ id ++ ".png" -) - -def spaceThumbnailUrl(object space) string ( - string path = spaceThumbnailPath(space["_id"].toStr()) - if !fs.exists(path) ( - return "" - ) - return appURL ++ "/thumbnails/space-" ++ space["_id"].toStr() ++ ".png?t=" ++ space.edited.toStr() -) - -def isSpaceOwner(object space,*serve.Context c) boolean ( - string ownerId = "" - if space.contains("ownerId") and space.ownerId != null ( - ownerId = space.ownerId.toStr() - ) - return contextBool(c, "isAdmin") or identityMatches(ownerId, space.owner.toStr(), contextString(c, "userId"), contextString(c, "username")) -) - -def searchEntryOwnedByContext(object project,*serve.Context c) boolean ( - string ownerId = "" - if project.contains("ownerId") and project.ownerId != null ( - ownerId = project.ownerId.toStr() - ) - return identityMatches(ownerId, project.owner.toStr(), contextString(c, "userId"), contextString(c, "username")) -) - -def isSpaceInvitee(object space,*serve.Context c) boolean ( - array invites = spaceCuratorInvites(space) - for i invites.len ( - object invite = invites[i].assert(object) - if normalizeUsername(invite.username.toStr()) == normalizeUsername(contextString(c, "username")) ( - return true - ) - ) - return false -) - -def canManageSpace(object space,*serve.Context c) boolean ( - if contextBool(c, "isAdmin") ( - return true - ) - string username = contextString(c, "username") - if isSpaceOwner(space, c) ( - return true - ) - if canManageGroupContent(space, c) ( - return true - ) - return stringArrayContainsUser(spaceManagers(space), username) -) - -def canViewSpace(object space,*serve.Context c) boolean ( - if space.visibility.toStr() == "public" or space.visibility.toStr() == "unlisted" ( - return true - ) - return canManageSpace(space, c) or isSpaceInvitee(space, c) -) - -def challengeArray(object space, string field) array ( - if space.contains(field) and space[field] != null ( - return space[field].assertElse(array, []) - ) - return [] -) - -def appendSpaceUserKey(array keys, string username) array ( - string key = normalizeUsername(username) - if key != "" and !keys.contains(key) ( - keys.append(key) - ) - return keys -) - -def spaceRelatedUserKeys(object space) array ( - array keys = appendSpaceUserKey([], space.owner.toStr()) - array managers = spaceManagers(space) - for i managers.len ( - keys = appendSpaceUserKey(keys, managers[i].toStr()) - ) - array followers = space.followers.assertElse(array, []) - for i followers.len ( - keys = appendSpaceUserKey(keys, followers[i].toStr()) - ) - array curatorInvites = spaceCuratorInvites(space) - for i curatorInvites.len ( - object invite = curatorInvites[i].assert(object) - keys = appendSpaceUserKey(keys, invite.username.toStr()) - ) - if space.kind.toStr() == "challenge" ( - array participants = challengeArray(space, "participants") - for i participants.len ( - keys = appendSpaceUserKey(keys, participants[i].toStr()) - ) - array judges = challengeArray(space, "judges") - for i judges.len ( - keys = appendSpaceUserKey(keys, judges[i].toStr()) - ) - array judgeInvites = challengeArray(space, "judgeInvites") - for i judgeInvites.len ( - object invite = judgeInvites[i].assert(object) - keys = appendSpaceUserKey(keys, invite.username.toStr()) - ) - ) - return keys -) - -def syncSpaceMemberships(object space) boolean ( - string spaceId = space["_id"].toStr() - array keys = spaceRelatedUserKeys(space) - string signature = json.stringify(keys) - string stateId = "space:" ++ spaceId - object state = (spaceMemberDocs.findById(stateId) ?? {}) - if state.len > 0 and state.signature.toStr() == signature ( - return true - ) - array previous = spaceMemberDocs.where("spaceId", "==", spaceId).all() - for i previous.len ( - object membership = previous[i].assert(object) - void spaceMemberDocs.deleteOne({_id: membership["_id"]}) - ) - for i keys.len ( - string userKey = keys[i].toStr() - void spaceMemberDocs.insertOne({_id: "member:" ++ userKey ++ ":" ++ spaceId, kind: "member", userKey: userKey, spaceId: spaceId}) - ) - return putDocument(spaceMemberDocs, stateId, {kind: "state", signature: signature}) -) - -def putSpaceDocument(object space) boolean ( - boolean saved = putDocument(spaceDocs, space["_id"].toStr(), space) - if saved ( - syncSpaceMemberships(space) - ) - return saved -) - -def deleteSpaceMemberships(string spaceId) boolean ( - array memberships = spaceMemberDocs.where("spaceId", "==", spaceId).all() - for i memberships.len ( - object membership = memberships[i].assert(object) - void spaceMemberDocs.deleteOne({_id: membership["_id"]}) - ) - void spaceMemberDocs.deleteOne({_id: "space:" ++ spaceId}) - return true -) - -def migrateSpaceMemberships() boolean ( - string migrationId = "migration:space-members-v1" - if (spaceMemberDocs.findById(migrationId) ?? {}).len > 0 ( - return true - ) - array previous = spaceMemberDocs.all() - for i previous.len ( - object document = previous[i].assert(object) - void spaceMemberDocs.deleteOne({_id: document["_id"]}) - ) - array spaces = spaceDocs.all() - for i spaces.len ( - syncSpaceMemberships(spaces[i].assert(object)) - ) - return putDocument(spaceMemberDocs, migrationId, {kind: "migration", completed: timestamp}) -) - -def removeProjectsFromSpaces(array removedProjectIds) boolean ( - array spaces = spaceDocs.all() - for i spaces.len ( - object space = spaces[i].assert(object) - array storedProjectIds = space.projects.assertElse(array, []) - array projectIds = [] - boolean changed = false - for j storedProjectIds.len ( - if removedProjectIds.contains(storedProjectIds[j].toStr()) ( - changed = true - ) else ( - projectIds.append(storedProjectIds[j]) - ) - ) - if changed ( - space.projects = projectIds - array submissions = [] - array storedSubmissions = challengeArray(space, "submissions") - for j storedSubmissions.len ( - object submission = storedSubmissions[j].assert(object) - if !removedProjectIds.contains(submission.projectId.toStr()) ( - submissions.append(submission) - ) - ) - space.submissions = submissions - array scores = [] - array storedScores = challengeArray(space, "scores") - for j storedScores.len ( - object score = storedScores[j].assert(object) - if !removedProjectIds.contains(score.projectId.toStr()) ( - scores.append(score) - ) - ) - space.scores = scores - array votes = [] - array storedVotes = challengeArray(space, "audienceVotes") - for j storedVotes.len ( - object vote = storedVotes[j].assert(object) - if !removedProjectIds.contains(vote.projectId.toStr()) ( - votes.append(vote) - ) - ) - space.audienceVotes = votes - space.edited = timestamp - putSpaceDocument(space) - ) - ) - return true -) - -def removeProjectFromSpaces(string projectId) boolean ( - return removeProjectsFromSpaces([projectId]) -) - -def purgeUserFromSpaces(string username) boolean ( - string key = normalizeUsername(username) - array spaces = spaceDocs.all() - for i spaces.len ( - object space = spaces[i].assert(object) - string spaceId = space["_id"].toStr() - if normalizeUsername(space.owner.toStr()) == key ( - void spaceDocs.deleteOne({_id: space["_id"]}) - deleteSpaceMemberships(spaceId) - void fs.remove(commentsDir ++ spaceCommentKey(spaceId) ++ ".json") - void fs.remove(commentsDir ++ spaceCommentKey(spaceId) ++ ".jsonl") - void fs.remove(spaceThumbnailPath(spaceId)) - ) else ( - boolean changed = spaceRelatedUserKeys(space).contains(key) - array likes = spaceLikes(space) - array broken = spaceBrokenHearts(space) - if stringArrayContainsUser(likes, key) or stringArrayContainsUser(broken, key) ( - changed = true - ) - space.managers = removeUser(spaceManagers(space), key) - space.followers = removeUser(space.followers.assertElse(array, []), key) - space.likes = removeUser(likes, key) - space.brokenHearts = removeUser(broken, key) - array curatorInvites = [] - array storedCuratorInvites = spaceCuratorInvites(space) - for j storedCuratorInvites.len ( - object invite = storedCuratorInvites[j].assert(object) - if normalizeUsername(invite.username.toStr()) != key ( - curatorInvites.append(invite) - ) - ) - space.curatorInvites = curatorInvites - if space.kind.toStr() == "challenge" ( - space.participants = removeUser(challengeArray(space, "participants"), key) - space.judges = removeUser(challengeArray(space, "judges"), key) - array judgeInvites = [] - array storedJudgeInvites = challengeArray(space, "judgeInvites") - for j storedJudgeInvites.len ( - object invite = storedJudgeInvites[j].assert(object) - if normalizeUsername(invite.username.toStr()) != key ( - judgeInvites.append(invite) - ) - ) - space.judgeInvites = judgeInvites - array scores = [] - array storedScores = challengeArray(space, "scores") - for j storedScores.len ( - object score = storedScores[j].assert(object) - if normalizeUsername(score.judge.toStr()) != key ( - scores.append(score) - ) else ( - changed = true - ) - ) - space.scores = scores - array votes = [] - array storedVotes = challengeArray(space, "audienceVotes") - for j storedVotes.len ( - object vote = storedVotes[j].assert(object) - if normalizeUsername(vote.username.toStr()) != key ( - votes.append(vote) - ) else ( - changed = true - ) - ) - space.audienceVotes = votes - ) - if changed ( - space.edited = timestamp - putSpaceDocument(space) - ) - ) - ) - return true -) - -def purgeUserCommunityRecords(string username, string userId) boolean ( - string key = normalizeUsername(username) - deleteDocumentsByField(reviewDocs, "authorKey", key) - deleteDocumentsByField(feedbackDocs, "author", username) - deleteDocumentsByField(playtimeDocs, "username", username) - if userId != "" ( - deleteDocumentsByField(playtimeDocs, "userId", userId) - deleteDocumentsByField(feedbackDocs, "authorId", userId) - ) - void playtimeSessionDocs.deleteOne({_id: key}) - - array releases = releaseDocs.where("creator", "==", username).all() - for i releases.len ( - object release = releases[i].assert(object) - release.creator = "Deleted User" - putDocument(releaseDocs, release["_id"].toStr(), release) - ) - - array ideas = ideaDocs.all() - for i ideas.len ( - object idea = ideas[i].assert(object) - string ideaId = idea["_id"].toStr() - if normalizeUsername(idea.author.toStr()) == key ( - void ideaDocs.deleteOne({_id: idea["_id"]}) - void fs.remove(commentsDir ++ "roadmap-" ++ ideaId ++ ".json") - void fs.remove(commentsDir ++ "roadmap-" ++ ideaId ++ ".jsonl") - ) else ( - array likes = [] - if idea.contains("likes") ( - likes = idea["likes"].assertElse(array, []) - ) else if idea.contains("voters") ( - likes = idea["voters"].assertElse(array, []) - ) - array dislikes = [] - if idea.contains("dislikes") ( - dislikes = idea["dislikes"].assertElse(array, []) - ) - array nextLikes = removeUser(likes, key) - array nextDislikes = removeUser(dislikes, key) - if nextLikes.len != likes.len or nextDislikes.len != dislikes.len ( - idea.likes = nextLikes - idea.dislikes = nextDislikes - idea.voters = [] - idea.votes = nextLikes.len - nextDislikes.len - putDocument(ideaDocs, ideaId, idea) - ) - ) - ) - return true -) - -def renameUserCommunityRecords(string oldKey, string newKey, string newName) boolean ( - array spaces = spaceDocs.all() - for i spaces.len ( - object space = spaces[i].assert(object) - boolean changed = spaceRelatedUserKeys(space).contains(oldKey) - array likes = spaceLikes(space) - array brokenHearts = spaceBrokenHearts(space) - if stringArrayContainsUser(likes, oldKey) or stringArrayContainsUser(brokenHearts, oldKey) ( - changed = true - ) - if normalizeUsername(space.owner.toStr()) == oldKey ( - space.owner = newName - ) - space.managers = replaceUserInArray(spaceManagers(space), oldKey, newName) - space.followers = replaceUserInArray(space.followers.assertElse(array, []), oldKey, newKey) - space.likes = replaceUserInArray(likes, oldKey, newKey) - space.brokenHearts = replaceUserInArray(brokenHearts, oldKey, newKey) - array curatorInvites = spaceCuratorInvites(space) - for j curatorInvites.len ( - object invite = curatorInvites[j].assert(object) - if normalizeUsername(invite.username.toStr()) == oldKey ( - invite.username = newName - changed = true - ) - if normalizeUsername(invite.invitedBy.toStr()) == oldKey ( - invite.invitedBy = newName - changed = true - ) - curatorInvites[j] = invite - ) - space.curatorInvites = curatorInvites - if space.kind.toStr() == "challenge" ( - space.participants = replaceUserInArray(challengeArray(space, "participants"), oldKey, newName) - space.judges = replaceUserInArray(challengeArray(space, "judges"), oldKey, newName) - array judgeInvites = challengeArray(space, "judgeInvites") - for j judgeInvites.len ( - object invite = judgeInvites[j].assert(object) - if normalizeUsername(invite.username.toStr()) == oldKey ( - invite.username = newName - changed = true - ) - if normalizeUsername(invite.invitedBy.toStr()) == oldKey ( - invite.invitedBy = newName - changed = true - ) - judgeInvites[j] = invite - ) - space.judgeInvites = judgeInvites - array scores = challengeArray(space, "scores") - for j scores.len ( - object score = scores[j].assert(object) - if normalizeUsername(score.judge.toStr()) == oldKey ( - score.judge = newName - scores[j] = score - changed = true - ) - ) - space.scores = scores - array votes = challengeArray(space, "audienceVotes") - for j votes.len ( - object vote = votes[j].assert(object) - if normalizeUsername(vote.username.toStr()) == oldKey ( - vote.username = newName - votes[j] = vote - changed = true - ) - ) - space.audienceVotes = votes - array submissions = challengeArray(space, "submissions") - for j submissions.len ( - object submission = submissions[j].assert(object) - if normalizeUsername(submission.owner.toStr()) == oldKey ( - submission.owner = newName - submissions[j] = submission - changed = true - ) - ) - space.submissions = submissions - ) - if changed ( - putSpaceDocument(space) - ) - ) - - array reviews = reviewDocs.where("authorKey", "==", oldKey).all() - for i reviews.len ( - object review = reviews[i].assert(object) - string oldId = review["_id"].toStr() - string newId = review.projectId.toStr() ++ ":" ++ newKey - review.author = newName - review.authorKey = newKey - void reviewDocs.deleteOne({_id: oldId}) - putDocument(reviewDocs, newId, review) - ) - - array playtimeRecords = playtimeDocs.all() - for i playtimeRecords.len ( - object playtime = playtimeRecords[i].assert(object) - if normalizeUsername(playtime.username.toStr()) == oldKey ( - string oldId = playtime["_id"].toStr() - string newId = playtime.projectId.toStr() ++ ":" ++ newKey - object existing = (playtimeDocs.findById(newId) ?? {}) - if existing.len > 0 and newId != oldId ( - playtime.duration = playtime.duration.toNum() + existing.duration.toNum() - ) - playtime.username = newName - void playtimeDocs.deleteOne({_id: oldId}) - putDocument(playtimeDocs, newId, playtime) - ) - ) - object session = (playtimeSessionDocs.findById(oldKey) ?? {}) - if session.len > 0 ( - session.username = newName - void playtimeSessionDocs.deleteOne({_id: oldKey}) - putDocument(playtimeSessionDocs, newKey, session) - ) - - array feedback = feedbackDocs.all() - for i feedback.len ( - object item = feedback[i].assert(object) - if normalizeUsername(item.author.toStr()) == oldKey ( - item.author = newName - putDocument(feedbackDocs, item["_id"].toStr(), item) - ) - ) - array releases = releaseDocs.all() - for i releases.len ( - object release = releases[i].assert(object) - if normalizeUsername(release.creator.toStr()) == oldKey ( - release.creator = newName - putDocument(releaseDocs, release["_id"].toStr(), release) - ) - ) - array pulls = pullDocs.all() - for i pulls.len ( - object pull = pulls[i].assert(object) - boolean changed = false - if normalizeUsername(pull.author.toStr()) == oldKey ( - pull.author = newName - changed = true - ) - if normalizeUsername(pull.sourceOwner.toStr()) == oldKey ( - pull.sourceOwner = newName - changed = true - ) - if changed ( - putDocument(pullDocs, pull["_id"].toStr(), pull) - ) - ) - - array ideas = ideaDocs.all() - for i ideas.len ( - object idea = ideas[i].assert(object) - boolean changed = false - if normalizeUsername(idea.author.toStr()) == oldKey ( - idea.author = newName - changed = true - ) - array likes = [] - if idea.contains("likes") ( - likes = idea["likes"].assertElse(array, []) - ) else if idea.contains("voters") ( - likes = idea["voters"].assertElse(array, []) - ) - array dislikes = [] - if idea.contains("dislikes") ( - dislikes = idea["dislikes"].assertElse(array, []) - ) - if stringArrayContainsUser(likes, oldKey) or stringArrayContainsUser(dislikes, oldKey) ( - idea.likes = replaceUserInArray(likes, oldKey, newName) - idea.dislikes = replaceUserInArray(dislikes, oldKey, newName) - idea.voters = [] - changed = true - ) - if changed ( - putDocument(ideaDocs, idea["_id"].toStr(), idea) - ) - ) - return true -) - -def challengeCriteria(object space) array ( - array criteria = challengeArray(space, "criteria") - if criteria.len == 0 ( - return [{id: "overall", name: "Overall", description: "How strong is the entry as a whole?", weight: 1}] - ) - array normalized = [] - for i criteria.len ( - object criterion = criteria[i].assert(object) - criterion.weight = clampNumber(criterion.weight.toNum(), 1, 5) - normalized.append(criterion) - ) - return normalized -) - -def challengePhase(object space) string ( - if space.contains("resultsPublishedAt") and space.resultsPublishedAt.toNum() > 0 ( - return "results" - ) - number startsAt = space.startsAt.toNum() - number endsAt = space.endsAt.toNum() - number judgingEndsAt = 0 - if space.contains("judgingEndsAt") ( - judgingEndsAt = space.judgingEndsAt.toNum() - ) - if startsAt > 0 and timestamp < startsAt ( - return "upcoming" - ) - if endsAt == 0 or timestamp <= endsAt ( - return "submissions" - ) - if judgingEndsAt == 0 or timestamp <= judgingEndsAt ( - return "judging" - ) - return "awaiting-results" -) - -def challengeVotingMode(object space) string ( - if space.contains("votingMode") and space.votingMode.toStr() == "audience" ( - return "audience" - ) - return "judges" -) - -def challengeAudienceJudged(object space) boolean ( - return challengeVotingMode(space) == "audience" -) - -def challengeAudienceVotingEnabled(object space) boolean ( - return challengeAudienceJudged(space) or (space.contains("communityVoting") and space.communityVoting == true) -) - -def challengeJudges(object space) array ( - if challengeAudienceJudged(space) ( - return [] - ) - array judges = challengeArray(space, "judges") - string tag = groupTagOf(space) - if tag != "" ( - array members = groupMemberNames(roturGroupSnapshot(tag)) - for i members.len ( - string member = members[i].toStr() - if !stringArrayContainsUser(judges, member) ( - judges.append(member) - ) - ) - ) - return judges -) - -def isChallengeJudge(object space, string username) boolean ( - return stringArrayContainsUser(challengeJudges(space), username) -) - -def isChallengeJudgeInvitee(object space, string username) boolean ( - array invites = challengeArray(space, "judgeInvites") - for i invites.len ( - object invite = invites[i].assert(object) - if normalizeUsername(invite.username.toStr()) == normalizeUsername(username) ( - return true - ) - ) - return false -) - -def challengeScoreSummary(object space, string projectId, string viewer) object ( - array criteria = challengeCriteria(space) - array scores = challengeArray(space, "scores") - number total = 0 - number scoreCount = 0 - object myScore = {} - array entryScores = [] - for i scores.len ( - object score = scores[i].assert(object) - if score.projectId.toStr() == projectId ( - entryScores.append(score) - number weighted = 0 - number weightTotal = 0 - array ratings = score.ratings.assertElse(array, []) - for r ratings.len ( - object rating = ratings[r].assert(object) - for k criteria.len ( - object criterion = criteria[k].assert(object) - if criterion.id.toStr() == rating.criterionId.toStr() ( - number weight = criterion.weight.toNum() - weighted += (clampNumber(rating.value.toNum(), 1, 10) * weight) - weightTotal += weight - ) - ) - ) - if weightTotal > 0 ( - total += (weighted / weightTotal) - scoreCount += 1 - ) - if normalizeUsername(score.judge.toStr()) == normalizeUsername(viewer) ( - myScore = score - ) - ) - ) - number audienceTotal = 0 - number audienceCount = 0 - number myVote = 0 - array votes = challengeArray(space, "audienceVotes") - for i votes.len ( - object vote = votes[i].assert(object) - if vote.projectId.toStr() == projectId ( - audienceTotal += vote.value.toNum() - audienceCount += 1 - if normalizeUsername(vote.username.toStr()) == normalizeUsername(viewer) ( - myVote = vote.value.toNum() - ) - ) - ) - number judgeScore = 0 - number audienceScore = 0 - if scoreCount > 0 ( - judgeScore = total / scoreCount - ) - if audienceCount > 0 ( - audienceScore = audienceTotal / audienceCount - ) - return {judgeScore: judgeScore, judgeScoreCount: scoreCount, audienceScore: audienceScore, audienceVoteCount: audienceCount, myScore: myScore, myVote: myVote, scores: entryScores} -) - -def challengeProjects(object space,*serve.Context c) array ( - array projects = [] - array ids = spaceProjectIds(space) - boolean showResults = (space.contains("resultsPublishedAt") and space.resultsPublishedAt.toNum() > 0) or canManageSpace(space, c) - boolean audienceJudged = challengeAudienceJudged(space) - string phase = challengePhase(space) - boolean revealRatings = phase == "awaiting-results" or phase == "results" or canManageSpace(space, c) - for i ids.len ( - auto loaded = loadProject(ids[i].toStr()) - if loaded.ok == true ( - object project = loaded.project.assert(object) - if project.shared == true or projectVisibility(project) == "unlisted" or canManageSpace(space, c) ( - object entry = indexEntryFromProject(project) - object summary = challengeScoreSummary(space, project.id.toStr(), contextString(c, "username")) - entry.judgeScore = if showResults then summary.judgeScore else 0 - entry.judgeScoreCount = summary.judgeScoreCount - entry.audienceScore = if revealRatings then summary.audienceScore else 0 - entry.audienceVoteCount = if revealRatings then summary.audienceVoteCount else 0 - entry.rankScore = entry.judgeScore - if audienceJudged and showResults ( - entry.rankScore = (((summary.audienceScore.toNum() * 100) + 0.5).toInt() * 1000000) + clampNumber(summary.audienceVoteCount.toNum(), 0, 999999) - ) - entry.myVote = summary.myVote - entry.myScore = if isChallengeJudge(space, contextString(c, "username")) then summary.myScore else {} - entry.scoreBreakdown = if canManageSpace(space, c) then summary.scores else [] - projects.append(entry) - ) - ) - ) - projects = projectsVisibleTo(projects, c) - if showResults ( - projects = rankChallengeEntries(projects) - ) - return projects -) - -def challengeWinner(object space, array projects) object ( - if challengePhase(space) != "results" ( - return {} - ) - for i projects.len ( - object entry = projects[i].assert(object) - if entry.contains("place") and entry.place.toNum() == 1 ( - boolean audienceJudged = challengeAudienceJudged(space) - return { - id: entry.id, - title: entry.title, - owner: entry.owner, - thumbUrl: entry.thumbUrl, - vanitySlug: entry.vanitySlug, - score: if audienceJudged then entry.audienceScore else entry.judgeScore, - scoreOutOf: if audienceJudged then 5 else 10, - voteCount: entry.audienceVoteCount - } - ) - ) - return {} -) - -def spaceTotalPlaytime(array ids) number ( - number total = 0 - for i ids.len ( - total += projectTotalPlaytime(ids[i].toStr()) - ) - return total -) - -def spaceResponse(object space,*serve.Context c) object ( - if !space.contains("commentCount") ( - space.commentCount = loadComments(spaceCommentKey(space["_id"].toStr())).len - putSpaceDocument(space) - ) - array projects = [] - array ids = spaceProjectIds(space) - if space.kind.toStr() == "challenge" ( - projects = challengeProjects(space, c) - ) else ( - for i ids.len ( - auto loaded = loadProject(ids[i].toStr()) - if loaded.ok == true ( - object project = loaded.project.assert(object) - if project.shared == true or projectVisibility(project) == "unlisted" or canManageSpace(space, c) ( - projects.append(indexEntryFromProject(project)) - ) - ) - ) - projects = projectsVisibleTo(projects, c) - ) - object response = space - response.projects = projects - array visibleIds = [] - for i projects.len ( - object visibleProject = projects[i].assert(object) - visibleIds.append(visibleProject.id) - ) - response.projectIds = if canManageSpace(space, c) then ids else visibleIds - response.canManage = canManageSpace(space, c) - response.isOwner = isSpaceOwner(space, c) - response.following = stringArrayContainsUser(spaceFollowers(space), contextString(c, "username")) - response.followerCount = spaceFollowers(space).len - response.invited = isSpaceInvitee(space, c) - array likes = spaceLikes(space) - array brokenHearts = spaceBrokenHearts(space) - response.likeCount = likes.len - response.brokenHeartCount = brokenHearts.len - response.commentCount = space.commentCount - response.myReaction = "" - if stringArrayContainsUser(likes, contextString(c, "username")) ( - response.myReaction = "heart" - ) else if stringArrayContainsUser(brokenHearts, contextString(c, "username")) ( - response.myReaction = "brokenheart" - ) - response.likes = [] - response.brokenHearts = [] - response.curatorInvites = [] - response.thumbnailUrl = spaceThumbnailUrl(space) - if space.kind.toStr() != "challenge" ( - response.totalPlaytimeMs = spaceTotalPlaytime(ids) - ) - if space.kind.toStr() == "challenge" ( - if !response.contains("theme") ( - response.theme = "" - ) - if !response.contains("rules") ( - response.rules = "" - ) - if !response.contains("judgingEndsAt") ( - response.judgingEndsAt = space.endsAt.toNum() + 604800000 - ) - response.criteria = challengeCriteria(space) - if !response.contains("communityVoting") ( - response.communityVoting = false - ) - response.votingMode = challengeVotingMode(space) - response.winner = challengeWinner(space, projects) - if !response.contains("resultsPublishedAt") ( - response.resultsPublishedAt = 0 - ) - response.phase = challengePhase(space) - response.isJudge = isChallengeJudge(space, contextString(c, "username")) - response.judgeInvited = isChallengeJudgeInvitee(space, contextString(c, "username")) - response.judgeCount = challengeJudges(space).len - response.participantCount = challengeArray(space, "participants").len - response.joined = stringArrayContainsUser(challengeArray(space, "participants"), contextString(c, "username")) - response.judges = challengeJudges(space) - response.judgeInvites = [] - response.scores = [] - response.audienceVotes = [] - response.participants = [] - ) - return response -) - -def spaceListResponse(object space,*serve.Context c) object ( - array ids = spaceProjectIds(space) - array followers = spaceFollowers(space) - string thumbnailURL = spaceThumbnailUrl(space) - number projectCount = 0 - boolean canManage = canManageSpace(space, c) - for i ids.len ( - object project = (projectSearchDocs.findById(ids[i].toStr()) ?? {}) - boolean hidden = projectHiddenFrom(project, c) - if project.len > 0 and !hidden and (project.shared == true or project.visibility.toStr() == "unlisted" or canManage) ( - projectCount += 1 - if thumbnailURL == "" and project.contains("thumbUrl") and thumbnailIsPublic(projectVisibility(project), false) ( - thumbnailURL = project.thumbUrl.toStr() - ) - ) - ) - object response = { - _id: space["_id"], - title: space.title, - description: space.description, - kind: space.kind, - visibility: space.visibility, - owner: space.owner, - groupTag: groupTagOf(space), - edited: space.edited, - startsAt: space.startsAt, - endsAt: space.endsAt, - openSubmissions: space.openSubmissions, - projects: [], - projectCount: projectCount, - followers: [], - followerCount: followers.len, - thumbnailUrl: thumbnailURL, - canManage: canManage, - isOwner: isSpaceOwner(space, c), - following: stringArrayContainsUser(followers, contextString(c, "username")), - invited: isSpaceInvitee(space, c) - } - if space.kind.toStr() == "challenge" ( - response.participantCount = challengeArray(space, "participants").len - ) else ( - response.totalPlaytimeMs = spaceTotalPlaytime(ids) - ) - return response -) - -def spaceManagementResponse(object space,*serve.Context c) object ( - array invites = spaceCuratorInvites(space) - array judgeInvites = challengeArray(space, "judgeInvites") - array participants = challengeArray(space, "participants") - object response = spaceResponse(space, c) - response.curatorInvites = invites - if space.kind.toStr() == "challenge" ( - response.judgeInvites = judgeInvites - response.participants = participants - ) - return response -) - -def handleListSpaces(*serve.Context c) ( - string kind = (c.query("kind") ?? "").toLower().strip() - string q = (c.query("q") ?? "").toLower().strip() - number limit = clampNumber(c.queryInt("limit", 24), 1, 100) - number offset = c.queryInt("offset", 0) - number startsBefore = c.queryInt("startsBefore", 0) - number endsAfter = c.queryInt("endsAfter", 0) - number resultsAfter = c.queryInt("resultsAfter", 0) - array stored = [] - - if q == "" and kind == "challenge" and resultsAfter > 0 ( - stored = spaceDocs.where("visibility", "==", "public").where("kind", "==", kind).where("resultsPublishedAt", ">=", resultsAfter).sort("resultsPublishedAt", "desc").limit(limit).all() - array spaces = [] - for i stored.len ( - object published = stored[i].assert(object) - object listed = spaceListResponse(published, c) - listed.resultsPublishedAt = published.resultsPublishedAt - listed.winner = challengeWinner(published, challengeProjects(published, c)) - if listed.winner.len > 0 ( - spaces.append(listed) - ) - ) - c.json(200, {ok: true, total: spaces.len, spaces: spaces}) - return - ) - - if q == "" and kind == "challenge" and startsBefore > 0 and endsAfter > 0 ( - number total = spaceDocs.where("visibility", "==", "public").where("kind", "==", kind).where("startsAt", "<=", startsBefore).where("endsAt", ">=", endsAfter).count() - offset = clampNumber(offset, 0, total) - stored = spaceDocs.where("visibility", "==", "public").where("kind", "==", kind).where("startsAt", "<=", startsBefore).where("endsAt", ">=", endsAfter).sort("startsAt", "asc").limit(offset + limit).all() - array page = paginate(stored, offset, limit) - array spaces = [] - for i page.len ( - spaces.append(spaceListResponse(page[i].assert(object), c)) - ) - c.json(200, {ok: true, total: total, spaces: spaces}) - return - ) - - if q == "" ( - number total = 0 - if kind != "" ( - total = spaceDocs.where("visibility", "==", "public").where("kind", "==", kind).count() - offset = clampNumber(offset, 0, total) - stored = spaceDocs.where("visibility", "==", "public").where("kind", "==", kind).sort("edited", "desc").limit(offset + limit).all() - ) else ( - total = spaceDocs.where("visibility", "==", "public").count() - offset = clampNumber(offset, 0, total) - stored = spaceDocs.where("visibility", "==", "public").sort("edited", "desc").limit(offset + limit).all() - ) - array page = paginate(stored, offset, limit) - array spaces = [] - for i page.len ( - spaces.append(spaceListResponse(page[i].assert(object), c)) - ) - c.json(200, {ok: true, total: total, spaces: spaces}) - return - ) - - if kind != "" ( - stored = spaceDocs.where("visibility", "==", "public").where("kind", "==", kind).sort("edited", "desc").all() - ) else ( - stored = spaceDocs.where("visibility", "==", "public").sort("edited", "desc").all() - ) - array matches = [] - for i stored.len ( - object space = stored[i].assert(object) - boolean include = space.visibility.toStr() == "public" - if include and kind != "" ( - include = space.kind.toStr() == kind - ) - if include and q != "" ( - include = space.title.toStr().toLower().contains(q) or space.description.toStr().toLower().contains(q) - ) - if include ( - number score = spaceSearchScore(space.title.toStr(), space.description.toStr(), space.owner.toStr(), q) - matches.append({rank: searchRank(score, spaceFollowers(space).len), space: space}) - ) - ) - matches.sortBy("rank", "descending") - number total = matches.len - offset = clampNumber(offset, 0, total) - array page = paginate(matches, offset, limit) - array spaces = [] - for i page.len ( - object row = page[i].assert(object) - spaces.append(spaceListResponse(row.space.assert(object), c)) - ) - c.json(200, {ok: true, total: total, spaces: spaces}) -) - -def handleGetSpace(*serve.Context c) ( - object space = (spaceDocs.findById(c.param("id")) ?? {}) - if space.len == 0 or !canViewSpace(space, c) ( - c.notFound("space not found") - return - ) - c.json(200, {ok: true, space: spaceResponse(space, c)}) -) - -def handleCreateSpace(*serve.Context c) ( - object body = requestJSON(c) - number ownedSpaces = spaceDocs.where("ownerId", "==", contextString(c, "userId")).count() - if !contextBool(c, "isAdmin") and ownedSpaces >= mistwarpBenefitsForUserId(contextString(c, "userId")).maxSpaces.toNum() ( - c.json(413, {ok: false, error: "space limit reached; delete a space or upgrade your plan"}) - return - ) - string groupTag = "" - if body.contains("groupTag") and body.groupTag != null and body.groupTag.toStr().strip() != "" ( - groupTag = normalizeGroupTag(body.groupTag.toStr()) - if groupTag == "" ( - c.badRequest("invalid group tag") - return - ) - if !canManageGroupTag(groupTag, c) ( - c.forbidden("you cannot create spaces for this group") - return - ) - ) - string title = body.title.toStr().strip() - string kind = body.kind.toStr().toLower().strip() - if title == "" or title.len > 100 ( - c.badRequest("title must be between 1 and 100 characters") - return - ) - if !validSpaceKind(kind) ( - c.badRequest("invalid space type") - return - ) - string visibility = "public" - if body.contains("visibility") ( - visibility = body.visibility.toStr().toLower() - ) - if !validSpaceVisibility(visibility) ( - c.badRequest("invalid visibility") - return - ) - string description = "" - if body.contains("description") ( - description = body.description.toStr().strip() - ) - if description.len > 5000 ( - c.badRequest("description is too long") - return - ) - number startsAt = 0 - number endsAt = 0 - if body.contains("startsAt") ( - startsAt = body.startsAt.toNum() - ) - if body.contains("endsAt") ( - endsAt = body.endsAt.toNum() - ) - if kind == "challenge" and (startsAt <= 0 or endsAt <= startsAt) ( - c.badRequest("challenge dates must be in order") - return - ) - string votingMode = "judges" - if body.contains("votingMode") ( - votingMode = body.votingMode.toStr().toLower().strip() - ) - if votingMode != "judges" and votingMode != "audience" ( - c.badRequest("invalid voting mode") - return - ) - string id = "s" ++ randomString(12) - object space = { - _id: id, - title: title, - description: description, - kind: kind, - visibility: visibility, - owner: contextString(c, "username"), - ownerId: contextString(c, "userId"), - groupTag: groupTag, - managers: [], - curatorInvites: [], - projects: [], - followers: [], - likes: [], - brokenHearts: [], - openSubmissions: body.contains("openSubmissions") and body.openSubmissions == true, - startsAt: startsAt, - endsAt: endsAt, - judgingEndsAt: endsAt + 604800000, - theme: "", - rules: "", - criteria: [{id: "overall", name: "Overall", description: "How strong is the entry as a whole?", weight: 1}], - judges: [], - judgeInvites: [], - scores: [], - audienceVotes: [], - submissions: [], - participants: [], - commentCount: 0, - communityVoting: false, - votingMode: votingMode, - resultsPublishedAt: 0, - created: timestamp, - edited: timestamp - } - putSpaceDocument(space) - c.json(201, {ok: true, space: spaceResponse(space, c)}) -) - -def handleUpdateSpace(*serve.Context c) ( - object space = (spaceDocs.findById(c.param("id")) ?? {}) - if space.len == 0 ( - c.notFound("space not found") - return - ) - if !canManageSpace(space, c) ( - c.forbidden("you cannot manage this space") - return - ) - object body = requestJSON(c) - if body.contains("owner") ( - boolean canTransfer = isSpaceOwner(space, c) or canManageGroupContent(space, c) - if !canTransfer ( - c.forbidden("you cannot transfer this space") - return - ) - string requestedOwner = body.owner.toStr().strip() - string nextOwnerId = discoverUserIdentity(requestedOwner) - if nextOwnerId == "" ( - c.badRequest("Rotur user not found") - return - ) - string nextOwner = usernameForId(nextOwnerId) - if nextOwner == "" ( - c.badRequest("Rotur user not found") - return - ) - number nextOwnerLimit = mistwarpBenefitsForUserId(nextOwnerId).maxSpaces.toNum() - if !identityMatches((space.ownerId ?? "").toStr(), space.owner.toStr(), nextOwnerId, nextOwner) and spaceDocs.where("ownerId", "==", nextOwnerId).count() >= nextOwnerLimit ( - c.badRequest("the new owner has reached their space limit") - return - ) - space.owner = nextOwner - space.ownerId = nextOwnerId - if !body.contains("keepGroup") or body.keepGroup != true ( - space.groupTag = "" - ) - ) - if body.contains("groupTag") ( - string nextGroupTag = normalizeGroupTag(body.groupTag.toStr()) - if body.groupTag.toStr().strip() == "" ( - nextGroupTag = "" - ) else if nextGroupTag == "" ( - c.badRequest("invalid group tag") - return - ) else if !canManageGroupTag(nextGroupTag, c) ( - c.forbidden("you cannot move this space into that group") - return - ) - space.groupTag = nextGroupTag - ) - if body.contains("title") ( - string title = body.title.toStr().strip() - if title == "" or title.len > 100 ( - c.badRequest("invalid title") - return - ) - space.title = title - ) - if body.contains("description") ( - string description = body.description.toStr().strip() - if description.len > 5000 ( - c.badRequest("description is too long") - return - ) - space.description = description - ) - if body.contains("visibility") and validSpaceVisibility(body.visibility.toStr()) ( - space.visibility = body.visibility - ) - if body.contains("openSubmissions") ( - space.openSubmissions = body.openSubmissions == true - ) - if body.contains("startsAt") ( - space.startsAt = body.startsAt.toNum() - ) - if body.contains("endsAt") ( - space.endsAt = body.endsAt.toNum() - ) - if body.contains("judgingEndsAt") ( - space.judgingEndsAt = body.judgingEndsAt.toNum() - ) - if body.contains("theme") ( - string theme = body.theme.toStr().strip() - if theme.len > 200 ( - c.badRequest("theme is too long") - return - ) - space.theme = theme - ) - if body.contains("rules") ( - string rules = body.rules.toStr().strip() - if rules.len > 10000 ( - c.badRequest("rules are too long") - return - ) - space.rules = rules - ) - if body.contains("communityVoting") ( - space.communityVoting = body.communityVoting == true - ) - if body.contains("votingMode") and space.kind.toStr() == "challenge" ( - string votingMode = body.votingMode.toStr().toLower().strip() - if votingMode != "judges" and votingMode != "audience" ( - c.badRequest("invalid voting mode") - return - ) - if votingMode != challengeVotingMode(space) and challengePhase(space) == "results" ( - c.badRequest("the voting mode cannot change after results are published") - return - ) - space.votingMode = votingMode - ) - if body.contains("criteria") and space.kind.toStr() == "challenge" ( - if challengeArray(space, "scores").len > 0 ( - c.badRequest("judging criteria cannot change after scoring begins") - return - ) - array rawCriteria = body.criteria.assertElse(array, []) - array criteria = [] - for i rawCriteria.len ( - object rawCriterion = rawCriteria[i].assert(object) - string name = rawCriterion.name.toStr().strip() - string description = rawCriterion.description.toStr().strip() - if name != "" and name.len <= 60 and description.len <= 300 and criteria.len < 8 ( - string criterionId = rawCriterion.id.toStr().strip() - if criterionId == "" ( - criterionId = "jc" ++ randomString(8) - ) - criteria.append({id: criterionId, name: name, description: description, weight: clampNumber(rawCriterion.weight.toNum(), 1, 5)}) - ) - ) - if criteria.len == 0 ( - c.badRequest("add at least one judging criterion") - return - ) - space.criteria = criteria - ) - if space.kind.toStr() == "challenge" and (space.startsAt.toNum() <= 0 or space.endsAt.toNum() <= space.startsAt.toNum() or space.judgingEndsAt.toNum() <= space.endsAt.toNum()) ( - c.badRequest("challenge dates must be in order") - return - ) - space.edited = timestamp - putSpaceDocument(space) - c.json(200, {ok: true, space: spaceResponse(space, c)}) -) - -def handleDeleteSpace(*serve.Context c) ( - object space = (spaceDocs.findById(c.param("id")) ?? {}) - if space.len == 0 ( - c.notFound("space not found") - return - ) - if !isSpaceOwner(space, c) ( - c.forbidden("only the owner can delete this space") - return - ) - void spaceDocs.deleteOne({_id: space["_id"]}) - deleteSpaceMemberships(space["_id"].toStr()) - void fs.remove(commentsDir ++ spaceCommentKey(space["_id"].toStr()) ++ ".json") - void fs.remove(commentsDir ++ spaceCommentKey(space["_id"].toStr()) ++ ".jsonl") - if fs.exists(spaceThumbnailPath(space["_id"].toStr())) ( - void fs.remove(spaceThumbnailPath(space["_id"].toStr())) - ) - c.json(200, {ok: true}) -) - -def handleSetSpaceThumbnail(*serve.Context c) ( - object space = (spaceDocs.findById(c.param("id")) ?? {}) - if space.len == 0 ( - c.notFound("space not found") - return - ) - if space.kind.toStr() != "studio" or !canManageSpace(space, c) ( - c.forbidden("you cannot change this studio thumbnail") - return - ) - auto fileResult = c.formFile("thumbnail") - if fileResult.isErr() ( - c.badRequest("missing thumbnail file") - return - ) - object thumbnail = fileResult.unwrap() - if thumbnail.size.toNum() > maxThumbBytes ( - c.badRequest("thumbnail too large") - return - ) - if !fs.writeFileBytes(spaceThumbnailPath(space["_id"].toStr()), thumbnail.data) ( - c.internalError("thumbnail save failed") - return - ) - space.edited = timestamp - putSpaceDocument(space) - c.json(200, {ok: true, thumbnailUrl: spaceThumbnailUrl(space)}) -) - -def handleAddSpaceProject(*serve.Context c) ( - object space = (spaceDocs.findById(c.param("id")) ?? {}) - object project = (projectSearchDocs.findById(c.param("project")) ?? {}) - if space.len == 0 or project.len == 0 ( - c.notFound("space or project not found") - return - ) - boolean projectVisible = project.shared == true or project.visibility.toStr() == "unlisted" - if !projectVisible ( - c.forbidden("only shared or unlisted projects can be added") - return - ) - boolean allowed = canManageSpace(space, c) - boolean ownsProject = searchEntryOwnedByContext(project, c) - boolean participantCanSubmit = space.openSubmissions == true and ownsProject - if space.kind.toStr() == "challenge" ( - participantCanSubmit = participantCanSubmit and challengePhase(space) == "submissions" - ) - allowed = allowed or participantCanSubmit - if !allowed ( - c.forbidden("submissions are closed") - return - ) - array ids = spaceProjectIds(space) - if !ids.contains(project.id.toStr()) ( - ids.append(project.id.toStr()) - space.projects = ids - if space.kind.toStr() == "challenge" ( - array submissions = challengeArray(space, "submissions") - submissions.append({projectId: project.id, owner: project.owner, submittedAt: timestamp}) - space.submissions = submissions - array participants = challengeArray(space, "participants") - if !stringArrayContainsUser(participants, project.owner.toStr()) ( - participants.append(project.owner) - ) - space.participants = participants - ) - space.edited = timestamp - putSpaceDocument(space) - addNotification(space.owner.toStr(), contextString(c, "username"), "space_project", {spaceId: space["_id"], spaceTitle: space.title, projectId: project.id, projectTitle: project.title}) - ) - c.json(200, {ok: true, space: spaceResponse(space, c)}) -) - -def handleRemoveSpaceProject(*serve.Context c) ( - object space = (spaceDocs.findById(c.param("id")) ?? {}) - if space.len == 0 ( - c.notFound("space not found") - return - ) - object project = (projectSearchDocs.findById(c.param("project")) ?? {}) - boolean allowed = canManageSpace(space, c) - if project.len > 0 ( - allowed = allowed or searchEntryOwnedByContext(project, c) - ) - if !allowed ( - c.forbidden("you cannot remove this project") - return - ) - space.projects = removeString(spaceProjectIds(space), c.param("project")) - if space.kind.toStr() == "challenge" ( - array submissions = challengeArray(space, "submissions") - array nextSubmissions = [] - for i submissions.len ( - object submission = submissions[i].assert(object) - if submission.projectId.toStr() != c.param("project") ( - nextSubmissions.append(submission) - ) - ) - space.submissions = nextSubmissions - ) - space.edited = timestamp - putSpaceDocument(space) - c.json(200, {ok: true, space: spaceResponse(space, c)}) -) - -def handleInviteChallengeJudge(*serve.Context c) ( - object space = (spaceDocs.findById(c.param("id")) ?? {}) - if space.len == 0 or space.kind.toStr() != "challenge" ( - c.notFound("challenge not found") - return - ) - if !canManageSpace(space, c) ( - c.forbidden("you cannot manage this challenge") - return - ) - if challengeAudienceJudged(space) ( - c.badRequest("this challenge is decided by audience ratings") - return - ) - string requested = requestJSON(c).username.toStr().strip() - string usernameKey = normalizeUsername(requested) - object profile = (profileSearchDocs.findById(usernameKey) ?? {}) - if usernameKey == "" or profile.len == 0 ( - c.notFound("user not found") - return - ) - if normalizeUsername(space.owner.toStr()) == usernameKey or isChallengeJudge(space, requested) or isChallengeJudgeInvitee(space, requested) ( - c.badRequest("this user is already on the challenge team") - return - ) - object invite = {username: profile.username, invitedBy: contextString(c, "username"), created: timestamp} - array invites = challengeArray(space, "judgeInvites") - invites.append(invite) - space.judgeInvites = invites - space.edited = timestamp - putSpaceDocument(space) - addNotification(profile.username.toStr(), contextString(c, "username"), "challenge_judge_invite", {spaceId: space["_id"], spaceTitle: space.title}) - c.json(201, {ok: true, invitation: invite}) -) - -def handleJoinChallenge(*serve.Context c) ( - object space = (spaceDocs.findById(c.param("id")) ?? {}) - if space.len == 0 or space.kind.toStr() != "challenge" or !canViewSpace(space, c) ( - c.notFound("challenge not found") - return - ) - string phase = challengePhase(space) - if phase != "upcoming" and phase != "submissions" ( - c.badRequest("this challenge is no longer open to new participants") - return - ) - array participants = challengeArray(space, "participants") - if !stringArrayContainsUser(participants, contextString(c, "username")) ( - participants.append(contextString(c, "username")) - space.participants = participants - space.edited = timestamp - putSpaceDocument(space) - if !isSpaceOwner(space, c) ( - addNotification(space.owner.toStr(), contextString(c, "username"), "challenge_join", {spaceId: space["_id"], spaceTitle: space.title}) - ) - ) - c.json(200, {ok: true, joined: true, participantCount: participants.len}) -) - -def handleLeaveChallenge(*serve.Context c) ( - object space = (spaceDocs.findById(c.param("id")) ?? {}) - if space.len == 0 or space.kind.toStr() != "challenge" ( - c.notFound("challenge not found") - return - ) - if challengePhase(space) != "upcoming" and challengePhase(space) != "submissions" ( - c.badRequest("participants are locked for this challenge") - return - ) - string username = contextString(c, "username") - array participants = challengeArray(space, "participants") - if !stringArrayContainsUser(participants, username) ( - c.json(200, {ok: true, joined: false, participantCount: participants.len}) - return - ) - array ids = spaceProjectIds(space) - for i ids.len ( - object project = (projectSearchDocs.findById(ids[i].toStr()) ?? {}) - if project.len > 0 and searchEntryOwnedByContext(project, c) ( - c.badRequest("remove your submission before leaving the challenge") - return - ) - ) - space.participants = removeUser(participants, username) - space.edited = timestamp - putSpaceDocument(space) - c.json(200, {ok: true, joined: false, participantCount: space.participants.len}) -) - -def handleRespondChallengeJudgeInvite(*serve.Context c) ( - object space = (spaceDocs.findById(c.param("id")) ?? {}) - if space.len == 0 or !isChallengeJudgeInvitee(space, contextString(c, "username")) ( - c.notFound("judge invitation not found") - return - ) - array invites = challengeArray(space, "judgeInvites") - array nextInvites = [] - for i invites.len ( - object invite = invites[i].assert(object) - if normalizeUsername(invite.username.toStr()) != normalizeUsername(contextString(c, "username")) ( - nextInvites.append(invite) - ) - ) - space.judgeInvites = nextInvites - if requestJSON(c).accepted == true ( - array judges = challengeArray(space, "judges") - if !stringArrayContainsUser(judges, contextString(c, "username")) ( - judges.append(contextString(c, "username")) - ) - space.judges = judges - addNotification(space.owner.toStr(), contextString(c, "username"), "challenge_judge_accepted", {spaceId: space["_id"], spaceTitle: space.title}) - ) - space.edited = timestamp - putSpaceDocument(space) - c.json(200, {ok: true, space: spaceResponse(space, c)}) -) - -def handleRemoveChallengeJudge(*serve.Context c) ( - object space = (spaceDocs.findById(c.param("id")) ?? {}) - if space.len == 0 or !canManageSpace(space, c) ( - c.forbidden("you cannot manage this challenge") - return - ) - string username = c.param("username") - array judges = challengeArray(space, "judges") - boolean changed = stringArrayContainsUser(judges, username) - space.judges = removeUser(judges, username) - array scores = challengeArray(space, "scores") - array nextScores = [] - for i scores.len ( - object score = scores[i].assert(object) - if normalizeUsername(score.judge.toStr()) != normalizeUsername(username) ( - nextScores.append(score) - ) - ) - if nextScores.len != scores.len ( - changed = true - ) - space.scores = nextScores - if changed ( - space.edited = timestamp - putSpaceDocument(space) - ) - c.json(200, {ok: true}) -) - -def validChallengeCriterion(object space, string criterionId) boolean ( - array criteria = challengeCriteria(space) - for i criteria.len ( - object criterion = criteria[i].assert(object) - if criterion.id.toStr() == criterionId ( - return true - ) - ) - return false -) - -def handleScoreChallengeEntry(*serve.Context c) ( - object space = (spaceDocs.findById(c.param("id")) ?? {}) - string projectId = c.param("project") - if space.len == 0 or space.kind.toStr() != "challenge" or !spaceProjectIds(space).contains(projectId) ( - c.notFound("challenge entry not found") - return - ) - if !isChallengeJudge(space, contextString(c, "username")) ( - c.forbidden("only invited judges can score entries") - return - ) - if challengePhase(space) != "judging" ( - c.badRequest("judging is not open") - return - ) - object project = (projectSearchDocs.findById(projectId) ?? {}) - if project.len > 0 and searchEntryOwnedByContext(project, c) ( - c.forbidden("you cannot score your own entry") - return - ) - object body = requestJSON(c) - array rawRatings = body.ratings.assertElse(array, []) - array ratings = [] - array ratedCriteria = [] - for i rawRatings.len ( - object rawRating = rawRatings[i].assert(object) - string criterionId = rawRating.criterionId.toStr() - number value = rawRating.value.toNum() - if validChallengeCriterion(space, criterionId) and !ratedCriteria.contains(criterionId) and value >= 1 and value <= 10 ( - ratings.append({criterionId: criterionId, value: value}) - ratedCriteria.append(criterionId) - ) - ) - if ratings.len != challengeCriteria(space).len ( - c.badRequest("score every criterion from 1 to 10") - return - ) - string feedback = body.feedback.toStr().strip() - if feedback.len > 2000 ( - c.badRequest("feedback is too long") - return - ) - string judge = contextString(c, "username") - array scores = challengeArray(space, "scores") - array nextScores = [] - for i scores.len ( - object existing = scores[i].assert(object) - if existing.projectId.toStr() != projectId or normalizeUsername(existing.judge.toStr()) != normalizeUsername(judge) ( - nextScores.append(existing) - ) - ) - object score = {projectId: projectId, judge: judge, ratings: ratings, feedback: feedback, edited: timestamp} - nextScores.append(score) - space.scores = nextScores - space.edited = timestamp - putSpaceDocument(space) - c.json(200, {ok: true, score: score}) -) - -def handleVoteChallengeEntry(*serve.Context c) ( - object space = (spaceDocs.findById(c.param("id")) ?? {}) - string projectId = c.param("project") - if space.len == 0 or space.kind.toStr() != "challenge" or !spaceProjectIds(space).contains(projectId) ( - c.notFound("challenge entry not found") - return - ) - if !challengeAudienceVotingEnabled(space) or challengePhase(space) != "judging" ( - c.badRequest("community voting is not open") - return - ) - object project = (projectSearchDocs.findById(projectId) ?? {}) - if project.len > 0 and searchEntryOwnedByContext(project, c) ( - c.forbidden("you cannot vote for your own entry") - return - ) - number value = requestJSON(c).value.toNum() - if value < 1 or value > 5 ( - c.badRequest("rating must be between 1 and 5") - return - ) - string username = contextString(c, "username") - array votes = challengeArray(space, "audienceVotes") - array nextVotes = [] - for i votes.len ( - object vote = votes[i].assert(object) - if vote.projectId.toStr() != projectId or normalizeUsername(vote.username.toStr()) != normalizeUsername(username) ( - nextVotes.append(vote) - ) - ) - nextVotes.append({projectId: projectId, username: username, value: value, edited: timestamp}) - space.audienceVotes = nextVotes - putSpaceDocument(space) - c.json(200, {ok: true, value: value}) -) - -def handlePublishChallengeResults(*serve.Context c) ( - object space = (spaceDocs.findById(c.param("id")) ?? {}) - if space.len == 0 or space.kind.toStr() != "challenge" or !canManageSpace(space, c) ( - c.forbidden("you cannot manage this challenge") - return - ) - if timestamp <= space.endsAt.toNum() ( - c.badRequest("submissions must close before results can be published") - return - ) - if challengeAudienceJudged(space) ( - if challengeArray(space, "audienceVotes").len == 0 ( - c.badRequest("at least one entry needs an audience rating") - return - ) - ) else if challengeArray(space, "scores").len == 0 ( - c.badRequest("at least one entry needs a judge score") - return - ) - space.resultsPublishedAt = timestamp - space.edited = timestamp - putSpaceDocument(space) - c.json(200, {ok: true, space: spaceResponse(space, c)}) -) - -def handleFollowSpace(*serve.Context c) ( - object space = (spaceDocs.findById(c.param("id")) ?? {}) - if space.len == 0 or !canViewSpace(space, c) ( - c.notFound("space not found") - return - ) - array followers = spaceFollowers(space) - if !stringArrayContainsUser(followers, contextString(c, "username")) ( - followers.append(contextString(c, "username")) - space.followers = followers - putSpaceDocument(space) - ) - c.json(200, {ok: true, following: true, followers: followers.len}) -) - -def handleUnfollowSpace(*serve.Context c) ( - object space = (spaceDocs.findById(c.param("id")) ?? {}) - if space.len == 0 ( - c.notFound("space not found") - return - ) - array followers = spaceFollowers(space) - if stringArrayContainsUser(followers, contextString(c, "username")) ( - followers = removeUser(followers, contextString(c, "username")) - space.followers = followers - putSpaceDocument(space) - ) - c.json(200, {ok: true, following: false, followers: followers.len}) -) - -def handleMySpaces(*serve.Context c) ( - string userKey = normalizeUsername(contextString(c, "username")) - array memberships = spaceMemberDocs.where("userKey", "==", userKey).all() - array stored = [] - object seen = {} - for i memberships.len ( - object membership = memberships[i].assert(object) - object space = (spaceDocs.findById(membership.spaceId.toStr()) ?? {}) - if space.len > 0 ( - seen[space["_id"].toStr()] = true - stored.append(space) - ) - ) - array allSpaces = spaceDocs.all() - for i allSpaces.len ( - object space = allSpaces[i].assert(object) - string spaceId = space["_id"].toStr() - if !seen.contains(spaceId) ( - boolean include = isSpaceOwner(space, c) - if !include and groupTagOf(space) != "" ( - include = canManageGroupContent(space, c) - ) - if include ( - seen[spaceId] = true - stored.append(space) - ) - ) - ) - stored.sortBy("edited", "descending") - array spaces = [] - for i stored.len ( - object space = stored[i].assert(object) - spaces.append(spaceListResponse(space, c)) - ) - c.json(200, {ok: true, spaces: spaces}) -) - -def handleGetSpaceManagement(*serve.Context c) ( - object space = (spaceDocs.findById(c.param("id")) ?? {}) - if space.len == 0 ( - c.notFound("space not found") - return - ) - if !canManageSpace(space, c) ( - c.forbidden("you cannot manage this space") - return - ) - c.json(200, {ok: true, space: spaceManagementResponse(space, c)}) -) - -def handleInviteSpaceCurator(*serve.Context c) ( - object space = (spaceDocs.findById(c.param("id")) ?? {}) - if space.len == 0 ( - c.notFound("space not found") - return - ) - if !isSpaceOwner(space, c) ( - c.forbidden("only the owner can invite curators") - return - ) - string username = requestJSON(c).username.toStr().strip() - string usernameKey = normalizeUsername(username) - if usernameKey == "" or username.len > 20 or usernameKey == normalizeUsername(space.owner.toStr()) ( - c.badRequest("choose another user") - return - ) - object profile = (profileSearchDocs.findById(usernameKey) ?? {}) - if profile.len == 0 ( - c.notFound("user not found") - return - ) - if stringArrayContainsUser(spaceManagers(space), username) ( - c.badRequest("this user is already a curator") - return - ) - array invites = spaceCuratorInvites(space) - for i invites.len ( - object existing = invites[i].assert(object) - if normalizeUsername(existing.username.toStr()) == usernameKey ( - c.badRequest("this user already has an invitation") - return - ) - ) - object invite = {username: profile.username, invitedBy: contextString(c, "username"), created: timestamp} - invites.append(invite) - space.curatorInvites = invites - space.edited = timestamp - putSpaceDocument(space) - addNotification(profile.username.toStr(), contextString(c, "username"), "space_curator_invite", {spaceId: space["_id"], spaceTitle: space.title}) - c.json(201, {ok: true, invitation: invite}) -) - -def handleRespondSpaceCuratorInvite(*serve.Context c) ( - object space = (spaceDocs.findById(c.param("id")) ?? {}) - if space.len == 0 ( - c.notFound("space not found") - return - ) - string username = contextString(c, "username") - array invites = spaceCuratorInvites(space) - array remaining = [] - boolean found = false - for i invites.len ( - object invite = invites[i].assert(object) - if normalizeUsername(invite.username.toStr()) == normalizeUsername(username) ( - found = true - ) else ( - remaining.append(invite) - ) - ) - if !found ( - c.notFound("invitation not found") - return - ) - boolean accepted = requestJSON(c).accepted == true - if accepted ( - array managers = spaceManagers(space) - if !stringArrayContainsUser(managers, username) ( - managers.append(username) - ) - space.managers = managers - ) - space.curatorInvites = remaining - space.edited = timestamp - putSpaceDocument(space) - string notificationType = "space_curator_declined" - if accepted ( - notificationType = "space_curator_accepted" - ) - addNotification(space.owner.toStr(), username, notificationType, {spaceId: space["_id"], spaceTitle: space.title}) - c.json(200, {ok: true, accepted: accepted}) -) - -def handleRemoveSpaceCurator(*serve.Context c) ( - object space = (spaceDocs.findById(c.param("id")) ?? {}) - if space.len == 0 ( - c.notFound("space not found") - return - ) - if !isSpaceOwner(space, c) ( - c.forbidden("only the owner can remove curators") - return - ) - string username = c.param("username") - array managers = spaceManagers(space) - if stringArrayContainsUser(managers, username) ( - space.managers = removeUser(managers, username) - space.edited = timestamp - putSpaceDocument(space) - addNotification(username, contextString(c, "username"), "space_curator_removed", {spaceId: space["_id"], spaceTitle: space.title}) - ) - c.json(200, {ok: true}) -) - -def handleCancelSpaceCuratorInvite(*serve.Context c) ( - object space = (spaceDocs.findById(c.param("id")) ?? {}) - if space.len == 0 ( - c.notFound("space not found") - return - ) - if !isSpaceOwner(space, c) ( - c.forbidden("only the owner can cancel invitations") - return - ) - string username = c.param("username") - array remaining = [] - array invites = spaceCuratorInvites(space) - boolean found = false - for i invites.len ( - object invite = invites[i].assert(object) - if normalizeUsername(invite.username.toStr()) != normalizeUsername(username) ( - remaining.append(invite) - ) else ( - found = true - ) - ) - if found ( - space.curatorInvites = remaining - space.edited = timestamp - putSpaceDocument(space) - ) - c.json(200, {ok: true}) -) - -def handleReactSpace(*serve.Context c) ( - string lockName = "space-reactions:" ++ c.param("id") - sync.lock(lockName) - handleReactSpaceLocked(c) - sync.unlock(lockName) -) - -def handleReactSpaceLocked(*serve.Context c) ( - object space = (spaceDocs.findById(c.param("id")) ?? {}) - if space.len == 0 or !canViewSpace(space, c) ( - c.notFound("space not found") - return - ) - string reactionType = requestJSON(c).type.toStr() - if !isValidReaction(reactionType) ( - c.badRequest("invalid reaction") - return - ) - string username = contextString(c, "username") - array likes = spaceLikes(space) - array brokenHearts = spaceBrokenHearts(space) - number previousLikes = likes.len - boolean hadReaction = false - if reactionType == "heart" ( - hadReaction = stringArrayContainsUser(likes, username) - ) else ( - hadReaction = stringArrayContainsUser(brokenHearts, username) - ) - likes = removeUser(likes, username) - brokenHearts = removeUser(brokenHearts, username) - if !hadReaction and reactionType == "heart" ( - likes.append(username) - ) else if !hadReaction and reactionType == "brokenheart" ( - brokenHearts.append(username) - ) - space.likes = likes - space.brokenHearts = brokenHearts - space.edited = timestamp - if !putSpaceDocument(space) ( - c.internalError("could not save reaction") - return - ) - notifyLikeMilestones(space.owner.toStr(), space.kind.toStr(), space["_id"].toStr(), previousLikes, likes.len, "/spaces/" ++ space["_id"].toStr(), {}) - c.json(200, {ok: true, likeCount: likes.len, brokenHeartCount: brokenHearts.len, myReaction: if hadReaction then "" else reactionType}) -) - -def handleGetSpaceComments(*serve.Context c) ( - object space = (spaceDocs.findById(c.param("id")) ?? {}) - if space.len == 0 or !canViewSpace(space, c) ( - c.notFound("space not found") - return - ) - object page = commentPage(loadComments(spaceCommentKey(space["_id"].toStr())), c) - page.ok = true - c.json(200, page) -) - -def handleCreateSpaceComment(*serve.Context c) ( - object space = (spaceDocs.findById(c.param("id")) ?? {}) - if space.len == 0 or !canViewSpace(space, c) ( - c.notFound("space not found") - return - ) - object body = requestJSON(c) - if !body.contains("content") or body.content == null ( - c.badRequest("comment content is required") - return - ) - string parentId = "" - if body.contains("parent") and body.parent != null ( - parentId = body.parent.toStr() - ) - object created = createComment(spaceCommentKey(space["_id"].toStr()), contextString(c, "username"), contextString(c, "userId"), body.content.toStr(), parentId, "comment", 0, "") - if !created.ok ( - c.badRequest(created.error.toStr()) - return - ) - if space.contains("commentCount") ( - space.commentCount = space.commentCount.toNum() + 1 - ) else ( - space.commentCount = loadComments(spaceCommentKey(space["_id"].toStr())).len - ) - putSpaceDocument(space) - string commenter = contextString(c, "username") - string parentAuthor = created.parentAuthor.toStr() - string newCommentId = created.comment.id.toStr() - object notificationData = {spaceId: space["_id"], spaceTitle: space.title, commentId: newCommentId, preview: created.comment.content.toStr()} - if parentAuthor != "" and normalizeUsername(parentAuthor) != normalizeUsername(commenter) ( - addNotification(parentAuthor, commenter, "reply", notificationData) - ) - if normalizeUsername(space.owner.toStr()) != normalizeUsername(commenter) and normalizeUsername(space.owner.toStr()) != normalizeUsername(parentAuthor) ( - addNotification(space.owner.toStr(), commenter, "space_comment", notificationData) - ) - notifyMentions(created.comment.content.toStr(), commenter, [space.owner.toStr(), parentAuthor], notificationData) - c.json(201, created) -) - -def handleDeleteSpaceComment(*serve.Context c) ( - object space = (spaceDocs.findById(c.param("id")) ?? {}) - if space.len == 0 ( - c.notFound("space not found") - return - ) - string moderator = space.owner.toStr() - if canManageSpace(space, c) ( - moderator = contextString(c, "username") - ) - object deleted = deleteComment(spaceCommentKey(space["_id"].toStr()), c.param("cid"), contextString(c, "username"), moderator, contextBool(c, "isAdmin")) - if !deleted.ok ( - c.json(deleted.status.toInt(), {ok: false, error: deleted.error}) - return - ) - if space.contains("commentCount") ( - space.commentCount = clampNumber(space.commentCount.toNum() - deleted.removed.toNum(), 0, space.commentCount.toNum()) - ) else ( - space.commentCount = loadComments(spaceCommentKey(space["_id"].toStr())).len - ) - putSpaceDocument(space) - c.json(200, {ok: true}) -) - -def handleEditSpaceComment(*serve.Context c) ( - object space = (spaceDocs.findById(c.param("id")) ?? {}) - if space.len == 0 or !canViewSpace(space, c) ( - c.notFound("space not found") - return - ) - object body = requestJSON(c) - if !body.contains("content") or body.content == null ( - c.badRequest("comment content is required") - return - ) - object edited = editComment(spaceCommentKey(space["_id"].toStr()), c.param("cid"), contextString(c, "username"), body.content.toStr()) - if !edited.ok ( - c.json(edited.status.toInt(), {ok: false, error: edited.error}) - return - ) - c.json(200, edited) -) - -def handlePinSpaceComment(*serve.Context c) ( - object space = (spaceDocs.findById(c.param("id")) ?? {}) - if space.len == 0 or !canViewSpace(space, c) ( - c.notFound("space not found") - return - ) - string moderator = space.owner.toStr() - if canManageSpace(space, c) ( - moderator = contextString(c, "username") - ) - object body = requestJSON(c) - boolean pinned = true - if body.contains("pinned") and body.pinned != null ( - pinned = body.pinned == true - ) - object updated = setCommentPin(spaceCommentKey(space["_id"].toStr()), c.param("cid"), contextString(c, "username"), moderator, contextBool(c, "isAdmin"), pinned) - if !updated.ok ( - c.json(updated.status.toInt(), {ok: false, error: updated.error}) - return - ) - updated.comment = commentResponse(updated.comment.assert(object), contextString(c, "username")) - c.json(200, updated) -) - -def handleReactSpaceComment(*serve.Context c) ( - object space = (spaceDocs.findById(c.param("id")) ?? {}) - if space.len == 0 or !canViewSpace(space, c) ( - c.notFound("space not found") - return - ) - reactToComment(c, spaceCommentKey(space["_id"].toStr()), "/spaces/" ++ space["_id"].toStr()) -) - -def handleCreatePreview(*serve.Context c) ( - auto res = loadProject(c.param("id")) - if !res.ok ( - c.notFound("project not found") - return - ) - object project = res.project.assert(object) - if !canEditProject(project, c) ( - c.forbidden("not your project") - return - ) - if projectMustStayPrivate(project) ( - c.forbidden("remixes of paid projects cannot create shareable preview links") - return - ) - array previews = previewDocs.where("projectId", "==", project.id.toStr()).all() - number activePreviews = 0 - for i previews.len ( - object existing = previews[i].assert(object) - if existing.expiresAt.toNum() <= timestamp ( - void previewDocs.deleteOne({_id: existing["_id"]}) - ) else ( - activePreviews += 1 - ) - ) - if activePreviews >= 20 ( - c.badRequest("a project can have at most 20 active previews") - return - ) - object body = requestJSON(c) - number hours = 24 - if body.contains("hours") ( - hours = clampNumber(body.hours.toNum(), 1, 168) - ) - string key = randomString(24) - string previewId = project.id.toStr() ++ ":" ++ key - object preview = {_id: previewId, projectId: project.id, key: key, owner: contextString(c, "username"), ownerId: contextString(c, "userId"), created: timestamp, expiresAt: timestamp + (hours * 3600000)} - void previewDocs.insertOne(preview) - c.json(201, {ok: true, key: key, expiresAt: preview.expiresAt}) -) - -def handleListReleases(*serve.Context c) ( - c.setHeader("Cache-Control", "private, no-store") - c.setHeader("CDN-Cache-Control", "private, no-store") - auto res = loadProject(c.param("id")) - if !res.ok or !canViewProject(res.project.assert(object), c) ( - c.notFound("project not found") - return - ) - object project = res.project.assert(object) - boolean canEdit = canEditProject(project, c) - ensureViewKey(project) - object jsonAccess = projectJsonAccess(project, c) - array stored = releaseDocs.where("projectId", "==", c.param("id")).sort("created", "desc").limit(100).all() - array releases = [] - for i stored.len ( - object release = stored[i].assert(object) - if release.channel.toStr() != "checkpoint" or canEdit ( - releases.append(publicReleaseRecord(release, jsonAccess)) - ) - ) - c.json(200, {ok: true, releases: releases}) -) - -def handleCreateRelease(*serve.Context c) ( - auto res = loadProject(c.param("id")) - if !res.ok ( - c.notFound("project not found") - return - ) - object project = res.project.assert(object) - if !canEditProject(project, c) ( - c.forbidden("not your project") - return - ) - object body = requestJSON(c) - string version = body.version.toStr().strip() - string releaseChannel = body.channel.toStr().toLower().strip() - if version == "" or version.len > 50 ( - c.badRequest("invalid version") - return - ) - if releaseChannel != "stable" and releaseChannel != "beta" and releaseChannel != "development" and releaseChannel != "checkpoint" ( - c.badRequest("invalid release channel") - return - ) - if releaseChannel == "checkpoint" and mistwarpBenefitsForProject(project).historyCheckpoints != true ( - c.forbidden("named history checkpoints require Rotur Lite, Plus, or Pro") - return - ) - number releaseLimit = mistwarpBenefitsForProject(project).maxProjectReleases.toNum() - if releaseDocs.where("projectId", "==", project.id.toStr()).limit(releaseLimit.toInt()).all().len >= releaseLimit ( - c.badRequest("this project has reached its release limit") - return - ) - string notes = "" - if body.contains("notes") ( - notes = body.notes.toStr().strip() - ) - if notes.len > 10000 ( - c.badRequest("release notes are too long") - return - ) - string releaseId = project.id.toStr() ++ ":" ++ randomString(12) - string commit = "" - if body.contains("commit") ( - commit = body.commit.toStr() - ) - object snapshot = ensureProjectJsonSnapshot(project) - if !snapshot.ok ( - c.internalError("could not snapshot the current project") - return - ) - project = snapshot.project.assert(object) - string jsonUrl = appURL ++ "/v1/projects/" ++ project.id.toStr() ++ "/releases/" ++ url.escape(releaseId) ++ "/project.json" - // jsonBytes is the snapshot's size, which counts against the owner's - // storage for as long as the release keeps it. - object release = {_id: releaseId, projectId: project.id, version: version, channel: releaseChannel, notes: notes, commit: commit, jsonKey: project.jsonKey, jsonBytes: (project.storedJsonBytes ?? 0).toNum(), jsonGzip: true, jsonUrl: jsonUrl, assetsBase: assetsBaseUrl(), created: timestamp, creator: contextString(c, "username"), creatorId: contextString(c, "userId")} - void releaseDocs.insertOne(release) - storageSettleProject(project) - if releaseChannel == "stable" ( - project.publicRelease = releaseId - saveProject(project) - ) - if releaseChannel != "checkpoint" ( - addActivity(contextString(c, "username"), "release", {projectId: project.id, projectTitle: project.title, version: version}) - ) - c.json(201, {ok: true, release: publicReleaseRecord(release, projectJsonAccess(project, c))}) -) - -def handleGetReleaseProjectJson(*serve.Context c) ( - object release = (releaseDocs.findById(c.param("release")) ?? {}) - if release.len == 0 or release.projectId.toStr() != c.param("id") ( - c.notFound("release not found") - return - ) - auto res = loadProject(c.param("id")) - if !res.ok or !canViewProjectJson(res.project.assert(object), c) ( - c.notFound("release not found") - return - ) - string releaseEtag = "\"" ++ release["_id"].toStr() ++ "\"" - c.setHeader("ETag", releaseEtag) - c.setHeader("Cache-Control", "no-cache") - if (c.header("If-None-Match") ?? "").contains(releaseEtag) ( - c.status(304) - return - ) - if release.contains("jsonKey") and release.jsonKey.toStr() != "" ( - serveProjectJsonStorageKey(c, release.jsonKey.toStr(), release.contains("jsonGzip") and release.jsonGzip == true) - return - ) - string releasePath = releasesDir ++ release["_id"].toStr().replace(":", "-") ++ ".json" - if !fs.exists(releasePath) ( - c.notFound("release snapshot not found") - return - ) - c.setHeader("Content-Type", "application/json") - c.text(200, fs.readFile(releasePath).unwrapOr("")) -) - -def handleDeleteRelease(*serve.Context c) ( - auto res = loadProject(c.param("id")) - if !res.ok ( - c.notFound("project not found") - return - ) - object project = res.project.assert(object) - if !canEditProject(project, c) ( - c.forbidden("not your project") - return - ) - object release = (releaseDocs.findById(c.param("release")) ?? {}) - if release.len == 0 or release.projectId.toStr() != project.id.toStr() ( - c.notFound("release not found") - return - ) - string jsonKey = (release.jsonKey ?? "").toStr() - void releaseDocs.deleteOne({_id: release["_id"]}) - void fs.remove(releasesDir ++ release["_id"].toStr().replace(":", "-") ++ ".json") - if jsonKey != "" ( - removeProjectStorageKeyIfOrphaned(jsonKey, "", false) - ) - storageSettleProject(project) - if project.publicRelease.toStr() == release["_id"].toStr() ( - project.publicRelease = "" - saveProject(project) - ) - c.json(200, {ok: true}) -) - -def handleCreateContribution(*serve.Context c) ( - auto parentResult = loadProject(c.param("id")) - object body = requestJSON(c) - auto remixResult = loadProject(body.remixProjectId.toStr()) - if !parentResult.ok or !remixResult.ok ( - c.notFound("project not found") - return - ) - object parent = parentResult.project.assert(object) - object remix = remixResult.project.assert(object) - string title = body.title.toStr().strip() - string description = "" - if body.contains("body") ( - description = body.body.toStr() - ) - string bountyId = "" - if body.contains("bountyId") and body.bountyId != null ( - bountyId = body.bountyId.toStr().strip() - ) - object created = createNativePull(c, parent, remix, title, description, bountyId) - if !created.ok ( - c.json(created.status.toInt(), {ok: false, error: created.error}) - return - ) - c.json(201, created) -) - -def playtimeIdentity(string username) string ( - string userId = userIdForUsername(username) - if userId != "" ( - return userId - ) - return normalizeUsername(username) -) - -def beginPlaytimeSession(string projectId, string username, string sessionId) boolean ( - string userId = userIdForUsername(username) - string identityKey = playtimeIdentity(username) - object session = (playtimeSessionDocs.findById(sessionId) ?? {}) - if session.len == 0 or session.projectId.toStr() != projectId or timestamp - session.lastSeen.toNum() > 120000 ( - putDocument(playtimeSessionDocs, sessionId, { - _id: sessionId, - projectId: projectId, - username: username, - userId: userId, - identityKey: identityKey, - lastCredit: timestamp, - lastSeen: timestamp - }) - ) - return true -) - -def touchRecentProjectActivity(string projectId, string username) boolean ( - string userId = userIdForUsername(username) - string identityKey = userId - if identityKey == "" ( - identityKey = normalizeUsername(username) - ) - string playtimeId = projectId ++ ":" ++ identityKey - string playtimeLock = "playtime:" ++ playtimeId - sync.lock(playtimeLock) - object playtime = (playtimeDocs.findById(playtimeId) ?? {}) - if playtime.len == 0 ( - playtime = {_id: playtimeId, projectId: projectId, username: username, userId: userId, duration: 0, updated: timestamp} - ) else ( - playtime.username = username - playtime.userId = userId - playtime.updated = timestamp - ) - playtime.projectId = projectId - boolean saved = putDocument(playtimeDocs, playtimeId, playtime) - sync.unlock(playtimeLock) - return saved -) - -def creditPlaytimeSession(string projectId, string username, string sessionId, boolean finished) number ( - string userId = userIdForUsername(username) - object session = (playtimeSessionDocs.findById(sessionId) ?? {}) - if session.len == 0 or session.projectId.toStr() != projectId ( - return 0 - ) - number credited = clampNumber(timestamp - session.lastCredit.toNum(), 0, 60000) - if credited > 0 ( - string identityKey = session.identityKey.assertElse(string, playtimeIdentity(username)) - string playtimeId = projectId ++ ":" ++ identityKey - string playtimeLock = "playtime:" ++ playtimeId - sync.lock(playtimeLock) - object playtime = (playtimeDocs.findById(playtimeId) ?? {}) - if playtime.len == 0 ( - playtime = {_id: playtimeId, projectId: projectId, username: username, userId: userId, duration: 0, updated: timestamp} - ) - playtime.duration = playtime.duration.toNum() + credited - playtime.updated = timestamp - playtime.projectId = projectId - putDocument(playtimeDocs, playtimeId, playtime) - sync.unlock(playtimeLock) - ) - if finished ( - void playtimeSessionDocs.deleteOne({_id: sessionId}) - ) else ( - session.lastCredit = timestamp - session.lastSeen = timestamp - putDocument(playtimeSessionDocs, sessionId, session) - ) - return credited -) - -def recordProjectDiagnostic(string projectId, boolean authenticated, string username, string sessionId, object body) object ( - string eventType = body.type.toStr().toLower().strip() - if eventType != "load" and eventType != "start" and eventType != "crash" and eventType != "exit" and eventType != "playtime" and eventType != "playtime_start" and eventType != "playtime_ping" and eventType != "playtime_finish" ( - return {ok: false, error: "invalid diagnostic type"} - ) - string diagnosticId = projectId ++ ":" ++ randomString(16) - number duration = 0 - number loadMs = 0 - string errorText = "" - string device = "" - if body.contains("duration") ( - duration = clampNumber(body.duration.toNum(), 0, 86400000) - ) - if body.contains("loadMs") ( - loadMs = clampNumber(body.loadMs.toNum(), 0, 600000) - ) - if body.contains("error") ( - errorText = body.error.toStr().trim(1, 500) - ) - if body.contains("device") ( - device = body.device.toStr().trim(1, 40) - ) - if eventType == "playtime_start" or eventType == "playtime_ping" or eventType == "playtime_finish" or eventType == "playtime" ( - if !authenticated ( - return {ok: true} - ) - if eventType == "playtime_start" ( - beginPlaytimeSession(projectId, username, sessionId) - ) else if eventType == "playtime_finish" ( - creditPlaytimeSession(projectId, username, sessionId, true) - ) else ( - creditPlaytimeSession(projectId, username, sessionId, false) - ) - return {ok: true} - ) - if eventType == "start" and authenticated ( - beginPlaytimeSession(projectId, username, sessionId) - ) - if eventType == "load" and authenticated ( - touchRecentProjectActivity(projectId, username) - ) - object item = {_id: diagnosticId, projectId: projectId, type: eventType, duration: duration, loadMs: loadMs, error: errorText, device: device, created: timestamp} - string diagnosticLock = "project-diagnostics-" ++ projectId - sync.lock(diagnosticLock) - void diagnosticDocs.insertOne(item) - array storedDiagnostics = diagnosticDocs.where("projectId", "==", projectId).sort("created", "desc").limit(550).all() - for i storedDiagnostics.len ( - if i > 500 ( - void diagnosticDocs.deleteOne({_id: storedDiagnostics[i]["_id"]}) - ) - ) - sync.unlock(diagnosticLock) - return {ok: true} -) - -def handleRecordDiagnostic(*serve.Context c) ( - auto projectResult = loadProject(c.param("id")) - if !projectResult.ok or !projectIsViewable(projectResult.project.assert(object)) ( - c.notFound("project not found") - return - ) - string sessionId = playtimeIdentity(contextString(c, "username")) - object res = recordProjectDiagnostic( - c.param("id"), - contextBool(c, "authenticated"), - contextString(c, "username"), - sessionId, - requestJSON(c) - ) - if !res.ok ( - c.badRequest(res.error.toStr()) - return - ) - c.json(201, {ok: true}) -) - -def handleGetDiagnostics(*serve.Context c) ( - auto res = loadProject(c.param("id")) - if !res.ok or !canEditProject(res.project.assert(object), c) ( - c.forbidden("not your project") - return - ) - array items = diagnosticDocs.where("projectId", "==", c.param("id")).sort("created", "desc").limit(500).all() - object counts = {load: 0, start: 0, crash: 0, exit: 0} - number totalLoadMs = 0 - number loadSamples = 0 - for i items.len ( - object item = items[i].assert(object) - string kind = item.type.toStr() - counts[kind] = counts[kind].toNum() + 1 - if item.loadMs.toNum() > 0 ( - totalLoadMs += item.loadMs.toNum() - loadSamples += 1 - ) - ) - number averageLoadMs = 0 - if loadSamples > 0 ( - averageLoadMs = totalLoadMs / loadSamples - ) - c.json(200, {ok: true, counts: counts, averageLoadMs: averageLoadMs, recent: paginate(items, 0, 50)}) -) - -def handleCreateProjectFeedback(*serve.Context c) ( - auto res = loadProject(c.param("id")) - if !res.ok or !canViewProject(res.project.assert(object), c) ( - c.notFound("project not found") - return - ) - object project = res.project.assert(object) - if feedbackDocs.where("projectId", "==", project.id.toStr()).limit(200).all().len >= 200 ( - c.badRequest("this project has reached its feedback limit") - return - ) - object body = requestJSON(c) - string feedbackType = body.type.toStr().toLower().strip() - if feedbackType != "bug" and feedbackType != "idea" and feedbackType != "confusing" and feedbackType != "other" ( - c.badRequest("invalid feedback type") - return - ) - string message = body.message.toStr().strip() - if message == "" or message.len > 2000 ( - c.badRequest("feedback must be between 1 and 2000 characters") - return - ) - string feedbackId = project.id.toStr() ++ ":" ++ randomString(16) - object feedback = {_id: feedbackId, projectId: project.id, type: feedbackType, message: message, status: "open", author: contextString(c, "username"), authorId: contextString(c, "userId"), created: timestamp, edited: timestamp} - void feedbackDocs.insertOne(feedback) - addNotification(project.owner.toStr(), contextString(c, "username"), "project_feedback", {projectId: project.id, projectTitle: project.title, feedbackType: feedbackType}) - c.json(201, {ok: true}) -) - -def handleGetProjectFeedback(*serve.Context c) ( - auto res = loadProject(c.param("id")) - if !res.ok or !canEditProject(res.project.assert(object), c) ( - c.forbidden("not your project") - return - ) - array feedback = feedbackDocs.where("projectId", "==", c.param("id")).sort("created", "desc").limit(200).all() - c.json(200, {ok: true, feedback: feedback}) -) - -def handleUpdateProjectFeedback(*serve.Context c) ( - auto res = loadProject(c.param("id")) - if !res.ok or !canEditProject(res.project.assert(object), c) ( - c.forbidden("not your project") - return - ) - object feedback = (feedbackDocs.findById(c.param("feedback")) ?? {}) - if feedback.len == 0 or feedback.projectId.toStr() != c.param("id") ( - c.notFound("feedback not found") - return - ) - string status = requestJSON(c).status.toStr().toLower() - if status != "open" and status != "working" and status != "done" and status != "dismissed" ( - c.badRequest("invalid feedback status") - return - ) - feedback.status = status - feedback.edited = timestamp - putDocument(feedbackDocs, feedback["_id"].toStr(), feedback) - c.json(200, {ok: true, feedback: feedback}) -) - -def handleListProjectReviews(*serve.Context c) ( - auto res = loadProject(c.param("id")) - if !res.ok or !canViewProject(res.project.assert(object), c) ( - c.notFound("project not found") - return - ) - array reviews = reviewsVisibleTo(reviewDocs.where("projectId", "==", c.param("id")).sort("edited", "desc").limit(200).all(), c) - number ratingTotal = 0 - object myReview = {} - for i reviews.len ( - object review = reviews[i].assert(object) - ratingTotal += review.rating.toNum() - review.playtimeMs = authorPublicPlaytime(c.param("id"), review.author.toStr()) - string authorId = "" - if review.contains("authorId") and review.authorId != null ( - authorId = review.authorId.toStr() - ) - if identityMatches(authorId, review.author.toStr(), contextString(c, "userId"), contextString(c, "username")) ( - myReview = review - ) - reviews[i] = review - ) - number average = 0 - if reviews.len > 0 ( - average = ratingTotal / reviews.len - ) - c.json(200, {ok: true, reviews: reviews, count: reviews.len, average: average, myReview: myReview}) -) - -def handleListUserReviews(*serve.Context c) ( - string name = normalizeUsername(c.param("name")) - string requestedId = userIdForUsername(name) - number limit = clampNumber(c.queryInt("limit", 20), 1, 50) - array allReviews = reviewDocs.sort("edited", "desc").limit(1000).all() - array reviews = [] - for i allReviews.len ( - object review = allReviews[i].assert(object) - string authorId = "" - if review.contains("authorId") and review.authorId != null ( - authorId = review.authorId.toStr() - ) - if identityMatches(authorId, review.author.toStr(), requestedId, name) and reviews.len < limit and viewerSeesShadowBanned(review.author.toStr(), authorId, c) ( - auto res = loadProject(review.projectId.toStr()) - if res.ok and canViewProject(res.project.assert(object), c) ( - object project = res.project.assert(object) - review.projectTitle = project.title - review.projectOwner = project.owner - review.playtimeMs = authorPublicPlaytime(review.projectId.toStr(), review.author.toStr()) - reviews.append(review) - ) - ) - ) - c.json(200, {ok: true, reviews: reviews}) -) - -def handleSaveProjectReview(*serve.Context c) ( - auto res = loadProject(c.param("id")) - if !res.ok or !canViewProject(res.project.assert(object), c) ( - c.notFound("project not found") - return - ) - object project = res.project.assert(object) - if projectRole(project, contextString(c, "username")) == "owner" ( - c.badRequest("you cannot review your own project") - return - ) - object body = requestJSON(c) - number rating = body.rating.toInt() - if rating < 1 or rating > 5 ( - c.badRequest("rating must be between 1 and 5") - return - ) - string message = "" - if body.contains("message") ( - message = body.message.toStr().strip() - ) - if message.len > 2000 ( - c.badRequest("review is too long") - return - ) - string reviewerId = contextString(c, "userId") - if reviewerId == "" ( - reviewerId = normalizeUsername(contextString(c, "username")) - ) - string reviewId = project.id.toStr() ++ ":" ++ reviewerId - object existing = (reviewDocs.findById(reviewId) ?? {}) - number created = timestamp - if existing.len > 0 ( - created = existing.created.toNum() - ) - object review = {_id: reviewId, projectId: project.id, author: contextString(c, "username"), authorId: contextString(c, "userId"), authorKey: normalizeUsername(contextString(c, "username")), rating: rating, message: message, created: created, edited: timestamp} - putDocument(reviewDocs, reviewId, review) - if existing.len == 0 ( - addNotification(project.owner.toStr(), contextString(c, "username"), "project_review", {projectId: project.id, projectTitle: project.title, rating: rating}) - ) - review.playtimeMs = authorPublicPlaytime(project.id.toStr(), review.author.toStr()) - c.json(200, {ok: true, review: review}) -) - -def handleDeleteProjectReview(*serve.Context c) ( - auto res = loadProject(c.param("id")) - if !res.ok or !canViewProject(res.project.assert(object), c) ( - c.notFound("project not found") - return - ) - string reviewerId = contextString(c, "userId") - if reviewerId == "" ( - reviewerId = normalizeUsername(contextString(c, "username")) - ) - string reviewId = c.param("id") ++ ":" ++ reviewerId - void reviewDocs.deleteOne({_id: reviewId}) - c.json(200, {ok: true}) -) - -def handleListIdeas(*serve.Context c) ( - array ideas = ideaDocs.sort("created", "desc").all() - string username = contextString(c, "username") - array developmentFeed = developmentPulls() - for i ideas.len ( - object idea = ideas[i].assert(object) - boolean needsSave = false - if !idea.contains("source") ( - idea.source = "community" - if normalizeUsername(idea.author.toStr()) == "mist" ( - idea.source = "mistwarp" - ) - needsSave = true - ) - if !idea.contains("interested") ( - idea.interested = idea.source.toStr() == "mistwarp" - needsSave = true - ) - if !idea.contains("kind") ( - idea.kind = "idea" - needsSave = true - ) - if !idea.contains("commentCount") ( - idea.commentCount = loadComments("roadmap-" ++ idea["_id"].toStr()).len - needsSave = true - ) - if needsSave ( - putDocument(ideaDocs, idea["_id"].toStr(), idea) - ) - array likes = [] - array dislikes = [] - if idea.contains("likes") ( - likes = idea["likes"].assertElse(array, []) - ) else if idea.contains("voters") ( - likes = idea["voters"].assertElse(array, []) - ) - if idea.contains("dislikes") ( - dislikes = idea["dislikes"].assertElse(array, []) - ) - idea.likesCount = likes.len - idea.dislikesCount = dislikes.len - idea.score = likes.len - dislikes.len - idea.myVote = "" - if stringArrayContainsUser(likes, username) ( - idea.myVote = "like" - ) else if stringArrayContainsUser(dislikes, username) ( - idea.myVote = "dislike" - ) - idea.likes = [] - idea.dislikes = [] - idea.voters = [] - idea.pullRequests = resolveIdeaPulls(idea, developmentFeed) - ideas[i] = idea - ) - ideas.sortBy("score", "descending") - c.json(200, {ok: true, ideas: ideas}) -) - -def handleCreateIdea(*serve.Context c) ( - object body = requestJSON(c) - string title = body.title.toStr().strip() - string description = body.description.toStr().strip() - if title == "" or title.len > 120 or description == "" or description.len > 3000 ( - c.badRequest("add a title and a description") - return - ) - string category = "Other" - if body.contains("category") ( - category = body.category.toStr().trim(1, 40) - ) - string kind = "idea" - if body.contains("kind") ( - kind = body.kind.toStr().toLower() - ) - if kind != "idea" and kind != "bug" ( - c.badRequest("invalid roadmap entry type") - return - ) - string ideaId = "i" ++ randomString(14) - string source = "community" - if contextBool(c, "isAdmin") ( - source = "mistwarp" - ) - object idea = {_id: ideaId, title: title, description: description, category: category, kind: kind, author: contextString(c, "username"), authorId: contextString(c, "userId"), source: source, interested: source == "mistwarp", status: "open", pulls: [], likes: [contextString(c, "username")], dislikes: [], votes: 1, commentCount: 0, created: timestamp, edited: timestamp} - void ideaDocs.insertOne(idea) - idea.myVote = "like" - idea.likesCount = 1 - idea.dislikesCount = 0 - idea.score = 1 - idea.commentCount = 0 - idea.likes = [] - idea.dislikes = [] - idea.pullRequests = [] - c.json(201, {ok: true, idea: idea}) -) - -def handleVoteIdea(*serve.Context c) ( - string lockName = "idea-reactions:" ++ c.param("id") - sync.lock(lockName) - handleVoteIdeaLocked(c) - sync.unlock(lockName) -) - -def handleVoteIdeaLocked(*serve.Context c) ( - object idea = (ideaDocs.findById(c.param("id")) ?? {}) - if idea.len == 0 ( - c.notFound("idea not found") - return - ) - string username = contextString(c, "username") - string vote = requestJSON(c).vote.toStr().toLower() - if vote != "like" and vote != "dislike" ( - c.badRequest("vote must be like or dislike") - return - ) - array likes = [] - array dislikes = [] - if idea.contains("likes") ( - likes = idea["likes"].assertElse(array, []) - ) else if idea.contains("voters") ( - likes = idea["voters"].assertElse(array, []) - ) - if idea.contains("dislikes") ( - dislikes = idea["dislikes"].assertElse(array, []) - ) - number previousLikes = likes.len - boolean hadVote = false - if vote == "like" ( - hadVote = stringArrayContainsUser(likes, username) - ) else ( - hadVote = stringArrayContainsUser(dislikes, username) - ) - likes = removeUser(likes, username) - dislikes = removeUser(dislikes, username) - if !hadVote and vote == "like" ( - likes.append(username) - ) else if !hadVote and vote == "dislike" ( - dislikes.append(username) - ) - idea.likes = likes - idea.dislikes = dislikes - idea.votes = likes.len - dislikes.len - idea.edited = timestamp - if !putDocument(ideaDocs, idea["_id"].toStr(), idea) ( - c.internalError("could not save reaction") - return - ) - notifyLikeMilestones(idea.author.toStr(), "roadmap post", idea["_id"].toStr(), previousLikes, likes.len, "/roadmap#idea-" ++ idea["_id"].toStr(), {}) - string myVote = "" - if !hadVote ( - myVote = vote - ) - c.json(200, {ok: true, myVote: myVote, likesCount: likes.len, dislikesCount: dislikes.len, score: likes.len - dislikes.len}) -) - -def handleGetIdeaComments(*serve.Context c) ( - object idea = (ideaDocs.findById(c.param("id")) ?? {}) - if idea.len == 0 ( - c.notFound("idea not found") - return - ) - object page = commentPage(loadComments("roadmap-" ++ idea["_id"].toStr()), c) - page.ok = true - c.json(200, page) -) - -def handleCreateIdeaComment(*serve.Context c) ( - object idea = (ideaDocs.findById(c.param("id")) ?? {}) - if idea.len == 0 ( - c.notFound("idea not found") - return - ) - object body = requestJSON(c) - if !body.contains("content") or body.content == null ( - c.badRequest("comment content is required") - return - ) - string parentId = "" - if body.contains("parent") and body.parent != null ( - parentId = body.parent.toStr() - ) - object created = createComment("roadmap-" ++ idea["_id"].toStr(), contextString(c, "username"), contextString(c, "userId"), body.content.toStr(), parentId, "comment", 0, "") - if !created.ok ( - c.badRequest(created.error.toStr()) - return - ) - if idea.contains("commentCount") ( - idea.commentCount = idea.commentCount.toNum() + 1 - ) else ( - idea.commentCount = loadComments("roadmap-" ++ idea["_id"].toStr()).len - ) - putDocument(ideaDocs, idea["_id"].toStr(), idea) - string commenter = contextString(c, "username") - string parentAuthor = created.parentAuthor.toStr() - string preview = created.comment.content.toStr() - if parentAuthor != "" and normalizeUsername(parentAuthor) != normalizeUsername(commenter) ( - addNotification(parentAuthor, commenter, "roadmap_comment", {roadmapId: idea["_id"], roadmapTitle: idea.title, preview: preview}) - ) - if normalizeUsername(idea.author.toStr()) != normalizeUsername(parentAuthor) ( - addNotification(idea.author.toStr(), commenter, "roadmap_comment", {roadmapId: idea["_id"], roadmapTitle: idea.title, preview: preview}) - ) - notifyMentions(created.comment.content.toStr(), commenter, [idea.author.toStr(), parentAuthor], {roadmapId: idea["_id"], roadmapTitle: idea.title, preview: preview}) - c.json(201, created) -) - -def handleDeleteIdeaComment(*serve.Context c) ( - object idea = (ideaDocs.findById(c.param("id")) ?? {}) - if idea.len == 0 ( - c.notFound("idea not found") - return - ) - object deleted = deleteComment("roadmap-" ++ idea["_id"].toStr(), c.param("cid"), contextString(c, "username"), idea.author.toStr(), contextBool(c, "isAdmin")) - if !deleted.ok ( - c.json(deleted.status.toInt(), {ok: false, error: deleted.error}) - return - ) - if idea.contains("commentCount") ( - idea.commentCount = clampNumber( - idea.commentCount.toNum() - deleted.removed.toNum(), - 0, - idea.commentCount.toNum() - ) - ) else ( - idea.commentCount = loadComments("roadmap-" ++ idea["_id"].toStr()).len - ) - putDocument(ideaDocs, idea["_id"].toStr(), idea) - c.json(200, {ok: true}) -) - -def handleEditIdeaComment(*serve.Context c) ( - object idea = (ideaDocs.findById(c.param("id")) ?? {}) - if idea.len == 0 ( - c.notFound("idea not found") - return - ) - object body = requestJSON(c) - if !body.contains("content") or body.content == null ( - c.badRequest("comment content is required") - return - ) - object edited = editComment("roadmap-" ++ idea["_id"].toStr(), c.param("cid"), contextString(c, "username"), body.content.toStr()) - if !edited.ok ( - c.json(edited.status.toInt(), {ok: false, error: edited.error}) - return - ) - c.json(200, edited) -) - -def handleReactIdeaComment(*serve.Context c) ( - object idea = (ideaDocs.findById(c.param("id")) ?? {}) - if idea.len == 0 ( - c.notFound("idea not found") - return - ) - reactToComment(c, "roadmap-" ++ idea["_id"].toStr(), "/roadmap?idea=" ++ idea["_id"].toStr()) -) - -def handleUpdateIdea(*serve.Context c) ( - object idea = (ideaDocs.findById(c.param("id")) ?? {}) - if idea.len == 0 ( - c.notFound("idea not found") - return - ) - object body = requestJSON(c) - if body.contains("status") ( - string status = body.status.toStr().toLower() - if status == "open" or status == "planned" or status == "building" or status == "shipped" or status == "declined" ( - idea.status = status - ) - ) - if body.contains("interested") ( - idea.interested = body.interested == true - ) - if body.contains("pulls") and typeof(body.pulls) == "array" ( - idea.pulls = normalizePullLinks(body.pulls.assert(array)) - ) - idea.edited = timestamp - putDocument(ideaDocs, idea["_id"].toStr(), idea) - idea.pullRequests = resolveIdeaPulls(idea, developmentPulls()) - c.json(200, {ok: true, idea: idea}) -) - -def xmlEscape(string value) string ( - return value.replace("&", "&").replace("<", "<").replace(">", ">").replace("\"", """) -) - -def handleProjectCard(*serve.Context c) ( - auto res = loadProject(c.param("id")) - if !res.ok or !projectIsViewable(res.project.assert(object)) ( - c.notFound("project not found") - return - ) - object project = res.project.assert(object) - string title = xmlEscape(project.title.toStr()).trim(1, 58) - string owner = xmlEscape(project.owner.toStr()).trim(1, 30) - string thumbnail = xmlEscape(thumbUrlFor(project)) - number hearts = project.loves.assert(array).len - string svg = "MistWarp" ++ title ++ "by " ++ owner ++ "" ++ project.views.toStr() ++ " plays " ++ hearts.toStr() ++ " hearts" - c.setHeader("Cache-Control", "public, max-age=300") - c.send(200, "image/svg+xml; charset=utf-8", storagefs.Bytes(svg)) -) - -def shareCardPath(object project) string ( - return cardsDir ++ project.id.toStr() ++ "-" ++ project.edited.toStr() ++ ".png" -) - -def shareCardVisible(object project) boolean ( - return project.shared == true and projectIsViewable(project) and !projectHiddenByModerator(project) -) - -def ensureShareCard(object project) string ( - string path = shareCardPath(project) - if fs.exists(path) ( - return "" - ) - return sharecard.Render(thumbnailFileFor(project), project.title.toStr(), project.owner.toStr(), path) -) - -def warmShareCard(string id) ( - auto res = loadProject(id) - if res.ok and shareCardVisible(res.project.assert(object)) ( - void ensureShareCard(res.project.assert(object)) - ) -) - -def handleProjectSharePng(*serve.Context c) ( - auto res = loadProject(c.param("id")) - if !res.ok or !shareCardVisible(res.project.assert(object)) ( - c.notFound("project not found") - return - ) - object project = res.project.assert(object) - if ensureShareCard(project) != "" ( - string cover = thumbUrlFor(project) - c.setHeader("Location", if cover == "" then editorOrigin ++ "/images/apple-touch-icon.png" else cover) - c.setHeader("Cache-Control", "public, max-age=300") - c.status(302) - return - ) - boolean current = c.query("v") == project.edited.toStr() - string etag = "\"" ++ project.id.toStr() ++ "-" ++ project.edited.toStr() ++ "\"" - c.setHeader("ETag", etag) - c.setHeader("Cache-Control", if current then "public, max-age=31536000, immutable" else "public, max-age=3600") - if (c.header("If-None-Match") ?? "") == etag ( - c.status(304) - return - ) - c.setHeader("Content-Type", "image/png") - c.setHeader("X-Content-Type-Options", "nosniff") - c.file(shareCardPath(project)) -) diff --git a/src/community/community-user-records.osl b/src/community/community-user-records.osl new file mode 100644 index 0000000..474b9d9 --- /dev/null +++ b/src/community/community-user-records.osl @@ -0,0 +1,312 @@ +export { + purgeUserCommunityRecords, + purgeUserFromSpaces +} + +def purgeUserFromSpaces(string username) boolean ( + string key = normalizeUsername(username) + array spaces = spaceDocs.all() + for i spaces.len ( + object space = spaces[i].assert(object) + string spaceId = space["_id"].toStr() + if normalizeUsername(space.owner.toStr()) == key ( + void spaceDocs.deleteOne({_id: space["_id"]}) + deleteSpaceMemberships(spaceId) + void fs.remove(commentsDir ++ spaceCommentKey(spaceId) ++ ".json") + void fs.remove(commentsDir ++ spaceCommentKey(spaceId) ++ ".jsonl") + void fs.remove(spaceThumbnailPath(spaceId)) + ) else ( + boolean changed = spaceRelatedUserKeys(space).contains(key) + array likes = spaceLikes(space) + array broken = spaceBrokenHearts(space) + if stringArrayContainsUser(likes, key) or stringArrayContainsUser(broken, key) ( + changed = true + ) + space.managers = removeUser(spaceManagers(space), key) + space.followers = removeUser(space.followers.assertElse(array, []), key) + space.likes = removeUser(likes, key) + space.brokenHearts = removeUser(broken, key) + array curatorInvites = [] + array storedCuratorInvites = spaceCuratorInvites(space) + for j storedCuratorInvites.len ( + object invite = storedCuratorInvites[j].assert(object) + if normalizeUsername(invite.username.toStr()) != key ( + curatorInvites.append(invite) + ) + ) + space.curatorInvites = curatorInvites + if space.kind.toStr() == "challenge" ( + space.participants = removeUser(challengeArray(space, "participants"), key) + space.judges = removeUser(challengeArray(space, "judges"), key) + array judgeInvites = [] + array storedJudgeInvites = challengeArray(space, "judgeInvites") + for j storedJudgeInvites.len ( + object invite = storedJudgeInvites[j].assert(object) + if normalizeUsername(invite.username.toStr()) != key ( + judgeInvites.append(invite) + ) + ) + space.judgeInvites = judgeInvites + array scores = [] + array storedScores = challengeArray(space, "scores") + for j storedScores.len ( + object score = storedScores[j].assert(object) + if normalizeUsername(score.judge.toStr()) != key ( + scores.append(score) + ) else ( + changed = true + ) + ) + space.scores = scores + array votes = [] + array storedVotes = challengeArray(space, "audienceVotes") + for j storedVotes.len ( + object vote = storedVotes[j].assert(object) + if normalizeUsername(vote.username.toStr()) != key ( + votes.append(vote) + ) else ( + changed = true + ) + ) + space.audienceVotes = votes + ) + if changed ( + space.edited = timestamp + putSpaceDocument(space) + ) + ) + ) + return true +) + +def purgeUserCommunityRecords(string username, string userId) boolean ( + string key = normalizeUsername(username) + deleteDocumentsByField(reviewDocs, "authorKey", key) + deleteDocumentsByField(feedbackDocs, "author", username) + deleteDocumentsByField(playtimeDocs, "username", username) + if userId != "" ( + deleteDocumentsByField(playtimeDocs, "userId", userId) + deleteDocumentsByField(feedbackDocs, "authorId", userId) + ) + void playtimeSessionDocs.deleteOne({_id: key}) + + array releases = releaseDocs.where("creator", "==", username).all() + for i releases.len ( + object release = releases[i].assert(object) + release.creator = "Deleted User" + putDocument(releaseDocs, release["_id"].toStr(), release) + ) + + array ideas = ideaDocs.all() + for i ideas.len ( + object idea = ideas[i].assert(object) + string ideaId = idea["_id"].toStr() + if normalizeUsername(idea.author.toStr()) == key ( + void ideaDocs.deleteOne({_id: idea["_id"]}) + void fs.remove(commentsDir ++ "roadmap-" ++ ideaId ++ ".json") + void fs.remove(commentsDir ++ "roadmap-" ++ ideaId ++ ".jsonl") + ) else ( + array likes = [] + if idea.contains("likes") ( + likes = idea["likes"].assertElse(array, []) + ) else if idea.contains("voters") ( + likes = idea["voters"].assertElse(array, []) + ) + array dislikes = [] + if idea.contains("dislikes") ( + dislikes = idea["dislikes"].assertElse(array, []) + ) + array nextLikes = removeUser(likes, key) + array nextDislikes = removeUser(dislikes, key) + if nextLikes.len != likes.len or nextDislikes.len != dislikes.len ( + idea.likes = nextLikes + idea.dislikes = nextDislikes + idea.voters = [] + idea.votes = nextLikes.len - nextDislikes.len + putDocument(ideaDocs, ideaId, idea) + ) + ) + ) + return true +) + +def renameUserCommunityRecords(string oldKey, string newKey, string newName) boolean ( + array spaces = spaceDocs.all() + for i spaces.len ( + object space = spaces[i].assert(object) + boolean changed = spaceRelatedUserKeys(space).contains(oldKey) + array likes = spaceLikes(space) + array brokenHearts = spaceBrokenHearts(space) + if stringArrayContainsUser(likes, oldKey) or stringArrayContainsUser(brokenHearts, oldKey) ( + changed = true + ) + if normalizeUsername(space.owner.toStr()) == oldKey ( + space.owner = newName + ) + space.managers = replaceUserInArray(spaceManagers(space), oldKey, newName) + space.followers = replaceUserInArray(space.followers.assertElse(array, []), oldKey, newKey) + space.likes = replaceUserInArray(likes, oldKey, newKey) + space.brokenHearts = replaceUserInArray(brokenHearts, oldKey, newKey) + array curatorInvites = spaceCuratorInvites(space) + for j curatorInvites.len ( + object invite = curatorInvites[j].assert(object) + if normalizeUsername(invite.username.toStr()) == oldKey ( + invite.username = newName + changed = true + ) + if normalizeUsername(invite.invitedBy.toStr()) == oldKey ( + invite.invitedBy = newName + changed = true + ) + curatorInvites[j] = invite + ) + space.curatorInvites = curatorInvites + if space.kind.toStr() == "challenge" ( + space.participants = replaceUserInArray(challengeArray(space, "participants"), oldKey, newName) + space.judges = replaceUserInArray(challengeArray(space, "judges"), oldKey, newName) + array judgeInvites = challengeArray(space, "judgeInvites") + for j judgeInvites.len ( + object invite = judgeInvites[j].assert(object) + if normalizeUsername(invite.username.toStr()) == oldKey ( + invite.username = newName + changed = true + ) + if normalizeUsername(invite.invitedBy.toStr()) == oldKey ( + invite.invitedBy = newName + changed = true + ) + judgeInvites[j] = invite + ) + space.judgeInvites = judgeInvites + array scores = challengeArray(space, "scores") + for j scores.len ( + object score = scores[j].assert(object) + if normalizeUsername(score.judge.toStr()) == oldKey ( + score.judge = newName + scores[j] = score + changed = true + ) + ) + space.scores = scores + array votes = challengeArray(space, "audienceVotes") + for j votes.len ( + object vote = votes[j].assert(object) + if normalizeUsername(vote.username.toStr()) == oldKey ( + vote.username = newName + votes[j] = vote + changed = true + ) + ) + space.audienceVotes = votes + array submissions = challengeArray(space, "submissions") + for j submissions.len ( + object submission = submissions[j].assert(object) + if normalizeUsername(submission.owner.toStr()) == oldKey ( + submission.owner = newName + submissions[j] = submission + changed = true + ) + ) + space.submissions = submissions + ) + if changed ( + putSpaceDocument(space) + ) + ) + + array reviews = reviewDocs.where("authorKey", "==", oldKey).all() + for i reviews.len ( + object review = reviews[i].assert(object) + string oldId = review["_id"].toStr() + string newId = review.projectId.toStr() ++ ":" ++ newKey + review.author = newName + review.authorKey = newKey + void reviewDocs.deleteOne({_id: oldId}) + putDocument(reviewDocs, newId, review) + ) + + array playtimeRecords = playtimeDocs.all() + for i playtimeRecords.len ( + object playtime = playtimeRecords[i].assert(object) + if normalizeUsername(playtime.username.toStr()) == oldKey ( + string oldId = playtime["_id"].toStr() + string newId = playtime.projectId.toStr() ++ ":" ++ newKey + object existing = (playtimeDocs.findById(newId) ?? {}) + if existing.len > 0 and newId != oldId ( + playtime.duration = playtime.duration.toNum() + existing.duration.toNum() + ) + playtime.username = newName + void playtimeDocs.deleteOne({_id: oldId}) + putDocument(playtimeDocs, newId, playtime) + ) + ) + object session = (playtimeSessionDocs.findById(oldKey) ?? {}) + if session.len > 0 ( + session.username = newName + void playtimeSessionDocs.deleteOne({_id: oldKey}) + putDocument(playtimeSessionDocs, newKey, session) + ) + + array feedback = feedbackDocs.all() + for i feedback.len ( + object item = feedback[i].assert(object) + if normalizeUsername(item.author.toStr()) == oldKey ( + item.author = newName + putDocument(feedbackDocs, item["_id"].toStr(), item) + ) + ) + array releases = releaseDocs.all() + for i releases.len ( + object release = releases[i].assert(object) + if normalizeUsername(release.creator.toStr()) == oldKey ( + release.creator = newName + putDocument(releaseDocs, release["_id"].toStr(), release) + ) + ) + array pulls = pullDocs.all() + for i pulls.len ( + object pull = pulls[i].assert(object) + boolean changed = false + if normalizeUsername(pull.author.toStr()) == oldKey ( + pull.author = newName + changed = true + ) + if normalizeUsername(pull.sourceOwner.toStr()) == oldKey ( + pull.sourceOwner = newName + changed = true + ) + if changed ( + putDocument(pullDocs, pull["_id"].toStr(), pull) + ) + ) + + array ideas = ideaDocs.all() + for i ideas.len ( + object idea = ideas[i].assert(object) + boolean changed = false + if normalizeUsername(idea.author.toStr()) == oldKey ( + idea.author = newName + changed = true + ) + array likes = [] + if idea.contains("likes") ( + likes = idea["likes"].assertElse(array, []) + ) else if idea.contains("voters") ( + likes = idea["voters"].assertElse(array, []) + ) + array dislikes = [] + if idea.contains("dislikes") ( + dislikes = idea["dislikes"].assertElse(array, []) + ) + if stringArrayContainsUser(likes, oldKey) or stringArrayContainsUser(dislikes, oldKey) ( + idea.likes = replaceUserInArray(likes, oldKey, newName) + idea.dislikes = replaceUserInArray(dislikes, oldKey, newName) + idea.voters = [] + changed = true + ) + if changed ( + putDocument(ideaDocs, idea["_id"].toStr(), idea) + ) + ) + return true +) diff --git a/src/community/playtime-diagnostics.osl b/src/community/playtime-diagnostics.osl new file mode 100644 index 0000000..dabee84 --- /dev/null +++ b/src/community/playtime-diagnostics.osl @@ -0,0 +1,187 @@ +export { + creditPlaytimeSession, + recordProjectDiagnostic +} + +def playtimeIdentity(string username) string ( + string userId = userIdForUsername(username) + if userId != "" ( + return userId + ) + return normalizeUsername(username) +) + +def beginPlaytimeSession(string projectId, string username, string sessionId) boolean ( + string userId = userIdForUsername(username) + string identityKey = playtimeIdentity(username) + object session = (playtimeSessionDocs.findById(sessionId) ?? {}) + if session.len == 0 or session.projectId.toStr() != projectId or timestamp - session.lastSeen.toNum() > 120000 ( + putDocument(playtimeSessionDocs, sessionId, { + _id: sessionId, + projectId: projectId, + username: username, + userId: userId, + identityKey: identityKey, + lastCredit: timestamp, + lastSeen: timestamp + }) + ) + return true +) + +def touchRecentProjectActivity(string projectId, string username) boolean ( + string userId = userIdForUsername(username) + string identityKey = userId + if identityKey == "" ( + identityKey = normalizeUsername(username) + ) + string playtimeId = projectId ++ ":" ++ identityKey + string playtimeLock = "playtime:" ++ playtimeId + sync.lock(playtimeLock) + object playtime = (playtimeDocs.findById(playtimeId) ?? {}) + if playtime.len == 0 ( + playtime = {_id: playtimeId, projectId: projectId, username: username, userId: userId, duration: 0, updated: timestamp} + ) else ( + playtime.username = username + playtime.userId = userId + playtime.updated = timestamp + ) + playtime.projectId = projectId + boolean saved = putDocument(playtimeDocs, playtimeId, playtime) + sync.unlock(playtimeLock) + return saved +) + +def creditPlaytimeSession(string projectId, string username, string sessionId, boolean finished) number ( + string userId = userIdForUsername(username) + object session = (playtimeSessionDocs.findById(sessionId) ?? {}) + if session.len == 0 or session.projectId.toStr() != projectId ( + return 0 + ) + number credited = clampNumber(timestamp - session.lastCredit.toNum(), 0, 60000) + if credited > 0 ( + string identityKey = session.identityKey.assertElse(string, playtimeIdentity(username)) + string playtimeId = projectId ++ ":" ++ identityKey + string playtimeLock = "playtime:" ++ playtimeId + sync.lock(playtimeLock) + object playtime = (playtimeDocs.findById(playtimeId) ?? {}) + if playtime.len == 0 ( + playtime = {_id: playtimeId, projectId: projectId, username: username, userId: userId, duration: 0, updated: timestamp} + ) + playtime.duration = playtime.duration.toNum() + credited + playtime.updated = timestamp + playtime.projectId = projectId + putDocument(playtimeDocs, playtimeId, playtime) + sync.unlock(playtimeLock) + ) + if finished ( + void playtimeSessionDocs.deleteOne({_id: sessionId}) + ) else ( + session.lastCredit = timestamp + session.lastSeen = timestamp + putDocument(playtimeSessionDocs, sessionId, session) + ) + return credited +) + +def recordProjectDiagnostic(string projectId, boolean authenticated, string username, string sessionId, object body) object ( + string eventType = body.type.toStr().toLower().strip() + if eventType != "load" and eventType != "start" and eventType != "crash" and eventType != "exit" and eventType != "playtime" and eventType != "playtime_start" and eventType != "playtime_ping" and eventType != "playtime_finish" ( + return {ok: false, error: "invalid diagnostic type"} + ) + string diagnosticId = projectId ++ ":" ++ randomString(16) + number duration = 0 + number loadMs = 0 + string errorText = "" + string device = "" + if body.contains("duration") ( + duration = clampNumber(body.duration.toNum(), 0, 86400000) + ) + if body.contains("loadMs") ( + loadMs = clampNumber(body.loadMs.toNum(), 0, 600000) + ) + if body.contains("error") ( + errorText = body.error.toStr().trim(1, 500) + ) + if body.contains("device") ( + device = body.device.toStr().trim(1, 40) + ) + if eventType == "playtime_start" or eventType == "playtime_ping" or eventType == "playtime_finish" or eventType == "playtime" ( + if !authenticated ( + return {ok: true} + ) + if eventType == "playtime_start" ( + beginPlaytimeSession(projectId, username, sessionId) + ) else if eventType == "playtime_finish" ( + creditPlaytimeSession(projectId, username, sessionId, true) + ) else ( + creditPlaytimeSession(projectId, username, sessionId, false) + ) + return {ok: true} + ) + if eventType == "start" and authenticated ( + beginPlaytimeSession(projectId, username, sessionId) + ) + if eventType == "load" and authenticated ( + touchRecentProjectActivity(projectId, username) + ) + object item = {_id: diagnosticId, projectId: projectId, type: eventType, duration: duration, loadMs: loadMs, error: errorText, device: device, created: timestamp} + string diagnosticLock = "project-diagnostics-" ++ projectId + sync.lock(diagnosticLock) + void diagnosticDocs.insertOne(item) + array storedDiagnostics = diagnosticDocs.where("projectId", "==", projectId).sort("created", "desc").limit(550).all() + for i storedDiagnostics.len ( + if i > 500 ( + void diagnosticDocs.deleteOne({_id: storedDiagnostics[i]["_id"]}) + ) + ) + sync.unlock(diagnosticLock) + return {ok: true} +) + +def handleRecordDiagnostic(*serve.Context c) ( + auto projectResult = loadProject(c.param("id")) + if !projectResult.ok or !projectIsViewable(projectResult.project.assert(object)) ( + c.notFound("project not found") + return + ) + string sessionId = playtimeIdentity(contextString(c, "username")) + object res = recordProjectDiagnostic( + c.param("id"), + contextBool(c, "authenticated"), + contextString(c, "username"), + sessionId, + requestJSON(c) + ) + if !res.ok ( + c.badRequest(res.error.toStr()) + return + ) + c.json(201, {ok: true}) +) + +def handleGetDiagnostics(*serve.Context c) ( + auto res = loadProject(c.param("id")) + if !res.ok or !canEditProject(res.project.assert(object), c) ( + c.forbidden("not your project") + return + ) + array items = diagnosticDocs.where("projectId", "==", c.param("id")).sort("created", "desc").limit(500).all() + object counts = {load: 0, start: 0, crash: 0, exit: 0} + number totalLoadMs = 0 + number loadSamples = 0 + for i items.len ( + object item = items[i].assert(object) + string kind = item.type.toStr() + counts[kind] = counts[kind].toNum() + 1 + if item.loadMs.toNum() > 0 ( + totalLoadMs += item.loadMs.toNum() + loadSamples += 1 + ) + ) + number averageLoadMs = 0 + if loadSamples > 0 ( + averageLoadMs = totalLoadMs / loadSamples + ) + c.json(200, {ok: true, counts: counts, averageLoadMs: averageLoadMs, recent: paginate(items, 0, 50)}) +) diff --git a/src/community/project-feedback.osl b/src/community/project-feedback.osl new file mode 100644 index 0000000..f3967ae --- /dev/null +++ b/src/community/project-feedback.osl @@ -0,0 +1,174 @@ +def handleCreateProjectFeedback(*serve.Context c) ( + auto res = loadProject(c.param("id")) + if !res.ok or !canViewProject(res.project.assert(object), c) ( + c.notFound("project not found") + return + ) + object project = res.project.assert(object) + if feedbackDocs.where("projectId", "==", project.id.toStr()).limit(200).all().len >= 200 ( + c.badRequest("this project has reached its feedback limit") + return + ) + object body = requestJSON(c) + string feedbackType = body.type.toStr().toLower().strip() + if feedbackType != "bug" and feedbackType != "idea" and feedbackType != "confusing" and feedbackType != "other" ( + c.badRequest("invalid feedback type") + return + ) + string message = body.message.toStr().strip() + if message == "" or message.len > 2000 ( + c.badRequest("feedback must be between 1 and 2000 characters") + return + ) + string feedbackId = project.id.toStr() ++ ":" ++ randomString(16) + object feedback = {_id: feedbackId, projectId: project.id, type: feedbackType, message: message, status: "open", author: contextString(c, "username"), authorId: contextString(c, "userId"), created: timestamp, edited: timestamp} + void feedbackDocs.insertOne(feedback) + addNotification(project.owner.toStr(), contextString(c, "username"), "project_feedback", {projectId: project.id, projectTitle: project.title, feedbackType: feedbackType}) + c.json(201, {ok: true}) +) + +def handleGetProjectFeedback(*serve.Context c) ( + auto res = loadProject(c.param("id")) + if !res.ok or !canEditProject(res.project.assert(object), c) ( + c.forbidden("not your project") + return + ) + array feedback = feedbackDocs.where("projectId", "==", c.param("id")).sort("created", "desc").limit(200).all() + c.json(200, {ok: true, feedback: feedback}) +) + +def handleUpdateProjectFeedback(*serve.Context c) ( + auto res = loadProject(c.param("id")) + if !res.ok or !canEditProject(res.project.assert(object), c) ( + c.forbidden("not your project") + return + ) + object feedback = (feedbackDocs.findById(c.param("feedback")) ?? {}) + if feedback.len == 0 or feedback.projectId.toStr() != c.param("id") ( + c.notFound("feedback not found") + return + ) + string status = requestJSON(c).status.toStr().toLower() + if status != "open" and status != "working" and status != "done" and status != "dismissed" ( + c.badRequest("invalid feedback status") + return + ) + feedback.status = status + feedback.edited = timestamp + putDocument(feedbackDocs, feedback["_id"].toStr(), feedback) + c.json(200, {ok: true, feedback: feedback}) +) + +def handleListProjectReviews(*serve.Context c) ( + auto res = loadProject(c.param("id")) + if !res.ok or !canViewProject(res.project.assert(object), c) ( + c.notFound("project not found") + return + ) + array reviews = reviewsVisibleTo(reviewDocs.where("projectId", "==", c.param("id")).sort("edited", "desc").limit(200).all(), c) + number ratingTotal = 0 + object myReview = {} + for i reviews.len ( + object review = reviews[i].assert(object) + ratingTotal += review.rating.toNum() + review.playtimeMs = authorPublicPlaytime(c.param("id"), review.author.toStr()) + string authorId = "" + if review.contains("authorId") and review.authorId != null ( + authorId = review.authorId.toStr() + ) + if identityMatches(authorId, review.author.toStr(), contextString(c, "userId"), contextString(c, "username")) ( + myReview = review + ) + reviews[i] = review + ) + number average = 0 + if reviews.len > 0 ( + average = ratingTotal / reviews.len + ) + c.json(200, {ok: true, reviews: reviews, count: reviews.len, average: average, myReview: myReview}) +) + +def handleListUserReviews(*serve.Context c) ( + string name = normalizeUsername(c.param("name")) + string requestedId = userIdForUsername(name) + number limit = clampNumber(c.queryInt("limit", 20), 1, 50) + array allReviews = reviewDocs.sort("edited", "desc").limit(1000).all() + array reviews = [] + for i allReviews.len ( + object review = allReviews[i].assert(object) + string authorId = "" + if review.contains("authorId") and review.authorId != null ( + authorId = review.authorId.toStr() + ) + if identityMatches(authorId, review.author.toStr(), requestedId, name) and reviews.len < limit and viewerSeesShadowBanned(review.author.toStr(), authorId, c) ( + auto res = loadProject(review.projectId.toStr()) + if res.ok and canViewProject(res.project.assert(object), c) ( + object project = res.project.assert(object) + review.projectTitle = project.title + review.projectOwner = project.owner + review.playtimeMs = authorPublicPlaytime(review.projectId.toStr(), review.author.toStr()) + reviews.append(review) + ) + ) + ) + c.json(200, {ok: true, reviews: reviews}) +) + +def handleSaveProjectReview(*serve.Context c) ( + auto res = loadProject(c.param("id")) + if !res.ok or !canViewProject(res.project.assert(object), c) ( + c.notFound("project not found") + return + ) + object project = res.project.assert(object) + if projectRole(project, contextString(c, "username")) == "owner" ( + c.badRequest("you cannot review your own project") + return + ) + object body = requestJSON(c) + number rating = body.rating.toInt() + if rating < 1 or rating > 5 ( + c.badRequest("rating must be between 1 and 5") + return + ) + string message = "" + if body.contains("message") ( + message = body.message.toStr().strip() + ) + if message.len > 2000 ( + c.badRequest("review is too long") + return + ) + string reviewerId = contextString(c, "userId") + if reviewerId == "" ( + reviewerId = normalizeUsername(contextString(c, "username")) + ) + string reviewId = project.id.toStr() ++ ":" ++ reviewerId + object existing = (reviewDocs.findById(reviewId) ?? {}) + number created = timestamp + if existing.len > 0 ( + created = existing.created.toNum() + ) + object review = {_id: reviewId, projectId: project.id, author: contextString(c, "username"), authorId: contextString(c, "userId"), authorKey: normalizeUsername(contextString(c, "username")), rating: rating, message: message, created: created, edited: timestamp} + putDocument(reviewDocs, reviewId, review) + if existing.len == 0 ( + addNotification(project.owner.toStr(), contextString(c, "username"), "project_review", {projectId: project.id, projectTitle: project.title, rating: rating}) + ) + review.playtimeMs = authorPublicPlaytime(project.id.toStr(), review.author.toStr()) + c.json(200, {ok: true, review: review}) +) + +def handleDeleteProjectReview(*serve.Context c) ( + auto res = loadProject(c.param("id")) + if !res.ok or !canViewProject(res.project.assert(object), c) ( + c.notFound("project not found") + return + ) + string reviewerId = contextString(c, "userId") + if reviewerId == "" ( + reviewerId = normalizeUsername(contextString(c, "username")) + ) + string reviewId = c.param("id") ++ ":" ++ reviewerId + void reviewDocs.deleteOne({_id: reviewId}) + c.json(200, {ok: true}) +) diff --git a/src/community/releases.osl b/src/community/releases.osl new file mode 100644 index 0000000..e22f161 --- /dev/null +++ b/src/community/releases.osl @@ -0,0 +1,217 @@ +def handleCreatePreview(*serve.Context c) ( + auto res = loadProject(c.param("id")) + if !res.ok ( + c.notFound("project not found") + return + ) + object project = res.project.assert(object) + if !canEditProject(project, c) ( + c.forbidden("not your project") + return + ) + if projectMustStayPrivate(project) ( + c.forbidden("remixes of paid projects cannot create shareable preview links") + return + ) + array previews = previewDocs.where("projectId", "==", project.id.toStr()).all() + number activePreviews = 0 + for i previews.len ( + object existing = previews[i].assert(object) + if existing.expiresAt.toNum() <= timestamp ( + void previewDocs.deleteOne({_id: existing["_id"]}) + ) else ( + activePreviews += 1 + ) + ) + if activePreviews >= 20 ( + c.badRequest("a project can have at most 20 active previews") + return + ) + object body = requestJSON(c) + number hours = 24 + if body.contains("hours") ( + hours = clampNumber(body.hours.toNum(), 1, 168) + ) + string key = randomString(24) + string previewId = project.id.toStr() ++ ":" ++ key + object preview = {_id: previewId, projectId: project.id, key: key, owner: contextString(c, "username"), ownerId: contextString(c, "userId"), created: timestamp, expiresAt: timestamp + (hours * 3600000)} + void previewDocs.insertOne(preview) + c.json(201, {ok: true, key: key, expiresAt: preview.expiresAt}) +) + +def handleListReleases(*serve.Context c) ( + c.setHeader("Cache-Control", "private, no-store") + c.setHeader("CDN-Cache-Control", "private, no-store") + auto res = loadProject(c.param("id")) + if !res.ok or !canViewProject(res.project.assert(object), c) ( + c.notFound("project not found") + return + ) + object project = res.project.assert(object) + boolean canEdit = canEditProject(project, c) + ensureViewKey(project) + object jsonAccess = projectJsonAccess(project, c) + array stored = releaseDocs.where("projectId", "==", c.param("id")).sort("created", "desc").limit(100).all() + array releases = [] + for i stored.len ( + object release = stored[i].assert(object) + if release.channel.toStr() != "checkpoint" or canEdit ( + releases.append(publicReleaseRecord(release, jsonAccess)) + ) + ) + c.json(200, {ok: true, releases: releases}) +) + +def handleCreateRelease(*serve.Context c) ( + auto res = loadProject(c.param("id")) + if !res.ok ( + c.notFound("project not found") + return + ) + object project = res.project.assert(object) + if !canEditProject(project, c) ( + c.forbidden("not your project") + return + ) + object body = requestJSON(c) + string version = body.version.toStr().strip() + string releaseChannel = body.channel.toStr().toLower().strip() + if version == "" or version.len > 50 ( + c.badRequest("invalid version") + return + ) + if releaseChannel != "stable" and releaseChannel != "beta" and releaseChannel != "development" and releaseChannel != "checkpoint" ( + c.badRequest("invalid release channel") + return + ) + if releaseChannel == "checkpoint" and mistwarpBenefitsForProject(project).historyCheckpoints != true ( + c.forbidden("named history checkpoints require Rotur Lite, Plus, or Pro") + return + ) + number releaseLimit = mistwarpBenefitsForProject(project).maxProjectReleases.toNum() + if releaseDocs.where("projectId", "==", project.id.toStr()).limit(releaseLimit.toInt()).all().len >= releaseLimit ( + c.badRequest("this project has reached its release limit") + return + ) + string notes = "" + if body.contains("notes") ( + notes = body.notes.toStr().strip() + ) + if notes.len > 10000 ( + c.badRequest("release notes are too long") + return + ) + string releaseId = project.id.toStr() ++ ":" ++ randomString(12) + string commit = "" + if body.contains("commit") ( + commit = body.commit.toStr() + ) + object snapshot = ensureProjectJsonSnapshot(project) + if !snapshot.ok ( + c.internalError("could not snapshot the current project") + return + ) + project = snapshot.project.assert(object) + string jsonUrl = appURL ++ "/v1/projects/" ++ project.id.toStr() ++ "/releases/" ++ url.escape(releaseId) ++ "/project.json" + // jsonBytes is the snapshot's size, which counts against the owner's + // storage for as long as the release keeps it. + object release = {_id: releaseId, projectId: project.id, version: version, channel: releaseChannel, notes: notes, commit: commit, jsonKey: project.jsonKey, jsonBytes: (project.storedJsonBytes ?? 0).toNum(), jsonGzip: true, jsonUrl: jsonUrl, assetsBase: assetsBaseUrl(), created: timestamp, creator: contextString(c, "username"), creatorId: contextString(c, "userId")} + void releaseDocs.insertOne(release) + storageSettleProject(project) + if releaseChannel == "stable" ( + project.publicRelease = releaseId + saveProject(project) + ) + if releaseChannel != "checkpoint" ( + addActivity(contextString(c, "username"), "release", {projectId: project.id, projectTitle: project.title, version: version}) + ) + c.json(201, {ok: true, release: publicReleaseRecord(release, projectJsonAccess(project, c))}) +) + +def handleGetReleaseProjectJson(*serve.Context c) ( + object release = (releaseDocs.findById(c.param("release")) ?? {}) + if release.len == 0 or release.projectId.toStr() != c.param("id") ( + c.notFound("release not found") + return + ) + auto res = loadProject(c.param("id")) + if !res.ok or !canViewProjectJson(res.project.assert(object), c) ( + c.notFound("release not found") + return + ) + string releaseEtag = "\"" ++ release["_id"].toStr() ++ "\"" + c.setHeader("ETag", releaseEtag) + c.setHeader("Cache-Control", "no-cache") + if (c.header("If-None-Match") ?? "").contains(releaseEtag) ( + c.status(304) + return + ) + if release.contains("jsonKey") and release.jsonKey.toStr() != "" ( + serveProjectJsonStorageKey(c, release.jsonKey.toStr(), release.contains("jsonGzip") and release.jsonGzip == true) + return + ) + string releasePath = releasesDir ++ release["_id"].toStr().replace(":", "-") ++ ".json" + if !fs.exists(releasePath) ( + c.notFound("release snapshot not found") + return + ) + c.setHeader("Content-Type", "application/json") + c.text(200, fs.readFile(releasePath).unwrapOr("")) +) + +def handleDeleteRelease(*serve.Context c) ( + auto res = loadProject(c.param("id")) + if !res.ok ( + c.notFound("project not found") + return + ) + object project = res.project.assert(object) + if !canEditProject(project, c) ( + c.forbidden("not your project") + return + ) + object release = (releaseDocs.findById(c.param("release")) ?? {}) + if release.len == 0 or release.projectId.toStr() != project.id.toStr() ( + c.notFound("release not found") + return + ) + string jsonKey = (release.jsonKey ?? "").toStr() + void releaseDocs.deleteOne({_id: release["_id"]}) + void fs.remove(releasesDir ++ release["_id"].toStr().replace(":", "-") ++ ".json") + if jsonKey != "" ( + removeProjectStorageKeyIfOrphaned(jsonKey, "", false) + ) + storageSettleProject(project) + if project.publicRelease.toStr() == release["_id"].toStr() ( + project.publicRelease = "" + saveProject(project) + ) + c.json(200, {ok: true}) +) + +def handleCreateContribution(*serve.Context c) ( + auto parentResult = loadProject(c.param("id")) + object body = requestJSON(c) + auto remixResult = loadProject(body.remixProjectId.toStr()) + if !parentResult.ok or !remixResult.ok ( + c.notFound("project not found") + return + ) + object parent = parentResult.project.assert(object) + object remix = remixResult.project.assert(object) + string title = body.title.toStr().strip() + string description = "" + if body.contains("body") ( + description = body.body.toStr() + ) + string bountyId = "" + if body.contains("bountyId") and body.bountyId != null ( + bountyId = body.bountyId.toStr().strip() + ) + object created = createNativePull(c, parent, remix, title, description, bountyId) + if !created.ok ( + c.json(created.status.toInt(), {ok: false, error: created.error}) + return + ) + c.json(201, created) +) diff --git a/src/community/roadmap.osl b/src/community/roadmap.osl new file mode 100644 index 0000000..0f13b0e --- /dev/null +++ b/src/community/roadmap.osl @@ -0,0 +1,281 @@ +def handleListIdeas(*serve.Context c) ( + array ideas = ideaDocs.sort("created", "desc").all() + string username = contextString(c, "username") + array developmentFeed = developmentPulls() + for i ideas.len ( + object idea = ideas[i].assert(object) + boolean needsSave = false + if !idea.contains("source") ( + idea.source = "community" + if normalizeUsername(idea.author.toStr()) == "mist" ( + idea.source = "mistwarp" + ) + needsSave = true + ) + if !idea.contains("interested") ( + idea.interested = idea.source.toStr() == "mistwarp" + needsSave = true + ) + if !idea.contains("kind") ( + idea.kind = "idea" + needsSave = true + ) + if !idea.contains("commentCount") ( + idea.commentCount = loadComments("roadmap-" ++ idea["_id"].toStr()).len + needsSave = true + ) + if needsSave ( + putDocument(ideaDocs, idea["_id"].toStr(), idea) + ) + array likes = [] + array dislikes = [] + if idea.contains("likes") ( + likes = idea["likes"].assertElse(array, []) + ) else if idea.contains("voters") ( + likes = idea["voters"].assertElse(array, []) + ) + if idea.contains("dislikes") ( + dislikes = idea["dislikes"].assertElse(array, []) + ) + idea.likesCount = likes.len + idea.dislikesCount = dislikes.len + idea.score = likes.len - dislikes.len + idea.myVote = "" + if stringArrayContainsUser(likes, username) ( + idea.myVote = "like" + ) else if stringArrayContainsUser(dislikes, username) ( + idea.myVote = "dislike" + ) + idea.likes = [] + idea.dislikes = [] + idea.voters = [] + idea.pullRequests = resolveIdeaPulls(idea, developmentFeed) + ideas[i] = idea + ) + ideas.sortBy("score", "descending") + c.json(200, {ok: true, ideas: ideas}) +) + +def handleCreateIdea(*serve.Context c) ( + object body = requestJSON(c) + string title = body.title.toStr().strip() + string description = body.description.toStr().strip() + if title == "" or title.len > 120 or description == "" or description.len > 3000 ( + c.badRequest("add a title and a description") + return + ) + string category = "Other" + if body.contains("category") ( + category = body.category.toStr().trim(1, 40) + ) + string kind = "idea" + if body.contains("kind") ( + kind = body.kind.toStr().toLower() + ) + if kind != "idea" and kind != "bug" ( + c.badRequest("invalid roadmap entry type") + return + ) + string ideaId = "i" ++ randomString(14) + string source = "community" + if contextBool(c, "isAdmin") ( + source = "mistwarp" + ) + object idea = {_id: ideaId, title: title, description: description, category: category, kind: kind, author: contextString(c, "username"), authorId: contextString(c, "userId"), source: source, interested: source == "mistwarp", status: "open", pulls: [], likes: [contextString(c, "username")], dislikes: [], votes: 1, commentCount: 0, created: timestamp, edited: timestamp} + void ideaDocs.insertOne(idea) + idea.myVote = "like" + idea.likesCount = 1 + idea.dislikesCount = 0 + idea.score = 1 + idea.commentCount = 0 + idea.likes = [] + idea.dislikes = [] + idea.pullRequests = [] + c.json(201, {ok: true, idea: idea}) +) + +def handleVoteIdea(*serve.Context c) ( + string lockName = "idea-reactions:" ++ c.param("id") + sync.lock(lockName) + handleVoteIdeaLocked(c) + sync.unlock(lockName) +) + +def handleVoteIdeaLocked(*serve.Context c) ( + object idea = (ideaDocs.findById(c.param("id")) ?? {}) + if idea.len == 0 ( + c.notFound("idea not found") + return + ) + string username = contextString(c, "username") + string vote = requestJSON(c).vote.toStr().toLower() + if vote != "like" and vote != "dislike" ( + c.badRequest("vote must be like or dislike") + return + ) + array likes = [] + array dislikes = [] + if idea.contains("likes") ( + likes = idea["likes"].assertElse(array, []) + ) else if idea.contains("voters") ( + likes = idea["voters"].assertElse(array, []) + ) + if idea.contains("dislikes") ( + dislikes = idea["dislikes"].assertElse(array, []) + ) + number previousLikes = likes.len + boolean hadVote = false + if vote == "like" ( + hadVote = stringArrayContainsUser(likes, username) + ) else ( + hadVote = stringArrayContainsUser(dislikes, username) + ) + likes = removeUser(likes, username) + dislikes = removeUser(dislikes, username) + if !hadVote and vote == "like" ( + likes.append(username) + ) else if !hadVote and vote == "dislike" ( + dislikes.append(username) + ) + idea.likes = likes + idea.dislikes = dislikes + idea.votes = likes.len - dislikes.len + idea.edited = timestamp + if !putDocument(ideaDocs, idea["_id"].toStr(), idea) ( + c.internalError("could not save reaction") + return + ) + notifyLikeMilestones(idea.author.toStr(), "roadmap post", idea["_id"].toStr(), previousLikes, likes.len, "/roadmap#idea-" ++ idea["_id"].toStr(), {}) + string myVote = "" + if !hadVote ( + myVote = vote + ) + c.json(200, {ok: true, myVote: myVote, likesCount: likes.len, dislikesCount: dislikes.len, score: likes.len - dislikes.len}) +) + +def handleGetIdeaComments(*serve.Context c) ( + object idea = (ideaDocs.findById(c.param("id")) ?? {}) + if idea.len == 0 ( + c.notFound("idea not found") + return + ) + object page = commentPage(loadComments("roadmap-" ++ idea["_id"].toStr()), c) + page.ok = true + c.json(200, page) +) + +def handleCreateIdeaComment(*serve.Context c) ( + object idea = (ideaDocs.findById(c.param("id")) ?? {}) + if idea.len == 0 ( + c.notFound("idea not found") + return + ) + object body = requestJSON(c) + if !body.contains("content") or body.content == null ( + c.badRequest("comment content is required") + return + ) + string parentId = "" + if body.contains("parent") and body.parent != null ( + parentId = body.parent.toStr() + ) + object created = createComment("roadmap-" ++ idea["_id"].toStr(), contextString(c, "username"), contextString(c, "userId"), body.content.toStr(), parentId, "comment", 0, "") + if !created.ok ( + c.badRequest(created.error.toStr()) + return + ) + if idea.contains("commentCount") ( + idea.commentCount = idea.commentCount.toNum() + 1 + ) else ( + idea.commentCount = loadComments("roadmap-" ++ idea["_id"].toStr()).len + ) + putDocument(ideaDocs, idea["_id"].toStr(), idea) + string commenter = contextString(c, "username") + string parentAuthor = created.parentAuthor.toStr() + string preview = created.comment.content.toStr() + if parentAuthor != "" and normalizeUsername(parentAuthor) != normalizeUsername(commenter) ( + addNotification(parentAuthor, commenter, "roadmap_comment", {roadmapId: idea["_id"], roadmapTitle: idea.title, preview: preview}) + ) + if normalizeUsername(idea.author.toStr()) != normalizeUsername(parentAuthor) ( + addNotification(idea.author.toStr(), commenter, "roadmap_comment", {roadmapId: idea["_id"], roadmapTitle: idea.title, preview: preview}) + ) + notifyMentions(created.comment.content.toStr(), commenter, [idea.author.toStr(), parentAuthor], {roadmapId: idea["_id"], roadmapTitle: idea.title, preview: preview}) + c.json(201, created) +) + +def handleDeleteIdeaComment(*serve.Context c) ( + object idea = (ideaDocs.findById(c.param("id")) ?? {}) + if idea.len == 0 ( + c.notFound("idea not found") + return + ) + object deleted = deleteComment("roadmap-" ++ idea["_id"].toStr(), c.param("cid"), contextString(c, "username"), idea.author.toStr(), contextBool(c, "isAdmin")) + if !deleted.ok ( + c.json(deleted.status.toInt(), {ok: false, error: deleted.error}) + return + ) + if idea.contains("commentCount") ( + idea.commentCount = clampNumber( + idea.commentCount.toNum() - deleted.removed.toNum(), + 0, + idea.commentCount.toNum() + ) + ) else ( + idea.commentCount = loadComments("roadmap-" ++ idea["_id"].toStr()).len + ) + putDocument(ideaDocs, idea["_id"].toStr(), idea) + c.json(200, {ok: true}) +) + +def handleEditIdeaComment(*serve.Context c) ( + object idea = (ideaDocs.findById(c.param("id")) ?? {}) + if idea.len == 0 ( + c.notFound("idea not found") + return + ) + object body = requestJSON(c) + if !body.contains("content") or body.content == null ( + c.badRequest("comment content is required") + return + ) + object edited = editComment("roadmap-" ++ idea["_id"].toStr(), c.param("cid"), contextString(c, "username"), body.content.toStr()) + if !edited.ok ( + c.json(edited.status.toInt(), {ok: false, error: edited.error}) + return + ) + c.json(200, edited) +) + +def handleReactIdeaComment(*serve.Context c) ( + object idea = (ideaDocs.findById(c.param("id")) ?? {}) + if idea.len == 0 ( + c.notFound("idea not found") + return + ) + reactToComment(c, "roadmap-" ++ idea["_id"].toStr(), "/roadmap?idea=" ++ idea["_id"].toStr()) +) + +def handleUpdateIdea(*serve.Context c) ( + object idea = (ideaDocs.findById(c.param("id")) ?? {}) + if idea.len == 0 ( + c.notFound("idea not found") + return + ) + object body = requestJSON(c) + if body.contains("status") ( + string status = body.status.toStr().toLower() + if status == "open" or status == "planned" or status == "building" or status == "shipped" or status == "declined" ( + idea.status = status + ) + ) + if body.contains("interested") ( + idea.interested = body.interested == true + ) + if body.contains("pulls") and typeof(body.pulls) == "array" ( + idea.pulls = normalizePullLinks(body.pulls.assert(array)) + ) + idea.edited = timestamp + putDocument(ideaDocs, idea["_id"].toStr(), idea) + idea.pullRequests = resolveIdeaPulls(idea, developmentPulls()) + c.json(200, {ok: true, idea: idea}) +) diff --git a/src/community/share-cards.osl b/src/community/share-cards.osl new file mode 100644 index 0000000..70ef52d --- /dev/null +++ b/src/community/share-cards.osl @@ -0,0 +1,74 @@ +export { + warmShareCard, + xmlEscape +} + +def xmlEscape(string value) string ( + return value.replace("&", "&").replace("<", "<").replace(">", ">").replace("\"", """) +) + +def handleProjectCard(*serve.Context c) ( + auto res = loadProject(c.param("id")) + if !res.ok or !projectIsViewable(res.project.assert(object)) ( + c.notFound("project not found") + return + ) + object project = res.project.assert(object) + string title = xmlEscape(project.title.toStr()).trim(1, 58) + string owner = xmlEscape(project.owner.toStr()).trim(1, 30) + string thumbnail = xmlEscape(thumbUrlFor(project)) + number hearts = project.loves.assert(array).len + string svg = "MistWarp" ++ title ++ "by " ++ owner ++ "" ++ project.views.toStr() ++ " plays " ++ hearts.toStr() ++ " hearts" + c.setHeader("Cache-Control", "public, max-age=300") + c.send(200, "image/svg+xml; charset=utf-8", storagefs.Bytes(svg)) +) + +def shareCardPath(object project) string ( + return cardsDir ++ project.id.toStr() ++ "-" ++ project.edited.toStr() ++ ".png" +) + +def shareCardVisible(object project) boolean ( + return project.shared == true and projectIsViewable(project) and !projectHiddenByModerator(project) +) + +def ensureShareCard(object project) string ( + string path = shareCardPath(project) + if fs.exists(path) ( + return "" + ) + return sharecard.Render(thumbnailFileFor(project), project.title.toStr(), project.owner.toStr(), path) +) + +def warmShareCard(string id) ( + auto res = loadProject(id) + if res.ok and shareCardVisible(res.project.assert(object)) ( + void ensureShareCard(res.project.assert(object)) + ) +) + +def handleProjectSharePng(*serve.Context c) ( + auto res = loadProject(c.param("id")) + if !res.ok or !shareCardVisible(res.project.assert(object)) ( + c.notFound("project not found") + return + ) + object project = res.project.assert(object) + if ensureShareCard(project) != "" ( + string cover = thumbUrlFor(project) + c.setHeader("Location", if cover == "" then editorOrigin ++ "/images/apple-touch-icon.png" else cover) + c.setHeader("Cache-Control", "public, max-age=300") + c.status(302) + return + ) + boolean current = c.query("v") == project.edited.toStr() + string etag = "\"" ++ project.id.toStr() ++ "-" ++ project.edited.toStr() ++ "\"" + c.setHeader("ETag", etag) + c.setHeader("Cache-Control", if current then "public, max-age=31536000, immutable" else "public, max-age=3600") + if (c.header("If-None-Match") ?? "") == etag ( + c.status(304) + return + ) + c.setHeader("Content-Type", "image/png") + c.setHeader("X-Content-Type-Options", "nosniff") + c.file(shareCardPath(project)) +) diff --git a/src/community/space-comments.osl b/src/community/space-comments.osl new file mode 100644 index 0000000..dbe4e53 --- /dev/null +++ b/src/community/space-comments.osl @@ -0,0 +1,126 @@ +def handleGetSpaceComments(*serve.Context c) ( + object space = (spaceDocs.findById(c.param("id")) ?? {}) + if space.len == 0 or !canViewSpace(space, c) ( + c.notFound("space not found") + return + ) + object page = commentPage(loadComments(spaceCommentKey(space["_id"].toStr())), c) + page.ok = true + c.json(200, page) +) + +def handleCreateSpaceComment(*serve.Context c) ( + object space = (spaceDocs.findById(c.param("id")) ?? {}) + if space.len == 0 or !canViewSpace(space, c) ( + c.notFound("space not found") + return + ) + object body = requestJSON(c) + if !body.contains("content") or body.content == null ( + c.badRequest("comment content is required") + return + ) + string parentId = "" + if body.contains("parent") and body.parent != null ( + parentId = body.parent.toStr() + ) + object created = createComment(spaceCommentKey(space["_id"].toStr()), contextString(c, "username"), contextString(c, "userId"), body.content.toStr(), parentId, "comment", 0, "") + if !created.ok ( + c.badRequest(created.error.toStr()) + return + ) + if space.contains("commentCount") ( + space.commentCount = space.commentCount.toNum() + 1 + ) else ( + space.commentCount = loadComments(spaceCommentKey(space["_id"].toStr())).len + ) + putSpaceDocument(space) + string commenter = contextString(c, "username") + string parentAuthor = created.parentAuthor.toStr() + string newCommentId = created.comment.id.toStr() + object notificationData = {spaceId: space["_id"], spaceTitle: space.title, commentId: newCommentId, preview: created.comment.content.toStr()} + if parentAuthor != "" and normalizeUsername(parentAuthor) != normalizeUsername(commenter) ( + addNotification(parentAuthor, commenter, "reply", notificationData) + ) + if normalizeUsername(space.owner.toStr()) != normalizeUsername(commenter) and normalizeUsername(space.owner.toStr()) != normalizeUsername(parentAuthor) ( + addNotification(space.owner.toStr(), commenter, "space_comment", notificationData) + ) + notifyMentions(created.comment.content.toStr(), commenter, [space.owner.toStr(), parentAuthor], notificationData) + c.json(201, created) +) + +def handleDeleteSpaceComment(*serve.Context c) ( + object space = (spaceDocs.findById(c.param("id")) ?? {}) + if space.len == 0 ( + c.notFound("space not found") + return + ) + string moderator = space.owner.toStr() + if canManageSpace(space, c) ( + moderator = contextString(c, "username") + ) + object deleted = deleteComment(spaceCommentKey(space["_id"].toStr()), c.param("cid"), contextString(c, "username"), moderator, contextBool(c, "isAdmin")) + if !deleted.ok ( + c.json(deleted.status.toInt(), {ok: false, error: deleted.error}) + return + ) + if space.contains("commentCount") ( + space.commentCount = clampNumber(space.commentCount.toNum() - deleted.removed.toNum(), 0, space.commentCount.toNum()) + ) else ( + space.commentCount = loadComments(spaceCommentKey(space["_id"].toStr())).len + ) + putSpaceDocument(space) + c.json(200, {ok: true}) +) + +def handleEditSpaceComment(*serve.Context c) ( + object space = (spaceDocs.findById(c.param("id")) ?? {}) + if space.len == 0 or !canViewSpace(space, c) ( + c.notFound("space not found") + return + ) + object body = requestJSON(c) + if !body.contains("content") or body.content == null ( + c.badRequest("comment content is required") + return + ) + object edited = editComment(spaceCommentKey(space["_id"].toStr()), c.param("cid"), contextString(c, "username"), body.content.toStr()) + if !edited.ok ( + c.json(edited.status.toInt(), {ok: false, error: edited.error}) + return + ) + c.json(200, edited) +) + +def handlePinSpaceComment(*serve.Context c) ( + object space = (spaceDocs.findById(c.param("id")) ?? {}) + if space.len == 0 or !canViewSpace(space, c) ( + c.notFound("space not found") + return + ) + string moderator = space.owner.toStr() + if canManageSpace(space, c) ( + moderator = contextString(c, "username") + ) + object body = requestJSON(c) + boolean pinned = true + if body.contains("pinned") and body.pinned != null ( + pinned = body.pinned == true + ) + object updated = setCommentPin(spaceCommentKey(space["_id"].toStr()), c.param("cid"), contextString(c, "username"), moderator, contextBool(c, "isAdmin"), pinned) + if !updated.ok ( + c.json(updated.status.toInt(), {ok: false, error: updated.error}) + return + ) + updated.comment = commentResponse(updated.comment.assert(object), contextString(c, "username")) + c.json(200, updated) +) + +def handleReactSpaceComment(*serve.Context c) ( + object space = (spaceDocs.findById(c.param("id")) ?? {}) + if space.len == 0 or !canViewSpace(space, c) ( + c.notFound("space not found") + return + ) + reactToComment(c, spaceCommentKey(space["_id"].toStr()), "/spaces/" ++ space["_id"].toStr()) +) diff --git a/src/community/space-memberships.osl b/src/community/space-memberships.osl new file mode 100644 index 0000000..7ea63a9 --- /dev/null +++ b/src/community/space-memberships.osl @@ -0,0 +1,157 @@ +export { + migrateSpaceMemberships, + removeProjectFromSpaces, + removeProjectsFromSpaces, + syncSpaceMemberships +} + +def appendSpaceUserKey(array keys, string username) array ( + string key = normalizeUsername(username) + if key != "" and !keys.contains(key) ( + keys.append(key) + ) + return keys +) + +def spaceRelatedUserKeys(object space) array ( + array keys = appendSpaceUserKey([], space.owner.toStr()) + array managers = spaceManagers(space) + for i managers.len ( + keys = appendSpaceUserKey(keys, managers[i].toStr()) + ) + array followers = space.followers.assertElse(array, []) + for i followers.len ( + keys = appendSpaceUserKey(keys, followers[i].toStr()) + ) + array curatorInvites = spaceCuratorInvites(space) + for i curatorInvites.len ( + object invite = curatorInvites[i].assert(object) + keys = appendSpaceUserKey(keys, invite.username.toStr()) + ) + if space.kind.toStr() == "challenge" ( + array participants = challengeArray(space, "participants") + for i participants.len ( + keys = appendSpaceUserKey(keys, participants[i].toStr()) + ) + array judges = challengeArray(space, "judges") + for i judges.len ( + keys = appendSpaceUserKey(keys, judges[i].toStr()) + ) + array judgeInvites = challengeArray(space, "judgeInvites") + for i judgeInvites.len ( + object invite = judgeInvites[i].assert(object) + keys = appendSpaceUserKey(keys, invite.username.toStr()) + ) + ) + return keys +) + +def syncSpaceMemberships(object space) boolean ( + string spaceId = space["_id"].toStr() + array keys = spaceRelatedUserKeys(space) + string signature = json.stringify(keys) + string stateId = "space:" ++ spaceId + object state = (spaceMemberDocs.findById(stateId) ?? {}) + if state.len > 0 and state.signature.toStr() == signature ( + return true + ) + array previous = spaceMemberDocs.where("spaceId", "==", spaceId).all() + for i previous.len ( + object membership = previous[i].assert(object) + void spaceMemberDocs.deleteOne({_id: membership["_id"]}) + ) + for i keys.len ( + string userKey = keys[i].toStr() + void spaceMemberDocs.insertOne({_id: "member:" ++ userKey ++ ":" ++ spaceId, kind: "member", userKey: userKey, spaceId: spaceId}) + ) + return putDocument(spaceMemberDocs, stateId, {kind: "state", signature: signature}) +) + +def putSpaceDocument(object space) boolean ( + boolean saved = putDocument(spaceDocs, space["_id"].toStr(), space) + if saved ( + syncSpaceMemberships(space) + ) + return saved +) + +def deleteSpaceMemberships(string spaceId) boolean ( + array memberships = spaceMemberDocs.where("spaceId", "==", spaceId).all() + for i memberships.len ( + object membership = memberships[i].assert(object) + void spaceMemberDocs.deleteOne({_id: membership["_id"]}) + ) + void spaceMemberDocs.deleteOne({_id: "space:" ++ spaceId}) + return true +) + +def migrateSpaceMemberships() boolean ( + string migrationId = "migration:space-members-v1" + if (spaceMemberDocs.findById(migrationId) ?? {}).len > 0 ( + return true + ) + array previous = spaceMemberDocs.all() + for i previous.len ( + object document = previous[i].assert(object) + void spaceMemberDocs.deleteOne({_id: document["_id"]}) + ) + array spaces = spaceDocs.all() + for i spaces.len ( + syncSpaceMemberships(spaces[i].assert(object)) + ) + return putDocument(spaceMemberDocs, migrationId, {kind: "migration", completed: timestamp}) +) + +def removeProjectsFromSpaces(array removedProjectIds) boolean ( + array spaces = spaceDocs.all() + for i spaces.len ( + object space = spaces[i].assert(object) + array storedProjectIds = space.projects.assertElse(array, []) + array projectIds = [] + boolean changed = false + for j storedProjectIds.len ( + if removedProjectIds.contains(storedProjectIds[j].toStr()) ( + changed = true + ) else ( + projectIds.append(storedProjectIds[j]) + ) + ) + if changed ( + space.projects = projectIds + array submissions = [] + array storedSubmissions = challengeArray(space, "submissions") + for j storedSubmissions.len ( + object submission = storedSubmissions[j].assert(object) + if !removedProjectIds.contains(submission.projectId.toStr()) ( + submissions.append(submission) + ) + ) + space.submissions = submissions + array scores = [] + array storedScores = challengeArray(space, "scores") + for j storedScores.len ( + object score = storedScores[j].assert(object) + if !removedProjectIds.contains(score.projectId.toStr()) ( + scores.append(score) + ) + ) + space.scores = scores + array votes = [] + array storedVotes = challengeArray(space, "audienceVotes") + for j storedVotes.len ( + object vote = storedVotes[j].assert(object) + if !removedProjectIds.contains(vote.projectId.toStr()) ( + votes.append(vote) + ) + ) + space.audienceVotes = votes + space.edited = timestamp + putSpaceDocument(space) + ) + ) + return true +) + +def removeProjectFromSpaces(string projectId) boolean ( + return removeProjectsFromSpaces([projectId]) +) diff --git a/src/community/space-model.osl b/src/community/space-model.osl new file mode 100644 index 0000000..b0d2aa0 --- /dev/null +++ b/src/community/space-model.osl @@ -0,0 +1,138 @@ +export { + stringArrayContainsUser +} + +def stringArrayContainsUser(array users, string username) boolean ( + string wanted = normalizeUsername(username) + for i users.len ( + if normalizeUsername(users[i].toStr()) == wanted ( + return true + ) + ) + return false +) + +def removeUser(array users, string username) array ( + string wanted = normalizeUsername(username) + array next = [] + for i users.len ( + if normalizeUsername(users[i].toStr()) != wanted ( + next.append(users[i]) + ) + ) + return next +) + +def validSpaceKind(string kind) boolean ( + return (kind == "studio") or (kind == "collection") or (kind == "challenge") +) + +def validSpaceVisibility(string visibility) boolean ( + return (visibility == "public") or (visibility == "unlisted") or (visibility == "private") +) + +def spaceManagers(object space) array ( + if space.contains("managers") ( + return space["managers"].assertElse(array, []) + ) + return [] +) + +def spaceCuratorInvites(object space) array ( + if space.contains("curatorInvites") ( + return space["curatorInvites"].assertElse(array, []) + ) + return [] +) + +def spaceLikes(object space) array ( + if space.contains("likes") ( + return space["likes"].assertElse(array, []) + ) + return [] +) + +def spaceBrokenHearts(object space) array ( + if space.contains("brokenHearts") ( + return space["brokenHearts"].assertElse(array, []) + ) + return [] +) + +def spaceProjectIds(object space) array ( + if space.contains("projects") and space.projects != null ( + return space.projects.assertElse(array, []) + ) + return [] +) + +def spaceFollowers(object space) array ( + if space.contains("followers") and space.followers != null ( + return space.followers.assertElse(array, []) + ) + return [] +) + +def spaceCommentKey(string id) string ( + return "space-" ++ id +) + +def spaceThumbnailPath(string id) string ( + return thumbnailsDir ++ "space-" ++ id ++ ".png" +) + +def spaceThumbnailUrl(object space) string ( + string path = spaceThumbnailPath(space["_id"].toStr()) + if !fs.exists(path) ( + return "" + ) + return appURL ++ "/thumbnails/space-" ++ space["_id"].toStr() ++ ".png?t=" ++ space.edited.toStr() +) + +def isSpaceOwner(object space,*serve.Context c) boolean ( + string ownerId = "" + if space.contains("ownerId") and space.ownerId != null ( + ownerId = space.ownerId.toStr() + ) + return contextBool(c, "isAdmin") or identityMatches(ownerId, space.owner.toStr(), contextString(c, "userId"), contextString(c, "username")) +) + +def searchEntryOwnedByContext(object project,*serve.Context c) boolean ( + string ownerId = "" + if project.contains("ownerId") and project.ownerId != null ( + ownerId = project.ownerId.toStr() + ) + return identityMatches(ownerId, project.owner.toStr(), contextString(c, "userId"), contextString(c, "username")) +) + +def isSpaceInvitee(object space,*serve.Context c) boolean ( + array invites = spaceCuratorInvites(space) + for i invites.len ( + object invite = invites[i].assert(object) + if normalizeUsername(invite.username.toStr()) == normalizeUsername(contextString(c, "username")) ( + return true + ) + ) + return false +) + +def canManageSpace(object space,*serve.Context c) boolean ( + if contextBool(c, "isAdmin") ( + return true + ) + string username = contextString(c, "username") + if isSpaceOwner(space, c) ( + return true + ) + if canManageGroupContent(space, c) ( + return true + ) + return stringArrayContainsUser(spaceManagers(space), username) +) + +def canViewSpace(object space,*serve.Context c) boolean ( + if space.visibility.toStr() == "public" or space.visibility.toStr() == "unlisted" ( + return true + ) + return canManageSpace(space, c) or isSpaceInvitee(space, c) +) diff --git a/src/community/space-responses.osl b/src/community/space-responses.osl new file mode 100644 index 0000000..d18f5c6 --- /dev/null +++ b/src/community/space-responses.osl @@ -0,0 +1,152 @@ +def spaceTotalPlaytime(array ids) number ( + number total = 0 + for i ids.len ( + total += projectTotalPlaytime(ids[i].toStr()) + ) + return total +) + +def spaceResponse(object space,*serve.Context c) object ( + if !space.contains("commentCount") ( + space.commentCount = loadComments(spaceCommentKey(space["_id"].toStr())).len + putSpaceDocument(space) + ) + array projects = [] + array ids = spaceProjectIds(space) + if space.kind.toStr() == "challenge" ( + projects = challengeProjects(space, c) + ) else ( + for i ids.len ( + auto loaded = loadProject(ids[i].toStr()) + if loaded.ok == true ( + object project = loaded.project.assert(object) + if project.shared == true or projectVisibility(project) == "unlisted" or canManageSpace(space, c) ( + projects.append(indexEntryFromProject(project)) + ) + ) + ) + projects = projectsVisibleTo(projects, c) + ) + object response = space + response.projects = projects + array visibleIds = [] + for i projects.len ( + object visibleProject = projects[i].assert(object) + visibleIds.append(visibleProject.id) + ) + response.projectIds = if canManageSpace(space, c) then ids else visibleIds + response.canManage = canManageSpace(space, c) + response.isOwner = isSpaceOwner(space, c) + response.following = stringArrayContainsUser(spaceFollowers(space), contextString(c, "username")) + response.followerCount = spaceFollowers(space).len + response.invited = isSpaceInvitee(space, c) + array likes = spaceLikes(space) + array brokenHearts = spaceBrokenHearts(space) + response.likeCount = likes.len + response.brokenHeartCount = brokenHearts.len + response.commentCount = space.commentCount + response.myReaction = "" + if stringArrayContainsUser(likes, contextString(c, "username")) ( + response.myReaction = "heart" + ) else if stringArrayContainsUser(brokenHearts, contextString(c, "username")) ( + response.myReaction = "brokenheart" + ) + response.likes = [] + response.brokenHearts = [] + response.curatorInvites = [] + response.thumbnailUrl = spaceThumbnailUrl(space) + if space.kind.toStr() != "challenge" ( + response.totalPlaytimeMs = spaceTotalPlaytime(ids) + ) + if space.kind.toStr() == "challenge" ( + if !response.contains("theme") ( + response.theme = "" + ) + if !response.contains("rules") ( + response.rules = "" + ) + if !response.contains("judgingEndsAt") ( + response.judgingEndsAt = space.endsAt.toNum() + 604800000 + ) + response.criteria = challengeCriteria(space) + if !response.contains("communityVoting") ( + response.communityVoting = false + ) + response.votingMode = challengeVotingMode(space) + response.winner = challengeWinner(space, projects) + if !response.contains("resultsPublishedAt") ( + response.resultsPublishedAt = 0 + ) + response.phase = challengePhase(space) + response.isJudge = isChallengeJudge(space, contextString(c, "username")) + response.judgeInvited = isChallengeJudgeInvitee(space, contextString(c, "username")) + response.judgeCount = challengeJudges(space).len + response.participantCount = challengeArray(space, "participants").len + response.joined = stringArrayContainsUser(challengeArray(space, "participants"), contextString(c, "username")) + response.judges = challengeJudges(space) + response.judgeInvites = [] + response.scores = [] + response.audienceVotes = [] + response.participants = [] + ) + return response +) + +def spaceListResponse(object space,*serve.Context c) object ( + array ids = spaceProjectIds(space) + array followers = spaceFollowers(space) + string thumbnailURL = spaceThumbnailUrl(space) + number projectCount = 0 + boolean canManage = canManageSpace(space, c) + for i ids.len ( + object project = (projectSearchDocs.findById(ids[i].toStr()) ?? {}) + boolean hidden = projectHiddenFrom(project, c) + if project.len > 0 and !hidden and (project.shared == true or project.visibility.toStr() == "unlisted" or canManage) ( + projectCount += 1 + if thumbnailURL == "" and project.contains("thumbUrl") and thumbnailIsPublic(projectVisibility(project), false) ( + thumbnailURL = project.thumbUrl.toStr() + ) + ) + ) + object response = { + _id: space["_id"], + title: space.title, + description: space.description, + kind: space.kind, + visibility: space.visibility, + owner: space.owner, + groupTag: groupTagOf(space), + edited: space.edited, + startsAt: space.startsAt, + endsAt: space.endsAt, + openSubmissions: space.openSubmissions, + projects: [], + projectCount: projectCount, + followers: [], + followerCount: followers.len, + thumbnailUrl: thumbnailURL, + canManage: canManage, + isOwner: isSpaceOwner(space, c), + following: stringArrayContainsUser(followers, contextString(c, "username")), + invited: isSpaceInvitee(space, c) + } + if space.kind.toStr() == "challenge" ( + response.participantCount = challengeArray(space, "participants").len + ) else ( + response.totalPlaytimeMs = spaceTotalPlaytime(ids) + ) + return response +) + +def spaceManagementResponse(object space,*serve.Context c) object ( + array invites = spaceCuratorInvites(space) + array judgeInvites = challengeArray(space, "judgeInvites") + array participants = challengeArray(space, "participants") + object response = spaceResponse(space, c) + response.curatorInvites = invites + if space.kind.toStr() == "challenge" ( + response.judgeInvites = judgeInvites + response.participants = participants + ) + return response +) diff --git a/src/community/spaces.osl b/src/community/spaces.osl new file mode 100644 index 0000000..1731725 --- /dev/null +++ b/src/community/spaces.osl @@ -0,0 +1,737 @@ +def handleListSpaces(*serve.Context c) ( + string kind = (c.query("kind") ?? "").toLower().strip() + string q = (c.query("q") ?? "").toLower().strip() + number limit = clampNumber(c.queryInt("limit", 24), 1, 100) + number offset = c.queryInt("offset", 0) + number startsBefore = c.queryInt("startsBefore", 0) + number endsAfter = c.queryInt("endsAfter", 0) + number resultsAfter = c.queryInt("resultsAfter", 0) + array stored = [] + + if q == "" and kind == "challenge" and resultsAfter > 0 ( + stored = spaceDocs.where("visibility", "==", "public").where("kind", "==", kind).where("resultsPublishedAt", ">=", resultsAfter).sort("resultsPublishedAt", "desc").limit(limit).all() + array spaces = [] + for i stored.len ( + object published = stored[i].assert(object) + object listed = spaceListResponse(published, c) + listed.resultsPublishedAt = published.resultsPublishedAt + listed.winner = challengeWinner(published, challengeProjects(published, c)) + if listed.winner.len > 0 ( + spaces.append(listed) + ) + ) + c.json(200, {ok: true, total: spaces.len, spaces: spaces}) + return + ) + + if q == "" and kind == "challenge" and startsBefore > 0 and endsAfter > 0 ( + number total = spaceDocs.where("visibility", "==", "public").where("kind", "==", kind).where("startsAt", "<=", startsBefore).where("endsAt", ">=", endsAfter).count() + offset = clampNumber(offset, 0, total) + stored = spaceDocs.where("visibility", "==", "public").where("kind", "==", kind).where("startsAt", "<=", startsBefore).where("endsAt", ">=", endsAfter).sort("startsAt", "asc").limit(offset + limit).all() + array page = paginate(stored, offset, limit) + array spaces = [] + for i page.len ( + spaces.append(spaceListResponse(page[i].assert(object), c)) + ) + c.json(200, {ok: true, total: total, spaces: spaces}) + return + ) + + if q == "" ( + number total = 0 + if kind != "" ( + total = spaceDocs.where("visibility", "==", "public").where("kind", "==", kind).count() + offset = clampNumber(offset, 0, total) + stored = spaceDocs.where("visibility", "==", "public").where("kind", "==", kind).sort("edited", "desc").limit(offset + limit).all() + ) else ( + total = spaceDocs.where("visibility", "==", "public").count() + offset = clampNumber(offset, 0, total) + stored = spaceDocs.where("visibility", "==", "public").sort("edited", "desc").limit(offset + limit).all() + ) + array page = paginate(stored, offset, limit) + array spaces = [] + for i page.len ( + spaces.append(spaceListResponse(page[i].assert(object), c)) + ) + c.json(200, {ok: true, total: total, spaces: spaces}) + return + ) + + if kind != "" ( + stored = spaceDocs.where("visibility", "==", "public").where("kind", "==", kind).sort("edited", "desc").all() + ) else ( + stored = spaceDocs.where("visibility", "==", "public").sort("edited", "desc").all() + ) + array matches = [] + for i stored.len ( + object space = stored[i].assert(object) + boolean include = space.visibility.toStr() == "public" + if include and kind != "" ( + include = space.kind.toStr() == kind + ) + if include and q != "" ( + include = space.title.toStr().toLower().contains(q) or space.description.toStr().toLower().contains(q) + ) + if include ( + number score = spaceSearchScore(space.title.toStr(), space.description.toStr(), space.owner.toStr(), q) + matches.append({rank: searchRank(score, spaceFollowers(space).len), space: space}) + ) + ) + matches.sortBy("rank", "descending") + number total = matches.len + offset = clampNumber(offset, 0, total) + array page = paginate(matches, offset, limit) + array spaces = [] + for i page.len ( + object row = page[i].assert(object) + spaces.append(spaceListResponse(row.space.assert(object), c)) + ) + c.json(200, {ok: true, total: total, spaces: spaces}) +) + +def handleGetSpace(*serve.Context c) ( + object space = (spaceDocs.findById(c.param("id")) ?? {}) + if space.len == 0 or !canViewSpace(space, c) ( + c.notFound("space not found") + return + ) + c.json(200, {ok: true, space: spaceResponse(space, c)}) +) + +def handleCreateSpace(*serve.Context c) ( + object body = requestJSON(c) + number ownedSpaces = spaceDocs.where("ownerId", "==", contextString(c, "userId")).count() + if !contextBool(c, "isAdmin") and ownedSpaces >= mistwarpBenefitsForUserId(contextString(c, "userId")).maxSpaces.toNum() ( + c.json(413, {ok: false, error: "space limit reached; delete a space or upgrade your plan"}) + return + ) + string groupTag = "" + if body.contains("groupTag") and body.groupTag != null and body.groupTag.toStr().strip() != "" ( + groupTag = normalizeGroupTag(body.groupTag.toStr()) + if groupTag == "" ( + c.badRequest("invalid group tag") + return + ) + if !canManageGroupTag(groupTag, c) ( + c.forbidden("you cannot create spaces for this group") + return + ) + ) + string title = body.title.toStr().strip() + string kind = body.kind.toStr().toLower().strip() + if title == "" or title.len > 100 ( + c.badRequest("title must be between 1 and 100 characters") + return + ) + if !validSpaceKind(kind) ( + c.badRequest("invalid space type") + return + ) + string visibility = "public" + if body.contains("visibility") ( + visibility = body.visibility.toStr().toLower() + ) + if !validSpaceVisibility(visibility) ( + c.badRequest("invalid visibility") + return + ) + string description = "" + if body.contains("description") ( + description = body.description.toStr().strip() + ) + if description.len > 5000 ( + c.badRequest("description is too long") + return + ) + number startsAt = 0 + number endsAt = 0 + if body.contains("startsAt") ( + startsAt = body.startsAt.toNum() + ) + if body.contains("endsAt") ( + endsAt = body.endsAt.toNum() + ) + if kind == "challenge" and (startsAt <= 0 or endsAt <= startsAt) ( + c.badRequest("challenge dates must be in order") + return + ) + string votingMode = "judges" + if body.contains("votingMode") ( + votingMode = body.votingMode.toStr().toLower().strip() + ) + if votingMode != "judges" and votingMode != "audience" ( + c.badRequest("invalid voting mode") + return + ) + string id = "s" ++ randomString(12) + object space = { + _id: id, + title: title, + description: description, + kind: kind, + visibility: visibility, + owner: contextString(c, "username"), + ownerId: contextString(c, "userId"), + groupTag: groupTag, + managers: [], + curatorInvites: [], + projects: [], + followers: [], + likes: [], + brokenHearts: [], + openSubmissions: body.contains("openSubmissions") and body.openSubmissions == true, + startsAt: startsAt, + endsAt: endsAt, + judgingEndsAt: endsAt + 604800000, + theme: "", + rules: "", + criteria: [{id: "overall", name: "Overall", description: "How strong is the entry as a whole?", weight: 1}], + judges: [], + judgeInvites: [], + scores: [], + audienceVotes: [], + submissions: [], + participants: [], + commentCount: 0, + communityVoting: false, + votingMode: votingMode, + resultsPublishedAt: 0, + created: timestamp, + edited: timestamp + } + putSpaceDocument(space) + c.json(201, {ok: true, space: spaceResponse(space, c)}) +) + +def handleUpdateSpace(*serve.Context c) ( + object space = (spaceDocs.findById(c.param("id")) ?? {}) + if space.len == 0 ( + c.notFound("space not found") + return + ) + if !canManageSpace(space, c) ( + c.forbidden("you cannot manage this space") + return + ) + object body = requestJSON(c) + if body.contains("owner") ( + boolean canTransfer = isSpaceOwner(space, c) or canManageGroupContent(space, c) + if !canTransfer ( + c.forbidden("you cannot transfer this space") + return + ) + string requestedOwner = body.owner.toStr().strip() + string nextOwnerId = discoverUserIdentity(requestedOwner) + if nextOwnerId == "" ( + c.badRequest("Rotur user not found") + return + ) + string nextOwner = usernameForId(nextOwnerId) + if nextOwner == "" ( + c.badRequest("Rotur user not found") + return + ) + number nextOwnerLimit = mistwarpBenefitsForUserId(nextOwnerId).maxSpaces.toNum() + if !identityMatches((space.ownerId ?? "").toStr(), space.owner.toStr(), nextOwnerId, nextOwner) and spaceDocs.where("ownerId", "==", nextOwnerId).count() >= nextOwnerLimit ( + c.badRequest("the new owner has reached their space limit") + return + ) + space.owner = nextOwner + space.ownerId = nextOwnerId + if !body.contains("keepGroup") or body.keepGroup != true ( + space.groupTag = "" + ) + ) + if body.contains("groupTag") ( + string nextGroupTag = normalizeGroupTag(body.groupTag.toStr()) + if body.groupTag.toStr().strip() == "" ( + nextGroupTag = "" + ) else if nextGroupTag == "" ( + c.badRequest("invalid group tag") + return + ) else if !canManageGroupTag(nextGroupTag, c) ( + c.forbidden("you cannot move this space into that group") + return + ) + space.groupTag = nextGroupTag + ) + if body.contains("title") ( + string title = body.title.toStr().strip() + if title == "" or title.len > 100 ( + c.badRequest("invalid title") + return + ) + space.title = title + ) + if body.contains("description") ( + string description = body.description.toStr().strip() + if description.len > 5000 ( + c.badRequest("description is too long") + return + ) + space.description = description + ) + if body.contains("visibility") and validSpaceVisibility(body.visibility.toStr()) ( + space.visibility = body.visibility + ) + if body.contains("openSubmissions") ( + space.openSubmissions = body.openSubmissions == true + ) + if body.contains("startsAt") ( + space.startsAt = body.startsAt.toNum() + ) + if body.contains("endsAt") ( + space.endsAt = body.endsAt.toNum() + ) + if body.contains("judgingEndsAt") ( + space.judgingEndsAt = body.judgingEndsAt.toNum() + ) + if body.contains("theme") ( + string theme = body.theme.toStr().strip() + if theme.len > 200 ( + c.badRequest("theme is too long") + return + ) + space.theme = theme + ) + if body.contains("rules") ( + string rules = body.rules.toStr().strip() + if rules.len > 10000 ( + c.badRequest("rules are too long") + return + ) + space.rules = rules + ) + if body.contains("communityVoting") ( + space.communityVoting = body.communityVoting == true + ) + if body.contains("votingMode") and space.kind.toStr() == "challenge" ( + string votingMode = body.votingMode.toStr().toLower().strip() + if votingMode != "judges" and votingMode != "audience" ( + c.badRequest("invalid voting mode") + return + ) + if votingMode != challengeVotingMode(space) and challengePhase(space) == "results" ( + c.badRequest("the voting mode cannot change after results are published") + return + ) + space.votingMode = votingMode + ) + if body.contains("criteria") and space.kind.toStr() == "challenge" ( + if challengeArray(space, "scores").len > 0 ( + c.badRequest("judging criteria cannot change after scoring begins") + return + ) + array rawCriteria = body.criteria.assertElse(array, []) + array criteria = [] + for i rawCriteria.len ( + object rawCriterion = rawCriteria[i].assert(object) + string name = rawCriterion.name.toStr().strip() + string description = rawCriterion.description.toStr().strip() + if name != "" and name.len <= 60 and description.len <= 300 and criteria.len < 8 ( + string criterionId = rawCriterion.id.toStr().strip() + if criterionId == "" ( + criterionId = "jc" ++ randomString(8) + ) + criteria.append({id: criterionId, name: name, description: description, weight: clampNumber(rawCriterion.weight.toNum(), 1, 5)}) + ) + ) + if criteria.len == 0 ( + c.badRequest("add at least one judging criterion") + return + ) + space.criteria = criteria + ) + if space.kind.toStr() == "challenge" and (space.startsAt.toNum() <= 0 or space.endsAt.toNum() <= space.startsAt.toNum() or space.judgingEndsAt.toNum() <= space.endsAt.toNum()) ( + c.badRequest("challenge dates must be in order") + return + ) + space.edited = timestamp + putSpaceDocument(space) + c.json(200, {ok: true, space: spaceResponse(space, c)}) +) + +def handleDeleteSpace(*serve.Context c) ( + object space = (spaceDocs.findById(c.param("id")) ?? {}) + if space.len == 0 ( + c.notFound("space not found") + return + ) + if !isSpaceOwner(space, c) ( + c.forbidden("only the owner can delete this space") + return + ) + void spaceDocs.deleteOne({_id: space["_id"]}) + deleteSpaceMemberships(space["_id"].toStr()) + void fs.remove(commentsDir ++ spaceCommentKey(space["_id"].toStr()) ++ ".json") + void fs.remove(commentsDir ++ spaceCommentKey(space["_id"].toStr()) ++ ".jsonl") + if fs.exists(spaceThumbnailPath(space["_id"].toStr())) ( + void fs.remove(spaceThumbnailPath(space["_id"].toStr())) + ) + c.json(200, {ok: true}) +) + +def handleSetSpaceThumbnail(*serve.Context c) ( + object space = (spaceDocs.findById(c.param("id")) ?? {}) + if space.len == 0 ( + c.notFound("space not found") + return + ) + if space.kind.toStr() != "studio" or !canManageSpace(space, c) ( + c.forbidden("you cannot change this studio thumbnail") + return + ) + auto fileResult = c.formFile("thumbnail") + if fileResult.isErr() ( + c.badRequest("missing thumbnail file") + return + ) + object thumbnail = fileResult.unwrap() + if thumbnail.size.toNum() > maxThumbBytes ( + c.badRequest("thumbnail too large") + return + ) + if !fs.writeFileBytes(spaceThumbnailPath(space["_id"].toStr()), thumbnail.data) ( + c.internalError("thumbnail save failed") + return + ) + space.edited = timestamp + putSpaceDocument(space) + c.json(200, {ok: true, thumbnailUrl: spaceThumbnailUrl(space)}) +) + +def handleAddSpaceProject(*serve.Context c) ( + object space = (spaceDocs.findById(c.param("id")) ?? {}) + object project = (projectSearchDocs.findById(c.param("project")) ?? {}) + if space.len == 0 or project.len == 0 ( + c.notFound("space or project not found") + return + ) + boolean projectVisible = project.shared == true or project.visibility.toStr() == "unlisted" + if !projectVisible ( + c.forbidden("only shared or unlisted projects can be added") + return + ) + boolean allowed = canManageSpace(space, c) + boolean ownsProject = searchEntryOwnedByContext(project, c) + boolean participantCanSubmit = space.openSubmissions == true and ownsProject + if space.kind.toStr() == "challenge" ( + participantCanSubmit = participantCanSubmit and challengePhase(space) == "submissions" + ) + allowed = allowed or participantCanSubmit + if !allowed ( + c.forbidden("submissions are closed") + return + ) + array ids = spaceProjectIds(space) + if !ids.contains(project.id.toStr()) ( + ids.append(project.id.toStr()) + space.projects = ids + if space.kind.toStr() == "challenge" ( + array submissions = challengeArray(space, "submissions") + submissions.append({projectId: project.id, owner: project.owner, submittedAt: timestamp}) + space.submissions = submissions + array participants = challengeArray(space, "participants") + if !stringArrayContainsUser(participants, project.owner.toStr()) ( + participants.append(project.owner) + ) + space.participants = participants + ) + space.edited = timestamp + putSpaceDocument(space) + addNotification(space.owner.toStr(), contextString(c, "username"), "space_project", {spaceId: space["_id"], spaceTitle: space.title, projectId: project.id, projectTitle: project.title}) + ) + c.json(200, {ok: true, space: spaceResponse(space, c)}) +) + +def handleRemoveSpaceProject(*serve.Context c) ( + object space = (spaceDocs.findById(c.param("id")) ?? {}) + if space.len == 0 ( + c.notFound("space not found") + return + ) + object project = (projectSearchDocs.findById(c.param("project")) ?? {}) + boolean allowed = canManageSpace(space, c) + if project.len > 0 ( + allowed = allowed or searchEntryOwnedByContext(project, c) + ) + if !allowed ( + c.forbidden("you cannot remove this project") + return + ) + space.projects = removeString(spaceProjectIds(space), c.param("project")) + if space.kind.toStr() == "challenge" ( + array submissions = challengeArray(space, "submissions") + array nextSubmissions = [] + for i submissions.len ( + object submission = submissions[i].assert(object) + if submission.projectId.toStr() != c.param("project") ( + nextSubmissions.append(submission) + ) + ) + space.submissions = nextSubmissions + ) + space.edited = timestamp + putSpaceDocument(space) + c.json(200, {ok: true, space: spaceResponse(space, c)}) +) + +def handleFollowSpace(*serve.Context c) ( + object space = (spaceDocs.findById(c.param("id")) ?? {}) + if space.len == 0 or !canViewSpace(space, c) ( + c.notFound("space not found") + return + ) + array followers = spaceFollowers(space) + if !stringArrayContainsUser(followers, contextString(c, "username")) ( + followers.append(contextString(c, "username")) + space.followers = followers + putSpaceDocument(space) + ) + c.json(200, {ok: true, following: true, followers: followers.len}) +) + +def handleUnfollowSpace(*serve.Context c) ( + object space = (spaceDocs.findById(c.param("id")) ?? {}) + if space.len == 0 ( + c.notFound("space not found") + return + ) + array followers = spaceFollowers(space) + if stringArrayContainsUser(followers, contextString(c, "username")) ( + followers = removeUser(followers, contextString(c, "username")) + space.followers = followers + putSpaceDocument(space) + ) + c.json(200, {ok: true, following: false, followers: followers.len}) +) + +def handleMySpaces(*serve.Context c) ( + string userKey = normalizeUsername(contextString(c, "username")) + array memberships = spaceMemberDocs.where("userKey", "==", userKey).all() + array stored = [] + object seen = {} + for i memberships.len ( + object membership = memberships[i].assert(object) + object space = (spaceDocs.findById(membership.spaceId.toStr()) ?? {}) + if space.len > 0 ( + seen[space["_id"].toStr()] = true + stored.append(space) + ) + ) + array allSpaces = spaceDocs.all() + for i allSpaces.len ( + object space = allSpaces[i].assert(object) + string spaceId = space["_id"].toStr() + if !seen.contains(spaceId) ( + boolean include = isSpaceOwner(space, c) + if !include and groupTagOf(space) != "" ( + include = canManageGroupContent(space, c) + ) + if include ( + seen[spaceId] = true + stored.append(space) + ) + ) + ) + stored.sortBy("edited", "descending") + array spaces = [] + for i stored.len ( + object space = stored[i].assert(object) + spaces.append(spaceListResponse(space, c)) + ) + c.json(200, {ok: true, spaces: spaces}) +) + +def handleGetSpaceManagement(*serve.Context c) ( + object space = (spaceDocs.findById(c.param("id")) ?? {}) + if space.len == 0 ( + c.notFound("space not found") + return + ) + if !canManageSpace(space, c) ( + c.forbidden("you cannot manage this space") + return + ) + c.json(200, {ok: true, space: spaceManagementResponse(space, c)}) +) + +def handleInviteSpaceCurator(*serve.Context c) ( + object space = (spaceDocs.findById(c.param("id")) ?? {}) + if space.len == 0 ( + c.notFound("space not found") + return + ) + if !isSpaceOwner(space, c) ( + c.forbidden("only the owner can invite curators") + return + ) + string username = requestJSON(c).username.toStr().strip() + string usernameKey = normalizeUsername(username) + if usernameKey == "" or username.len > 20 or usernameKey == normalizeUsername(space.owner.toStr()) ( + c.badRequest("choose another user") + return + ) + object profile = (profileSearchDocs.findById(usernameKey) ?? {}) + if profile.len == 0 ( + c.notFound("user not found") + return + ) + if stringArrayContainsUser(spaceManagers(space), username) ( + c.badRequest("this user is already a curator") + return + ) + array invites = spaceCuratorInvites(space) + for i invites.len ( + object existing = invites[i].assert(object) + if normalizeUsername(existing.username.toStr()) == usernameKey ( + c.badRequest("this user already has an invitation") + return + ) + ) + object invite = {username: profile.username, invitedBy: contextString(c, "username"), created: timestamp} + invites.append(invite) + space.curatorInvites = invites + space.edited = timestamp + putSpaceDocument(space) + addNotification(profile.username.toStr(), contextString(c, "username"), "space_curator_invite", {spaceId: space["_id"], spaceTitle: space.title}) + c.json(201, {ok: true, invitation: invite}) +) + +def handleRespondSpaceCuratorInvite(*serve.Context c) ( + object space = (spaceDocs.findById(c.param("id")) ?? {}) + if space.len == 0 ( + c.notFound("space not found") + return + ) + string username = contextString(c, "username") + array invites = spaceCuratorInvites(space) + array remaining = [] + boolean found = false + for i invites.len ( + object invite = invites[i].assert(object) + if normalizeUsername(invite.username.toStr()) == normalizeUsername(username) ( + found = true + ) else ( + remaining.append(invite) + ) + ) + if !found ( + c.notFound("invitation not found") + return + ) + boolean accepted = requestJSON(c).accepted == true + if accepted ( + array managers = spaceManagers(space) + if !stringArrayContainsUser(managers, username) ( + managers.append(username) + ) + space.managers = managers + ) + space.curatorInvites = remaining + space.edited = timestamp + putSpaceDocument(space) + string notificationType = "space_curator_declined" + if accepted ( + notificationType = "space_curator_accepted" + ) + addNotification(space.owner.toStr(), username, notificationType, {spaceId: space["_id"], spaceTitle: space.title}) + c.json(200, {ok: true, accepted: accepted}) +) + +def handleRemoveSpaceCurator(*serve.Context c) ( + object space = (spaceDocs.findById(c.param("id")) ?? {}) + if space.len == 0 ( + c.notFound("space not found") + return + ) + if !isSpaceOwner(space, c) ( + c.forbidden("only the owner can remove curators") + return + ) + string username = c.param("username") + array managers = spaceManagers(space) + if stringArrayContainsUser(managers, username) ( + space.managers = removeUser(managers, username) + space.edited = timestamp + putSpaceDocument(space) + addNotification(username, contextString(c, "username"), "space_curator_removed", {spaceId: space["_id"], spaceTitle: space.title}) + ) + c.json(200, {ok: true}) +) + +def handleCancelSpaceCuratorInvite(*serve.Context c) ( + object space = (spaceDocs.findById(c.param("id")) ?? {}) + if space.len == 0 ( + c.notFound("space not found") + return + ) + if !isSpaceOwner(space, c) ( + c.forbidden("only the owner can cancel invitations") + return + ) + string username = c.param("username") + array remaining = [] + array invites = spaceCuratorInvites(space) + boolean found = false + for i invites.len ( + object invite = invites[i].assert(object) + if normalizeUsername(invite.username.toStr()) != normalizeUsername(username) ( + remaining.append(invite) + ) else ( + found = true + ) + ) + if found ( + space.curatorInvites = remaining + space.edited = timestamp + putSpaceDocument(space) + ) + c.json(200, {ok: true}) +) + +def handleReactSpace(*serve.Context c) ( + string lockName = "space-reactions:" ++ c.param("id") + sync.lock(lockName) + handleReactSpaceLocked(c) + sync.unlock(lockName) +) + +def handleReactSpaceLocked(*serve.Context c) ( + object space = (spaceDocs.findById(c.param("id")) ?? {}) + if space.len == 0 or !canViewSpace(space, c) ( + c.notFound("space not found") + return + ) + string reactionType = requestJSON(c).type.toStr() + if !isValidReaction(reactionType) ( + c.badRequest("invalid reaction") + return + ) + string username = contextString(c, "username") + array likes = spaceLikes(space) + array brokenHearts = spaceBrokenHearts(space) + number previousLikes = likes.len + boolean hadReaction = false + if reactionType == "heart" ( + hadReaction = stringArrayContainsUser(likes, username) + ) else ( + hadReaction = stringArrayContainsUser(brokenHearts, username) + ) + likes = removeUser(likes, username) + brokenHearts = removeUser(brokenHearts, username) + if !hadReaction and reactionType == "heart" ( + likes.append(username) + ) else if !hadReaction and reactionType == "brokenheart" ( + brokenHearts.append(username) + ) + space.likes = likes + space.brokenHearts = brokenHearts + space.edited = timestamp + if !putSpaceDocument(space) ( + c.internalError("could not save reaction") + return + ) + notifyLikeMilestones(space.owner.toStr(), space.kind.toStr(), space["_id"].toStr(), previousLikes, likes.len, "/spaces/" ++ space["_id"].toStr(), {}) + c.json(200, {ok: true, likeCount: likes.len, brokenHeartCount: brokenHearts.len, myReaction: if hadReaction then "" else reactionType}) +)