diff --git a/apps/juxtaposition-ui/src/database.js b/apps/juxtaposition-ui/src/database.js index 57805cc8..a44d53a1 100644 --- a/apps/juxtaposition-ui/src/database.js +++ b/apps/juxtaposition-ui/src/database.js @@ -113,21 +113,6 @@ async function getUserContent(pid) { return CONTENT.findOne({ pid: pid }); } -async function getFollowingUsers(content) { - verifyConnected(); - return SETTINGS.find({ - pid: content.following_users, - ...notBanned() - }); -} - -async function getFollowedUsers(content) { - verifyConnected(); - return SETTINGS.find({ - pid: content.followed_users - }); -} - async function getNotifications(pid, limit, offset) { verifyConnected(); return NOTIFICATION.find({ @@ -192,8 +177,6 @@ export const database = { getPostByID, getDuplicatePosts, getEndPoint, - getFollowingUsers, - getFollowedUsers, getUsersSettings, getUserSettings, getUserSettingsFuzzySearch, diff --git a/apps/juxtaposition-ui/src/middleware/checkBan.tsx b/apps/juxtaposition-ui/src/middleware/checkBan.tsx index bfca7cbd..606ed953 100644 --- a/apps/juxtaposition-ui/src/middleware/checkBan.tsx +++ b/apps/juxtaposition-ui/src/middleware/checkBan.tsx @@ -1,5 +1,3 @@ -import moment from 'moment'; -import { database as db } from '@/database'; import { config } from '@/config'; import { humanDate, humanFromNow } from '@/util'; import { WebLoginView } from '@/services/juxt-web/views/web/loginView'; @@ -8,10 +6,10 @@ import { PortalFatalErrorView } from '@/services/juxt-web/views/portal/errorView import type { RequestHandler } from 'express'; export const checkBan: RequestHandler = async (request, response, next) => { - // Initialize access levels so the template engine can always access them - response.locals.tester = false; - response.locals.moderator = false; - response.locals.developer = false; + // Set access levels + response.locals.tester = request.self?.permissions.tester ?? null; + response.locals.moderator = request.self?.permissions.moderator ?? null; + response.locals.developer = request.self?.permissions.developer ?? null; if (!request.user) { if (request.guest_access || request.path === '/login') { @@ -21,11 +19,6 @@ export const checkBan: RequestHandler = async (request, response, next) => { } } - // Set access levels - response.locals.tester = request.user.accessLevel >= 1 && request.user.accessLevel <= 3; - response.locals.moderator = request.user.accessLevel == 2 || request.user.accessLevel == 3; - response.locals.developer = request.user.accessLevel == 3; - // Check if user has access to the environment let accessAllowed = false; switch (config.serverEnvironment) { @@ -52,33 +45,29 @@ export const checkBan: RequestHandler = async (request, response, next) => { ctr: }); } - const userSettings = await db.getUserSettings(request.pid); - if (userSettings && moment(userSettings.ban_lift_date) <= moment() && userSettings.account_status !== 3) { - userSettings.account_status = 0; - await userSettings.save(); - } - // This includes ban checks for both Juxt specifically and the account server, ideally this should be squashed - // assuming we support more gradual bans on PNID's - if (userSettings && (userSettings.account_status < 0 || userSettings.account_status > 1 || request.user.accessLevel < 0)) { - let banMessage = ''; - let banCode = 5980020; - switch (userSettings.account_status) { - case 2: - banMessage = `${request.user.username} has been banned. The ban ends ${humanFromNow(userSettings.ban_lift_date)} (at ${humanDate(userSettings.ban_lift_date)}).`; - banCode = 5980010; - break; - case 3: - banMessage = `${request.user.username} has been permanently banned.`; - banCode = 5980011; - break; - default: - banMessage = `${request.user.username} has been banned.`; + + if (request.self?.banState) { + const banState = request.self.banState; + const endDateMessage = banState.endDate ? ` The ban ends ${humanFromNow(banState.endDate)} (at ${humanDate(banState.endDate)}).` : ''; + + let banCode = 5980020; // fallback + let banMessage = `${request.self.username} has been banned.${endDateMessage}`; // fallback + if (banState.code === 'temp_ban') { + banMessage = `${request.self.username} has been banned.${endDateMessage}`; + banCode = 5980010; + } else if (banState.code === 'perma_ban') { + banMessage = `${request.user.username} has been permanently banned.${endDateMessage}`; + banCode = 5980011; + } else if (banState.code === 'network_ban') { + banMessage = `${request.user.username} has been permanently banned.${endDateMessage}`; + banMessage += `\n\nThis ban restricts all parts of Pretendo Network.`; + banCode = 5980020; } - if (request.user.accessLevel < 0) { - banMessage += '\n\nThis ban restricts all parts of Pretendo Network.'; - } else if (userSettings.ban_reason) { - banMessage += `\n\nReason: ${userSettings.ban_reason}.`; + + if (banState.reason) { + banMessage += `\n\nReason: ${banState.reason}.`; } + banMessage += `\n\nIf you have any questions, please contact the moderators on the Pretendo Network Forum (https://preten.do/ban-appeal/).`; return response.jsxForDirectory({ @@ -88,10 +77,5 @@ export const checkBan: RequestHandler = async (request, response, next) => { }); } - if (userSettings) { - userSettings.last_active = new Date(); - await userSettings.save(); - } - next(); }; diff --git a/apps/juxtaposition-ui/src/middleware/consoleAuth.tsx b/apps/juxtaposition-ui/src/middleware/consoleAuth.tsx index 7971b03a..d4c84341 100644 --- a/apps/juxtaposition-ui/src/middleware/consoleAuth.tsx +++ b/apps/juxtaposition-ui/src/middleware/consoleAuth.tsx @@ -1,3 +1,4 @@ +import { createInternalApiClient } from '@/api/client'; import { config } from '@/config'; import { getLanguage } from '@/i18n'; import { logger } from '@/logger'; @@ -60,6 +61,15 @@ export const consoleAuth: RequestHandler = async (request, response, next) => { } } + request.self = null; + if (request.user) { + const selfResult = await createInternalApiClient(request.tokens).self.get({ throwOnError: false }); + if (selfResult.error) { + logger.error(selfResult.error, 'Failed to get self from access token'); + } + request.self = selfResult.error ? null : selfResult.data ?? null; + } + const mayBypassAuthChecks = request.user?.accessLevel === 3 || config.disableConsoleChecks; // This section includes checks if a user is a developer and adds exceptions for these cases diff --git a/apps/juxtaposition-ui/src/middleware/webAuth.ts b/apps/juxtaposition-ui/src/middleware/webAuth.ts index 528b2366..a7e1517c 100644 --- a/apps/juxtaposition-ui/src/middleware/webAuth.ts +++ b/apps/juxtaposition-ui/src/middleware/webAuth.ts @@ -1,3 +1,4 @@ +import { createInternalApiClient } from '@/api/client'; import { config } from '@/config'; import { logger } from '@/logger'; import { getUserAccountData, getUserDataFromToken } from '@/util'; @@ -34,6 +35,15 @@ export const webAuth: RequestHandler = async (request, response, next) => { request.tokens = { oauthToken: request.cookies.access_token }; + request.self = null; + if (request.user) { + const selfResult = await createInternalApiClient(request.tokens).self.get({ throwOnError: false }); + if (selfResult.error) { + logger.error(selfResult.error, 'Failed to get self from access token'); + } + request.self = selfResult.error ? null : selfResult.data ?? null; + } + // Handle guest access pages if (!request.pid) { if (!requestOkForGuest(request)) { diff --git a/apps/juxtaposition-ui/src/services/juxt-web/routes/admin/admin.tsx b/apps/juxtaposition-ui/src/services/juxt-web/routes/admin/admin.tsx index 27ede439..5380e88f 100644 --- a/apps/juxtaposition-ui/src/services/juxt-web/routes/admin/admin.tsx +++ b/apps/juxtaposition-ui/src/services/juxt-web/routes/admin/admin.tsx @@ -37,10 +37,7 @@ adminRouter.get('/posts', async function (req, res) { // `any` is needed because database.js is not typed yet const rawReports: HydratedReportDocument[] = await database.getAllOpenReports() as any; - const userContent = await database.getUserContent(auth().pid); - if (!userContent) { - throw new Error('User content is null'); - } + const userContent = auth().self.content; const postIds = rawReports.map(obj => obj.post_id); const nonRemovedPosts = await POST.find( diff --git a/apps/juxtaposition-ui/src/services/juxt-web/routes/console/communities.tsx b/apps/juxtaposition-ui/src/services/juxt-web/routes/console/communities.tsx index a90a230a..0d907c5d 100644 --- a/apps/juxtaposition-ui/src/services/juxt-web/routes/console/communities.tsx +++ b/apps/juxtaposition-ui/src/services/juxt-web/routes/console/communities.tsx @@ -101,9 +101,7 @@ communitiesRouter.get('/:communityID/related', async function (req, res) { }) }); - const userSettings = await database.getUserSettings(auth().pid); - const userContent = await database.getUserContent(auth().pid); - if (!userContent || !userSettings) { + if (!auth().self.hasDoneOnboarding) { return res.redirect('/404'); } const { data: community } = await req.api.communities.get({ id: params.communityID }); @@ -172,9 +170,8 @@ communitiesRouter.get('/:communityID/:type', async function (req, res) { }) }); - const userSettings = hasAuth() ? await database.getUserSettings(auth().pid) : null; - const userContent = hasAuth() ? await database.getUserContent(auth().pid) : null; - if (hasAuth() && (!userContent || !userSettings)) { + const self = hasAuth() ? auth().self : null; + if (self && !self.hasDoneOnboarding) { return res.redirect('/404'); } const { data: community } = await req.api.communities.get({ id: params.communityID }); @@ -189,8 +186,8 @@ communitiesRouter.get('/:communityID/:type', async function (req, res) { throw new Error('Community stats could not be found'); } - const canPost = userSettings !== null && isPostingAllowed(community, userSettings, null, auth().user); - const isUserFollowing = userContent !== null && userContent.followed_communities.includes(community.olive_community_id); + const canPost = !!self && isPostingAllowed(community, self, null); + const isUserFollowing = !!self && self.content.followed_communities.includes(community.olive_community_id); const { data: subCommunitiesList } = await req.api.communities.list({ category: 'sub', limit: 90, parent_id: community.olive_community_id }); const subCommunities = subCommunitiesList.items; @@ -210,7 +207,7 @@ communitiesRouter.get('/:communityID/:type', async function (req, res) { const postListProps: PostListViewProps = { nextLink: `/titles/${params.communityID}/${params.type}/more?offset=${posts.length}&pjax=true`, posts, - userContent + userContent: self?.content ?? null }; if (query.pjax) { @@ -249,7 +246,7 @@ communitiesRouter.get('/:communityID/:type', async function (req, res) { }); communitiesRouter.get('/:communityID/:type/more', async function (req, res) { - const { query, params, auth } = parseReq(req, { + const { query, params, auth, hasAuth } = parseReq(req, { params: z.object({ communityID: z.string(), type: z.string() @@ -260,7 +257,7 @@ communitiesRouter.get('/:communityID/:type/more', async function (req, res) { }); const offset = query.offset; - const userContent = await database.getUserContent(auth().pid); + const userContent = hasAuth() ? auth().self.content : null; const { data: community } = await req.api.communities.get({ id: params.communityID }); if (!community || !userContent) { return res.redirect('/404'); diff --git a/apps/juxtaposition-ui/src/services/juxt-web/routes/console/feed.tsx b/apps/juxtaposition-ui/src/services/juxt-web/routes/console/feed.tsx index 2ba5c86b..0d9ea68c 100644 --- a/apps/juxtaposition-ui/src/services/juxt-web/routes/console/feed.tsx +++ b/apps/juxtaposition-ui/src/services/juxt-web/routes/console/feed.tsx @@ -1,6 +1,5 @@ import express from 'express'; import { z } from 'zod'; -import { database } from '@/database'; import { config } from '@/config'; import { parseReq } from '@/services/juxt-web/routes/routeUtils'; import { WebGlobalFeedView, WebPeopleFeedView, WebPersonalFeedView } from '@/services/juxt-web/views/web/feed'; @@ -13,12 +12,12 @@ import { PortalPostListView } from '@/services/juxt-web/views/portal/postList'; export const feedRouter = express.Router(); feedRouter.get('/', async function (req, res) { - const { auth, query } = parseReq(req, { + const { auth, hasAuth, query } = parseReq(req, { query: z.object({ pjax: z.stringbool().optional() }) }); - const userContent = await database.getUserContent(auth().pid); + const userContent = hasAuth() ? auth().self.content : null; if (!userContent) { return res.redirect('/404'); } @@ -43,12 +42,12 @@ feedRouter.get('/', async function (req, res) { }); feedRouter.get('/people', async function (req, res) { - const { auth, query } = parseReq(req, { + const { auth, hasAuth, query } = parseReq(req, { query: z.object({ pjax: z.stringbool().optional() }) }); - const userContent = await database.getUserContent(auth().pid); + const userContent = hasAuth() ? auth().self.content : null; if (!userContent) { return res.redirect('/404'); } @@ -73,12 +72,12 @@ feedRouter.get('/people', async function (req, res) { }); feedRouter.get('/all', async function (req, res) { - const { auth, query } = parseReq(req, { + const { auth, hasAuth, query } = parseReq(req, { query: z.object({ pjax: z.stringbool().optional() }) }); - const userContent = await database.getUserContent(auth().pid); + const userContent = hasAuth() ? auth().self.content : null; if (!userContent) { return res.redirect('/404'); } @@ -103,13 +102,13 @@ feedRouter.get('/all', async function (req, res) { }); feedRouter.get('/more', async function (req, res) { - const { auth, query } = parseReq(req, { + const { auth, hasAuth, query } = parseReq(req, { query: z.object({ pjax: z.stringbool().optional(), offset: z.coerce.number().nonnegative().default(0) }) }); - const userContent = await database.getUserContent(auth().pid); + const userContent = hasAuth() ? auth().self.content : null; if (!userContent) { return res.redirect('/404'); } @@ -130,13 +129,13 @@ feedRouter.get('/more', async function (req, res) { }); feedRouter.get('/people/more', async function (req, res) { - const { auth, query } = parseReq(req, { + const { auth, hasAuth, query } = parseReq(req, { query: z.object({ pjax: z.stringbool().optional(), offset: z.coerce.number().nonnegative().default(0) }) }); - const userContent = await database.getUserContent(auth().pid); + const userContent = hasAuth() ? auth().self.content : null; if (!userContent) { return res.redirect('/404'); } @@ -157,13 +156,13 @@ feedRouter.get('/people/more', async function (req, res) { }); feedRouter.get('/all/more', async function (req, res) { - const { auth, query } = parseReq(req, { + const { auth, hasAuth, query } = parseReq(req, { query: z.object({ pjax: z.stringbool().optional(), offset: z.coerce.number().nonnegative().default(0) }) }); - const userContent = await database.getUserContent(auth().pid); + const userContent = hasAuth() ? auth().self.content : null; if (!userContent) { return res.redirect('/404'); } diff --git a/apps/juxtaposition-ui/src/services/juxt-web/routes/console/posts.tsx b/apps/juxtaposition-ui/src/services/juxt-web/routes/console/posts.tsx index 46bfb9f8..0f453620 100644 --- a/apps/juxtaposition-ui/src/services/juxt-web/routes/console/posts.tsx +++ b/apps/juxtaposition-ui/src/services/juxt-web/routes/console/posts.tsx @@ -22,11 +22,8 @@ import { CtrReportPostPage } from '@/services/juxt-web/views/ctr/reportPostView' import { getShotMode, isPostingAllowed } from '@/services/juxt-web/routes/permissions'; import { AutomodRule } from '@/models/automodRules'; import type { Request, Response } from 'express'; -import type { InferSchemaType } from 'mongoose'; import type { PaintingUrls } from '@/images'; import type { PostPageViewProps } from '@/services/juxt-web/views/web/postPageView'; -import type { HydratedSettingsDocument } from '@/models/settings'; -import type { ContentSchema } from '@/models/content'; import type { EmpathyActionEnum } from '@/api/generated'; import type { NewPostViewProps } from '@/services/juxt-web/views/web/newPostView'; const storage = multer.memoryStorage(); @@ -146,13 +143,7 @@ postsRouter.get('/:post_id', async function (req, res) { post_id: z.string() }) }); - - let userSettings: HydratedSettingsDocument | null = null; - let userContent: InferSchemaType | null = null; - if (hasAuth()) { - userSettings = await database.getUserSettings(auth().pid); - userContent = await database.getUserContent(auth().pid); - } + const self = hasAuth() ? auth().self : null; const { data: post } = await req.api.posts.get({ post_id: params.post_id }); if (!post) { @@ -173,7 +164,7 @@ postsRouter.get('/:post_id', async function (req, res) { // increase limit for post replies since there's no pagination yet const replies = (await req.api.posts.list({ parent_id: post.id, include_replies: 'true', sort: 'oldest', limit: 500 }))?.data.items ?? []; const postPNID = await getUserAccountData(post.pid); - const canPost = hasAuth() && userSettings !== null && isPostingAllowed(community, userSettings, post, auth().user); + const canPost = !!self && isPostingAllowed(community, self, post); const props: PostPageViewProps = { community, @@ -181,7 +172,7 @@ postsRouter.get('/:post_id', async function (req, res) { postPNID, replies, canPost, - userContent + userContent: self?.content ?? null }; res.jsxForDirectory({ web: , @@ -339,7 +330,7 @@ postsRouter.post('/:post_id/report', upload.none(), async function (req, res) { }); async function newPost(req: Request, res: Response): Promise { - const { params, body, files, auth } = parseReq(req, { + const { params, body, files, auth, hasAuth } = parseReq(req, { params: z.object({ post_id: z.string().optional() }), @@ -357,9 +348,9 @@ async function newPost(req: Request, res: Response): Promise { }), files: ['shot'] }); + const self = hasAuth() ? auth().self : null; const rejectReturnUrl = params.post_id ? `/posts/${params.post_id}/create` : `/titles/${body.community_id}/create`; - const userSettings = await database.getUserSettings(auth().pid); let parentPost = null; const postId = await generatePostUID(21); let { data: community } = await req.api.communities.get({ id: body.community_id }); @@ -381,13 +372,13 @@ async function newPost(req: Request, res: Response): Promise { res.status(422); return res.redirect('/posts/' + req.params.post_id.toString()); } - if (!community || !userSettings || !req.user) { + if (!community || !self) { res.status(403); logger.error('Incoming post is missing data - rejecting'); return res.redirect('/titles/show'); } - if (!isPostingAllowed(community, userSettings, parentPost, req.user)) { + if (!isPostingAllowed(community, self, parentPost)) { res.status(403); return res.redirect(`/titles/${community.olive_community_id}/new`); } @@ -467,7 +458,7 @@ async function newPost(req: Request, res: Response): Promise { const document = { title_id: community.titleIds[0], community_id: community.olive_community_id, - screen_name: userSettings.screen_name, + screen_name: self.miiName, body: postBody, painting: paintings?.blob ?? '', painting_img: paintings?.img ?? '', diff --git a/apps/juxtaposition-ui/src/services/juxt-web/routes/console/show.tsx b/apps/juxtaposition-ui/src/services/juxt-web/routes/console/show.tsx index 02d85a54..cff6b838 100644 --- a/apps/juxtaposition-ui/src/services/juxt-web/routes/console/show.tsx +++ b/apps/juxtaposition-ui/src/services/juxt-web/routes/console/show.tsx @@ -17,9 +17,8 @@ showRouter.get('/', async function (req, res) { }) }); - const user = await database.getUserSettings(req.pid); - const content = await database.getUserContent(req.pid); - if (!user || !content) { + const self = hasAuth() ? auth().self : null; + if (!self?.hasDoneOnboarding) { return res.jsxForDirectory({ web: , portal: , @@ -35,16 +34,8 @@ showRouter.get('/', async function (req, res) { res.redirect('/titles'); } - if (hasAuth()) { - const currentMii = auth().user.mii; - if (!currentMii || !user) { - return; - } - if (currentMii.name !== user.screen_name) { - setName(auth().pid, currentMii.name); - user.screen_name = currentMii.name; - await user.save(); - } + if (self) { + setName(self.pid, self.miiName); } }); @@ -57,20 +48,21 @@ showRouter.get('/first', async function (req, res) { }); showRouter.post('/newUser', async function (req, res) { - const { auth, body } = parseReq(req, { + const { auth, hasAuth, body } = parseReq(req, { body: z.object({ experience: z.number(), notifications: z.boolean() }) }); - if (req.pid === null || !req.new_users || req.directory === 'web') { + const self = hasAuth() ? auth().self : null; + + if (!self || !req.new_users || req.directory === 'web') { return res.sendStatus(401); } - const user = await database.getUserSettings(auth().pid); - if (user) { - return res.sendStatus(504); // User already exists + if (self.hasDoneOnboarding) { + return res.sendStatus(504); // Onboarding already finished } await createUser(auth().pid, body.experience, body.notifications); diff --git a/apps/juxtaposition-ui/src/services/juxt-web/routes/console/topics.tsx b/apps/juxtaposition-ui/src/services/juxt-web/routes/console/topics.tsx index a1ceb79f..339711b0 100644 --- a/apps/juxtaposition-ui/src/services/juxt-web/routes/console/topics.tsx +++ b/apps/juxtaposition-ui/src/services/juxt-web/routes/console/topics.tsx @@ -1,7 +1,6 @@ import express from 'express'; import { z } from 'zod'; import { config } from '@/config'; -import { database } from '@/database'; import { POST } from '@/models/post'; import { parseReq } from '@/services/juxt-web/routes/routeUtils'; import { WebPostListView } from '@/services/juxt-web/views/web/postList'; @@ -14,7 +13,7 @@ import { CtrTopicTagView } from '@/services/juxt-web/views/ctr/topics'; export const topicsRouter = express.Router(); topicsRouter.get('/', async function (req, res) { - const { auth, query } = parseReq(req, { + const { auth, hasAuth, query } = parseReq(req, { query: z.object({ pjax: z.stringbool().optional(), limit: z.coerce.number().min(1).max(config.postLimit).optional().default(config.postLimit), @@ -22,10 +21,7 @@ topicsRouter.get('/', async function (req, res) { }) }); - const userContent = await database.getUserContent(auth().pid); - if (!userContent) { - return res.redirect('/404'); - } + const userContent = hasAuth() ? auth().self.content : null; const posts = await POST.find({ topic_tag: query.topic_tag }).sort({ created_at: -1 }).limit(query.limit); const nextLink = `/topics/more?topic_tag=${query.topic_tag}&offset=${posts.length}&pjax=true`; @@ -47,7 +43,7 @@ topicsRouter.get('/', async function (req, res) { }); topicsRouter.get('/more', async function (req, res) { - const { auth, query } = parseReq(req, { + const { auth, hasAuth, query } = parseReq(req, { query: z.object({ pjax: z.stringbool().optional(), limit: z.coerce.number().min(1).max(config.postLimit).optional().default(config.postLimit), @@ -56,10 +52,7 @@ topicsRouter.get('/more', async function (req, res) { }) }); - const userContent = await database.getUserContent(auth().pid); - if (!userContent) { - return res.redirect('/404'); - } + const userContent = hasAuth() ? auth().self.content : null; const posts = await POST.find({ topic_tag: query.topic_tag }).sort({ created_at: -1 }).skip(query.offset).limit(query.limit); const nextLink = `/topics/more?topic_tag=${query.topic_tag}&offset=${query.offset + posts.length}&pjax=true`; diff --git a/apps/juxtaposition-ui/src/services/juxt-web/routes/console/userpage.tsx b/apps/juxtaposition-ui/src/services/juxt-web/routes/console/userpage.tsx index a5d510ce..8b35aeb6 100644 --- a/apps/juxtaposition-ui/src/services/juxt-web/routes/console/userpage.tsx +++ b/apps/juxtaposition-ui/src/services/juxt-web/routes/console/userpage.tsx @@ -5,7 +5,7 @@ import { database } from '@/database'; import { logger } from '@/logger'; import { POST } from '@/models/post'; import { SETTINGS } from '@/models/settings'; -import { getCommunityHash, getUserAccountData, getUserFriendPIDs, newNotification } from '@/util'; +import { getCommunityHash, getUserAccountData, getUserFriendPIDs } from '@/util'; import { parseReq } from '@/services/juxt-web/routes/routeUtils'; import { WebUserPageView } from '@/services/juxt-web/views/web/userPageView'; import { WebPostListView } from '@/services/juxt-web/views/web/postList'; @@ -23,8 +23,8 @@ import type { Request, Response } from 'express'; import type { UserPageFollowingViewProps } from '@/services/juxt-web/views/web/userPageFollowingView'; import type { PostListViewProps } from '@/services/juxt-web/views/web/postList'; import type { UserPageViewProps } from '@/services/juxt-web/views/web/userPageView'; -import type { HydratedSettingsDocument } from '@/models/settings'; import type { UserSettingsViewProps } from '@/services/juxt-web/views/web/userSettingsView'; +import type { ShallowUser } from '@/api/generated'; export const userPageRouter = express.Router(); const upload = multer({ dest: 'uploads/' }); @@ -180,30 +180,23 @@ userPageRouter.post('/follow', upload.none(), async function (req, res) { }); const userToFollow = await database.getUserContent(body.id); - const userContent = await database.getUserContent(auth().pid); - if (!userToFollow || !userContent || !userToFollow.pid) { + const userContent = auth().self.content; + if (!userToFollow || !userToFollow.pid) { // Invalid state, can't do a follow return res.send({ status: 423, id: body.id, count: userToFollow?.following_users.length ?? 0 }); } const isFollowing = userContent.followed_users.includes(userToFollow.pid); - let newFollowerCount = userToFollow.following_users.length; - if (!isFollowing) { - // Follow - await (userToFollow as any).addToFollowers(userContent.pid); - await (userContent as any).addToUsers(userToFollow.pid); - newFollowerCount++; - const existingNotification = await database.getNotification(userToFollow.pid, 2, userContent.pid); - if (!existingNotification) { - await newNotification({ pid: userToFollow.pid, type: 'follow', objectID: auth().pid.toString(), link: `/users/${auth().pid}` }); - } + const shouldFollow = !isFollowing; + if (shouldFollow) { + await req.api.users.followerUser({ id: userToFollow.pid }); } else { - // Unfollow - await (userToFollow as any).removeFromFollowers(userContent.pid); - await (userContent as any).removeFromUsers(userToFollow.pid); - newFollowerCount--; + await req.api.users.unfollowUser({ id: userToFollow.pid }); } + const changeNum = shouldFollow ? 1 : -1; + const newFollowerCount = userToFollow.following_users.length + changeNum; + // idk why, but it always subtracts one from the count before returning res.send({ status: 200, id: userToFollow.pid, count: newFollowerCount - 1 }); }); @@ -253,17 +246,17 @@ async function userPage(req: Request, res: Response, userID: number): Promise ({ + pid: v.pid, + accountStatus: v.account_status, + miiName: v.screen_name + })); selection = 1; } else if (params.type === 'followers') { - followers = await database.getFollowingUsers(userContent); + const { data: page } = await req.api.users.listFollowers({ id: userID, limit: 100 }); + followers = page.items; selection = 3; } else { - followers = await database.getFollowedUsers(userContent); + const { data: page } = await req.api.users.listFollowing({ id: userID, limit: 100 }); + followers = page.items; communities = userContent?.followed_communities ?? []; selection = 2; } const communityMap = getCommunityHash(); const listProps: UserPageFollowingViewProps = { - followers: followers.filter(v => v.pid !== 0), + followers, communities: communities .filter(v => v !== '0') // UserContent had a wrong default of [0], which means it needs to be filtered out before usage .map(com => ({ id: com, name: communityMap.get(com) ?? com })) @@ -431,15 +431,15 @@ async function userRelations(req: Request, res: Response, userID: number): Promi } async function morePosts(req: Request, res: Response, userID: number): Promise { - const { query } = parseReq(req, { + const { query, auth, hasAuth } = parseReq(req, { query: z.object({ offset: z.coerce.number().nonnegative().default(0) }) }); const { offset } = query; - const userContent = await database.getUserContent(req.pid); - const posts = (await req.api.posts.list({ posted_by: userID, offset }))?.data.items ?? []; + const userContent = hasAuth() ? auth().self.content : null; + const posts = (await req.api.users.posts.list({ id: userID, offset }))?.data.items ?? []; if (posts.length === 0 || !userContent) { return res.sendStatus(204); diff --git a/apps/juxtaposition-ui/src/services/juxt-web/routes/permissions.ts b/apps/juxtaposition-ui/src/services/juxt-web/routes/permissions.ts index 4a2f17ed..7eb74412 100644 --- a/apps/juxtaposition-ui/src/services/juxt-web/routes/permissions.ts +++ b/apps/juxtaposition-ui/src/services/juxt-web/routes/permissions.ts @@ -1,20 +1,18 @@ import type { InferSchemaType } from 'mongoose'; -import type { GetUserDataResponse } from '@pretendonetwork/grpc/account/get_user_data_rpc'; import type { PostSchema } from '@/models/post'; -import type { HydratedSettingsDocument } from '@/models/settings'; import type { ParamPack } from '@/types/common/param-pack'; -import type { Community, CommunityShotMode, Post } from '@/api/generated'; +import type { Community, CommunityShotMode, Post, Self } from '@/api/generated'; -export function isPostingAllowed(community: Community, userSettings: HydratedSettingsDocument, parentPost: InferSchemaType | Post | null, user: GetUserDataResponse): boolean { +export function isPostingAllowed(community: Community, user: Self, parentPost: InferSchemaType | Post | null): boolean { const isReply = !!parentPost; const isPublicPostableCommunity = community.type >= 0 && community.type < 2; const isOpenCommunity = community.permissions.open; const isCommunityAdmin = community.adminPids.includes(user.pid); - const isUserLimitedFromPosting = userSettings.account_status !== 0; + const isUserLimitedFromPosting = !user.permissions.posting; const hasAccessLevelRequirement = isReply - ? user.accessLevel >= community.permissions.minimum_new_comment_access_level - : user.accessLevel >= community.permissions.minimum_new_post_access_level; + ? user.permissions.accessLevel >= community.permissions.minimum_new_comment_access_level + : user.permissions.accessLevel >= community.permissions.minimum_new_post_access_level; if (isUserLimitedFromPosting) { return false; diff --git a/apps/juxtaposition-ui/src/services/juxt-web/routes/routeUtils.ts b/apps/juxtaposition-ui/src/services/juxt-web/routes/routeUtils.ts index a23cbeb0..4ec74b84 100644 --- a/apps/juxtaposition-ui/src/services/juxt-web/routes/routeUtils.ts +++ b/apps/juxtaposition-ui/src/services/juxt-web/routes/routeUtils.ts @@ -3,10 +3,12 @@ import type { Request } from 'express'; import type { z } from 'zod'; import type { UserTokens } from '@/types/juxt/tokens'; import type { ParamPack } from '@/types/common/param-pack'; +import type { Self } from '@/api/generated'; type AnySchema = z.ZodObject | z.ZodPipe | undefined | null; export type AuthRequest = TReq & { + self: Self; user: AccountGetUserDataResponse; pid: number; tokens: UserTokens; @@ -14,6 +16,7 @@ export type AuthRequest = TReq & { }; export type AuthContext = { + self: Self; user: AccountGetUserDataResponse; pid: number; tokens: UserTokens; @@ -37,7 +40,7 @@ export type ParsedRequest(req: TReq): AuthRequest { - if (!(req as any).user) { + if (!(req as any).user || !(req as any).self) { throw new Error('Trying to get authed request while not being authed'); } return req as AuthRequest; @@ -83,6 +86,7 @@ export function parseReq
- {user.screen_name} + {user.miiName}
diff --git a/apps/juxtaposition-ui/src/services/juxt-web/views/portal/userPageFollowingView.tsx b/apps/juxtaposition-ui/src/services/juxt-web/views/portal/userPageFollowingView.tsx index 9cc98d4c..6e612e53 100644 --- a/apps/juxtaposition-ui/src/services/juxt-web/views/portal/userPageFollowingView.tsx +++ b/apps/juxtaposition-ui/src/services/juxt-web/views/portal/userPageFollowingView.tsx @@ -13,7 +13,7 @@ export function PortalUserPageFollowingView(props: UserPageFollowingViewProps):
- {user.screen_name} + {user.miiName}
diff --git a/apps/juxtaposition-ui/src/services/juxt-web/views/web/admin/reportListView.tsx b/apps/juxtaposition-ui/src/services/juxt-web/views/web/admin/reportListView.tsx index 86c76c8c..012d3fa4 100644 --- a/apps/juxtaposition-ui/src/services/juxt-web/views/web/admin/reportListView.tsx +++ b/apps/juxtaposition-ui/src/services/juxt-web/views/web/admin/reportListView.tsx @@ -7,9 +7,9 @@ import { humanDate, humanFromNow } from '@/util'; import { WebMiiIcon } from '@/services/juxt-web/views/web/components/ui/WebMiiIcon'; import type { ReactNode } from 'react'; import type { InferSchemaType } from 'mongoose'; -import type { ContentSchema } from '@/models/content'; import type { HydratedReportDocument } from '@/models/report'; import type { PostSchema } from '@/models/post'; +import type { SelfContent } from '@/api/generated'; export type ReportWithPost = { report: HydratedReportDocument; @@ -18,13 +18,13 @@ export type ReportWithPost = { export type ReportListViewProps = { reasonMap: string[]; - userContent: InferSchemaType; + userContent: SelfContent; reports: ReportWithPost[]; }; export type ReportProps = { reasonMap: string[]; - userContent: InferSchemaType; + userContent: SelfContent; report: HydratedReportDocument; post: InferSchemaType; }; diff --git a/apps/juxtaposition-ui/src/services/juxt-web/views/web/feed.tsx b/apps/juxtaposition-ui/src/services/juxt-web/views/web/feed.tsx index b54b6be1..08e9d2c6 100644 --- a/apps/juxtaposition-ui/src/services/juxt-web/views/web/feed.tsx +++ b/apps/juxtaposition-ui/src/services/juxt-web/views/web/feed.tsx @@ -5,12 +5,10 @@ import { WebReportModalView } from '@/services/juxt-web/views/web/reportModalVie import { WebPostListView } from '@/services/juxt-web/views/web/postList'; import { T } from '@/services/juxt-web/views/common/components/T'; import type { ReactNode } from 'react'; -import type { InferSchemaType } from 'mongoose'; -import type { ContentSchema } from '@/models/content'; -import type { Post } from '@/api/generated'; +import type { Post, SelfContent } from '@/api/generated'; export type FeedViewProps = { - userContent: InferSchemaType; + userContent: SelfContent | null; posts: Post[]; nextLink: string; }; diff --git a/apps/juxtaposition-ui/src/services/juxt-web/views/web/post.tsx b/apps/juxtaposition-ui/src/services/juxt-web/views/web/post.tsx index 77efd150..87dea09b 100644 --- a/apps/juxtaposition-ui/src/services/juxt-web/views/web/post.tsx +++ b/apps/juxtaposition-ui/src/services/juxt-web/views/web/post.tsx @@ -7,9 +7,8 @@ import { WebUIIcon } from '@/services/juxt-web/views/web/components/ui/WebUIIcon import { T } from '@/services/juxt-web/views/common/components/T'; import type { InferSchemaType } from 'mongoose'; import type { ReactNode } from 'react'; -import type { ContentSchema } from '@/models/content'; import type { PostSchema } from '@/models/post'; -import type { Post } from '@/api/generated'; +import type { Post, SelfContent } from '@/api/generated'; export type PostScreenshotProps = { post: InferSchemaType | Post; @@ -26,7 +25,7 @@ export function WebPostScreenshot(props: PostScreenshotProps): ReactNode { } export type PostViewProps = { - userContent?: InferSchemaType | null; + userContent?: SelfContent | null; post: InferSchemaType | Post; isReply?: boolean; isMainPost?: boolean; diff --git a/apps/juxtaposition-ui/src/services/juxt-web/views/web/postList.tsx b/apps/juxtaposition-ui/src/services/juxt-web/views/web/postList.tsx index 30189584..41413fbd 100644 --- a/apps/juxtaposition-ui/src/services/juxt-web/views/web/postList.tsx +++ b/apps/juxtaposition-ui/src/services/juxt-web/views/web/postList.tsx @@ -2,12 +2,11 @@ import { WebPostView } from '@/services/juxt-web/views/web/post'; import { T } from '@/services/juxt-web/views/common/components/T'; import type { InferSchemaType } from 'mongoose'; import type { ReactNode } from 'react'; -import type { ContentSchema } from '@/models/content'; import type { PostSchema } from '@/models/post'; -import type { Post } from '@/api/generated'; +import type { Post, SelfContent } from '@/api/generated'; export type PostListViewProps = { - userContent: InferSchemaType | null; + userContent: SelfContent | null; posts: InferSchemaType[] | Post[]; nextLink: string; }; diff --git a/apps/juxtaposition-ui/src/services/juxt-web/views/web/postPageView.tsx b/apps/juxtaposition-ui/src/services/juxt-web/views/web/postPageView.tsx index b0147c99..19ecbee5 100644 --- a/apps/juxtaposition-ui/src/services/juxt-web/views/web/postPageView.tsx +++ b/apps/juxtaposition-ui/src/services/juxt-web/views/web/postPageView.tsx @@ -5,14 +5,12 @@ import { WebPostView } from '@/services/juxt-web/views/web/post'; import { useUrl } from '@/services/juxt-web/views/common/hooks/useUrl'; import { T } from '@/services/juxt-web/views/common/components/T'; import type { ReactNode } from 'react'; -import type { InferSchemaType } from 'mongoose'; import type { GetUserDataResponse } from '@pretendonetwork/grpc/account/get_user_data_rpc'; -import type { ContentSchema } from '@/models/content'; -import type { Community, Post } from '@/api/generated'; +import type { Community, Post, SelfContent } from '@/api/generated'; export type PostPageViewProps = { post: Post; - userContent: InferSchemaType | null; + userContent: SelfContent | null; postPNID: GetUserDataResponse; community: Community; replies: Post[]; diff --git a/apps/juxtaposition-ui/src/services/juxt-web/views/web/topics.tsx b/apps/juxtaposition-ui/src/services/juxt-web/views/web/topics.tsx index 678cdd8f..f5e8d40b 100644 --- a/apps/juxtaposition-ui/src/services/juxt-web/views/web/topics.tsx +++ b/apps/juxtaposition-ui/src/services/juxt-web/views/web/topics.tsx @@ -5,12 +5,12 @@ import { WebPostListView } from '@/services/juxt-web/views/web/postList'; import { T } from '@/services/juxt-web/views/common/components/T'; import type { ReactNode } from 'react'; import type { InferSchemaType } from 'mongoose'; -import type { ContentSchema } from '@/models/content'; import type { PostSchema } from '@/models/post'; +import type { SelfContent } from '@/api/generated'; export type TopicTagViewProps = { title: string; - userContent: InferSchemaType; + userContent: SelfContent | null; posts: InferSchemaType[]; nextLink: string; }; diff --git a/apps/juxtaposition-ui/src/services/juxt-web/views/web/userPageFollowingView.tsx b/apps/juxtaposition-ui/src/services/juxt-web/views/web/userPageFollowingView.tsx index 7f0d1453..754ce52e 100644 --- a/apps/juxtaposition-ui/src/services/juxt-web/views/web/userPageFollowingView.tsx +++ b/apps/juxtaposition-ui/src/services/juxt-web/views/web/userPageFollowingView.tsx @@ -1,6 +1,6 @@ import { useUrl } from '@/services/juxt-web/views/common/hooks/useUrl'; import type { ReactNode } from 'react'; -import type { HydratedSettingsDocument } from '@/models/settings'; +import type { ShallowUser } from '@/api/generated'; export type CommunityViewData = { id: string; @@ -8,7 +8,7 @@ export type CommunityViewData = { }; export type UserPageFollowingViewProps = { - followers: HydratedSettingsDocument[]; + followers: ShallowUser[]; communities: CommunityViewData[]; }; @@ -23,7 +23,7 @@ export function WebUserPageFollowingView(props: UserPageFollowingViewProps): Rea - {user.screen_name} + {user.miiName} diff --git a/apps/juxtaposition-ui/src/services/juxt-web/views/web/userPageView.tsx b/apps/juxtaposition-ui/src/services/juxt-web/views/web/userPageView.tsx index 7bfd756d..b546d18e 100644 --- a/apps/juxtaposition-ui/src/services/juxt-web/views/web/userPageView.tsx +++ b/apps/juxtaposition-ui/src/services/juxt-web/views/web/userPageView.tsx @@ -12,6 +12,7 @@ import type { InferSchemaType } from 'mongoose'; import type { GetUserDataResponse } from '@pretendonetwork/grpc/account/get_user_data_rpc'; import type { ContentSchema } from '@/models/content'; import type { HydratedSettingsDocument } from '@/models/settings'; +import type { SelfContent } from '@/api/generated'; export type UserPageViewProps = { baseLink: string; @@ -23,7 +24,7 @@ export type UserPageViewProps = { userContent: InferSchemaType; userSettings: HydratedSettingsDocument; isOnline: boolean; - requestUserContent: InferSchemaType | null; + requestUserContent: SelfContent | null; }; export function WebUserTier(props: { user: GetUserDataResponse }): ReactNode { diff --git a/apps/juxtaposition-ui/src/types/express.d.ts b/apps/juxtaposition-ui/src/types/express.d.ts index 92e8bee0..e5ad28bb 100644 --- a/apps/juxtaposition-ui/src/types/express.d.ts +++ b/apps/juxtaposition-ui/src/types/express.d.ts @@ -4,7 +4,7 @@ import type { GetUserDataResponse } from '@pretendonetwork/grpc/account/get_user import type { i18n } from 'i18next'; import type { ParamPack } from '@/types/common/param-pack'; import type { UserTokens } from '@/types/juxt/tokens'; -import type { InternalApi } from '@/api/generated'; +import type { InternalApi, Self } from '@/api/generated'; declare global { namespace Express { @@ -13,6 +13,7 @@ declare global { interface Request { directory?: 'ctr' | 'portal' | 'web'; api: InternalApi; + self: Self | null; paramPackData: ParamPack | null; tokens: UserTokens; diff --git a/apps/miiverse-api/src/services/internal/contract/self.ts b/apps/miiverse-api/src/services/internal/contract/self.ts new file mode 100644 index 00000000..ffd67178 --- /dev/null +++ b/apps/miiverse-api/src/services/internal/contract/self.ts @@ -0,0 +1,103 @@ +import { z } from 'zod'; +import { asOpenapi } from '@/services/internal/builder/openapi'; +import type { AccountData } from '@/types/common/account-data'; + +export const banCodes = z.enum(['network_ban', 'temp_ban', 'perma_ban']).openapi('BanCodeEnum'); + +export type BanCodesEnum = z.infer; + +export const selfPermissionsSchema = z.object({ + posting: z.boolean(), + moderator: z.boolean(), + tester: z.boolean(), + developer: z.boolean(), + accessLevel: z.number() +}).openapi('SelfPermissions'); + +export const selfBanStateSchema = asOpenapi('SelfBanState', z.object({ + code: banCodes, + endDate: z.date().nullable(), + reason: z.string().nullable() +})); + +export const selfContentSchema = z.object({ + followed_communities: z.array(z.string()), + followed_users: z.array(z.number()) +}).openapi('SelfContent'); + +export const selfSchema = z.object({ + pid: z.number(), + username: z.string(), + hasDoneOnboarding: z.boolean(), + miiName: z.string(), + accountStatus: z.number(), + content: selfContentSchema, + permissions: selfPermissionsSchema, + banState: selfBanStateSchema.nullable() +}).openapi('Self'); + +export type SelfDto = z.infer; + +const baseSelf: SelfDto = { + pid: 0, + username: '', + accountStatus: 0, + hasDoneOnboarding: false, + miiName: '', + content: { + followed_communities: [], + followed_users: [] + }, + permissions: { + moderator: false, + developer: false, + tester: false, + posting: false, + accessLevel: 0 + }, + banState: null +}; + +export function mapBannedSelf(auth: AccountData, banCode: BanCodesEnum, banLiftDate: Date | null, banReason: string | null): SelfDto { + return { + ...baseSelf, + pid: auth.pnid.pid, + username: auth.pnid.username, + banState: { + endDate: banLiftDate, + reason: banReason, + code: banCode + } + }; +} + +export function mapSelf(auth: AccountData): SelfDto { + if (!auth.settings || !auth.content) { + return { + ...baseSelf, + pid: auth.pnid.pid + }; + } + + return { + pid: auth.pnid.pid, + username: auth.pnid.username, + accountStatus: auth.settings.account_status, + miiName: auth.settings.screen_name, + hasDoneOnboarding: true, + content: { + followed_communities: auth.content.followed_communities.filter(v => v !== '0'), + followed_users: auth.content.followed_users.filter(v => v !== 0) + }, + permissions: { + moderator: auth.moderator, + tester: auth.pnid.accessLevel >= 1 && auth.pnid.accessLevel <= 3, + developer: auth.pnid.accessLevel === 3, + accessLevel: auth.pnid.accessLevel, + + // 0 = normal, 1 = limited from posting, 2 = temp ban, 3 = perma ban + posting: auth.settings.account_status === 0 + }, + banState: null + }; +} diff --git a/apps/miiverse-api/src/services/internal/contract/user.ts b/apps/miiverse-api/src/services/internal/contract/user.ts new file mode 100644 index 00000000..673b34ff --- /dev/null +++ b/apps/miiverse-api/src/services/internal/contract/user.ts @@ -0,0 +1,18 @@ +import { z } from 'zod'; +import type { HydratedSettingsDocument } from '@/types/mongoose/settings'; + +export const shallowUserSchema = z.object({ + pid: z.number(), + miiName: z.string(), + accountStatus: z.number() +}).openapi('ShallowUser'); + +export type ShallowUserDto = z.infer; + +export function mapShallowUser(settings: HydratedSettingsDocument): ShallowUserDto { + return { + pid: settings.pid, + accountStatus: settings.account_status, + miiName: settings.screen_name + }; +} diff --git a/apps/miiverse-api/src/services/internal/index.ts b/apps/miiverse-api/src/services/internal/index.ts index 46db8e7c..13dd5a41 100644 --- a/apps/miiverse-api/src/services/internal/index.ts +++ b/apps/miiverse-api/src/services/internal/index.ts @@ -3,6 +3,9 @@ import { postsRouter } from '@/services/internal/routes/posts'; import { communitiesRouter } from '@/services/internal/routes/communities'; import { activityFeedsRouter } from '@/services/internal/routes/activityFeeds'; import { communityPostsRouter } from '@/services/internal/routes/communityPosts'; +import { userPostsRouter } from '@/services/internal/routes/userPosts'; +import { userProfileRouter } from '@/services/internal/routes/userProfile'; +import { selfRouter } from '@/services/internal/routes/self'; import { adminAutomodRouter } from '@/services/internal/routes/admin/adminAutomod'; export const internalApiRouter = express.Router(); @@ -10,4 +13,8 @@ internalApiRouter.use('/api/v1', postsRouter.toRouter()); internalApiRouter.use('/api/v1', communitiesRouter.toRouter()); internalApiRouter.use('/api/v1', activityFeedsRouter.toRouter()); internalApiRouter.use('/api/v1', communityPostsRouter.toRouter()); +internalApiRouter.use('/api/v1', userPostsRouter.toRouter()); +internalApiRouter.use('/api/v1', userProfileRouter.toRouter()); +internalApiRouter.use('/api/v1', selfRouter.toRouter()); + internalApiRouter.use('/api/v1', adminAutomodRouter.toRouter()); diff --git a/apps/miiverse-api/src/services/internal/middleware/auth-accesscheck.ts b/apps/miiverse-api/src/services/internal/middleware/auth-accesscheck.ts index d25a4c33..ef58813d 100644 --- a/apps/miiverse-api/src/services/internal/middleware/auth-accesscheck.ts +++ b/apps/miiverse-api/src/services/internal/middleware/auth-accesscheck.ts @@ -12,13 +12,15 @@ export async function authAccessCheck(request: express.Request, response: expres } if (account.pnid.deleted) { - throw new errors.unauthorized('Account does not exist'); + response.locals.accountAuthError = new errors.unauthorized('Account does not exist'); + return next(); } if (account.pnid.accessLevel < 0 || account.pnid.permissions?.bannedAllPermanently === true || account.pnid.permissions?.bannedAllTemporarily === true) { - throw new errors.forbidden('Account has been banned'); + response.locals.accountAuthError = new errors.forbidden('Account has been banned'); + return next(); } // TODO console linking check (needs account server support) @@ -32,10 +34,9 @@ export async function authAccessCheck(request: express.Request, response: expres // 0 = normal, 1 = limited from posting, 2 = temp ban, 3 = perma ban if (account.settings.account_status !== 0 && account.settings.account_status !== 1) { - throw new errors.forbidden('Account has been banned from Juxtaposition'); + response.locals.accountAuthError = new errors.forbidden('Account has been banned from Juxtaposition'); + return next(); } - // TODO lift expired temporary bans and post limits (frontend's responsibility for now) - return next(); } diff --git a/apps/miiverse-api/src/services/internal/middleware/guards.ts b/apps/miiverse-api/src/services/internal/middleware/guards.ts index 13b8bfae..fafb3b82 100644 --- a/apps/miiverse-api/src/services/internal/middleware/guards.ts +++ b/apps/miiverse-api/src/services/internal/middleware/guards.ts @@ -18,9 +18,14 @@ export async function user(request: express.Request, response: express.Response, throw new errors.unauthorized('Authentication token not provided'); } + const authError = response.locals.accountAuthError; + if (authError) { + // Auth access checks determined that this account can't access juxt + throw authError; + } + if (account.settings === null) { // Most endpoints expect users to have completed the account setup flow - // TODO eventually this will need a carveout for the setup flow itself throw new errors.forbidden('Account setup not complete'); } @@ -31,21 +36,23 @@ export async function user(request: express.Request, response: express.Response, * Moderators only */ export async function moderator(request: express.Request, response: express.Response, next: express.NextFunction): Promise { - const account = response.locals.account; - if (account === null) { - // Guest access - throw new errors.unauthorized('Authentication token not provided'); - } + return guards.user(request, response, () => { + const account = response.locals.account; + if (account === null) { + // Guest access + throw new errors.unauthorized('Authentication token not provided'); + } - if (account.settings === null) { - throw new errors.forbidden('Account setup not complete'); - } + if (account.settings === null) { + throw new errors.forbidden('Account setup not complete'); + } - if (account.moderator !== true) { - throw new errors.forbidden('You cannot access this endpoint'); - } + if (account.moderator !== true) { + throw new errors.forbidden('You cannot access this endpoint'); + } - return next(); + return next(); + }); } /** diff --git a/apps/miiverse-api/src/services/internal/routes/self.ts b/apps/miiverse-api/src/services/internal/routes/self.ts new file mode 100644 index 00000000..daa50253 --- /dev/null +++ b/apps/miiverse-api/src/services/internal/routes/self.ts @@ -0,0 +1,65 @@ +import { guards } from '@/services/internal/middleware/guards'; +import { createInternalApiRouter } from '@/services/internal/builder/router'; +import { errors } from '@/services/internal/errors'; +import { mapBannedSelf, mapSelf, selfSchema } from '@/services/internal/contract/self'; +import { Settings } from '@/models/settings'; + +export const selfRouter = createInternalApiRouter(); + +selfRouter.get({ + path: '/self', + name: 'self.get', + description: 'Get everything necccesary to represent the current user', + guard: guards.guest, + schema: { + response: selfSchema + }, + async handler({ auth }) { + if (!auth) { + throw new errors.unauthorized('User is not logged in'); + } + + // TODO these updates should probably be done in a middleware + const userSettings = await Settings.findOne({ pid: auth.pnid.pid }); + if (userSettings) { + // Clear ban lift date if neccesary + const hasBan = userSettings.account_status !== 0; + const shouldClearBan = userSettings.ban_lift_date && new Date(userSettings.ban_lift_date) <= new Date(); + if (hasBan && shouldClearBan) { + userSettings.account_status = 0; + } + + // Record activity & update metadata + userSettings.last_active = new Date(); + if (auth.pnid.mii) { + userSettings.screen_name = auth.pnid.mii.name; + } + + // Save changes to current auth state + await userSettings.save(); + auth.settings = userSettings; + } + + const accountStatus = auth.settings?.account_status ?? 0; + const isJuxtBanned = accountStatus < 0 || accountStatus > 1; + const isNetworkBanned = auth.pnid.accessLevel < 0 || + auth.pnid.permissions?.bannedAllPermanently === true || + auth.pnid.permissions?.bannedAllTemporarily === true; + + if (isNetworkBanned) { + return mapBannedSelf(auth, 'network_ban', null, null); + } + if (isJuxtBanned) { + const reason = userSettings?.ban_reason ?? null; + const endDate = userSettings?.ban_lift_date ?? null; + if (accountStatus === 2) { + return mapBannedSelf(auth, 'temp_ban', endDate, reason); + } + + // Technically it has a endDate, but we don't want the frontend to know about it + return mapBannedSelf(auth, 'perma_ban', null, reason); + } + + return mapSelf(auth); + } +}); diff --git a/apps/miiverse-api/src/services/internal/routes/userPosts.ts b/apps/miiverse-api/src/services/internal/routes/userPosts.ts new file mode 100644 index 00000000..1453effb --- /dev/null +++ b/apps/miiverse-api/src/services/internal/routes/userPosts.ts @@ -0,0 +1,47 @@ +import { z } from 'zod'; +import { Post } from '@/models/post'; +import { deleteOptional, filterRemovedPosts } from '@/services/internal/utils'; +import { guards } from '@/services/internal/middleware/guards'; +import { mapPost, postSchema } from '@/services/internal/contract/post'; +import { mapPage, pageControlSchema, pageDtoSchema } from '@/services/internal/contract/page'; +import { createInternalApiRouter } from '@/services/internal/builder/router'; +import { standardSortSchema, standardSortToDirection } from '@/services/internal/contract/utils'; +import { Settings } from '@/models/settings'; +import type { FilterQuery } from 'mongoose'; +import type { IPost } from '@/types/mongoose/post'; + +export const userPostsRouter = createInternalApiRouter(); + +userPostsRouter.get({ + path: '/users/:id/posts', + name: 'users.posts.list', + guard: guards.guest, + schema: { + params: z.object({ + id: z.coerce.number() + }), + query: z.object({ + sort: standardSortSchema + }).extend(pageControlSchema()), + response: pageDtoSchema(postSchema) + }, + async handler({ params, query, auth }) { + const targetUser = await Settings.findOne({ pid: params.id }); + if (!targetUser) { + return mapPage(0, []); + } + + const dbQuery: FilterQuery = deleteOptional({ + pid: targetUser.pid, + ...filterRemovedPosts(auth) + }); + const posts = await Post + .find(dbQuery) + .sort({ created_at: standardSortToDirection(query.sort) }) + .skip(query.offset) + .limit(query.limit); + const total = await Post.countDocuments(dbQuery); + + return mapPage(total, posts.map(mapPost)); + } +}); diff --git a/apps/miiverse-api/src/services/internal/routes/userProfile.ts b/apps/miiverse-api/src/services/internal/routes/userProfile.ts new file mode 100644 index 00000000..b4fa8aa9 --- /dev/null +++ b/apps/miiverse-api/src/services/internal/routes/userProfile.ts @@ -0,0 +1,168 @@ +import { z } from 'zod'; +import { deleteOptional } from '@/services/internal/utils'; +import { guards } from '@/services/internal/middleware/guards'; +import { mapPage, pageControlSchema, pageDtoSchema } from '@/services/internal/contract/page'; +import { createInternalApiRouter } from '@/services/internal/builder/router'; +import { Settings } from '@/models/settings'; +import { Content } from '@/models/content'; +import { mapShallowUser, shallowUserSchema } from '@/services/internal/contract/user'; +import { mapResult, resultSchema } from '@/services/internal/contract/result'; +import { errors } from '@/services/internal/errors'; +import { createNewFollowNotification } from '@/services/internal/utils/notifications'; +import type { FilterQuery } from 'mongoose'; +import type { ISettings } from '@/types/mongoose/settings'; + +export const userProfileRouter = createInternalApiRouter(); + +function notBanned() { + return { account_status: { $in: [0, 1] } }; +} + +userProfileRouter.get({ + path: '/users/:id/followers', + name: 'users.listFollowers', + description: 'Get a list of who is following the target user', + guard: guards.guest, + schema: { + params: z.object({ + id: z.coerce.number() + }), + query: z.object(pageControlSchema(100)), + response: pageDtoSchema(shallowUserSchema) + }, + async handler({ params, query }) { + const targetUser = await Settings.findOne({ pid: params.id }); + const targetUserContent = await Content.findOne({ pid: params.id }); + if (!targetUser) { + return mapPage(0, []); + } + + // User contents frequently have a `0` element in it + const targetPids = (targetUserContent?.following_users ?? []).filter(v => v !== 0); + + const dbQuery: FilterQuery = deleteOptional({ + pid: { + $in: targetPids + }, + ...notBanned() + }); + const items = await Settings + .find(dbQuery) + .sort({ pid: -1 }) + .skip(query.offset) + .limit(query.limit); + const total = await Settings.countDocuments(dbQuery); + + return mapPage(total, items.map(mapShallowUser)); + } +}); + +userProfileRouter.get({ + path: '/users/:id/following', + name: 'users.listFollowing', + description: 'Get a list of who the target user is following', + guard: guards.guest, + schema: { + params: z.object({ + id: z.coerce.number() + }), + query: z.object(pageControlSchema(100)), + response: pageDtoSchema(shallowUserSchema) + }, + async handler({ params, query }) { + const targetUser = await Settings.findOne({ pid: params.id }); + const targetUserContent = await Content.findOne({ pid: params.id }); + if (!targetUser) { + return mapPage(0, []); + } + + // User contents frequently have a `0` element in it + const targetPids = (targetUserContent?.followed_users ?? []).filter(v => v !== 0); + + const dbQuery: FilterQuery = deleteOptional({ + pid: { + $in: targetPids + }, + ...notBanned() + }); + const items = await Settings + .find(dbQuery) + .sort({ pid: -1 }) + .skip(query.offset) + .limit(query.limit); + const total = await Settings.countDocuments(dbQuery); + + return mapPage(total, items.map(mapShallowUser)); + } +}); + +userProfileRouter.post({ + path: '/users/:id/followers/@me', + name: 'users.followerUser', + guard: guards.user, + schema: { + params: z.object({ + id: z.coerce.number() + }), + response: resultSchema + }, + async handler({ params, auth }) { + const currentUser = auth!; + const currentUserPid = currentUser.pnid.pid; + + const targetUserContent = await Content.findOne({ pid: params.id }); + const currentUserContent = await Content.findOne({ pid: currentUserPid }); + if (!targetUserContent || !currentUserContent) { + throw new errors.notFound(); + } + + const currentUserFollowedUsers = currentUserContent.followed_users; + const isFollowing = currentUserFollowedUsers.includes(targetUserContent.pid); + if (isFollowing) { + return mapResult('success'); + } + + targetUserContent.following_users.push(currentUserPid); + currentUserContent.followed_users.push(targetUserContent.pid); + await targetUserContent.save(); + await currentUserContent.save(); + + await createNewFollowNotification({ currentUser: currentUserPid, userToFollow: targetUserContent.pid }); + return mapResult('success'); + } +}); + +userProfileRouter.delete({ + path: '/users/:id/followers/@me', + name: 'users.unfollowUser', + guard: guards.user, + schema: { + params: z.object({ + id: z.coerce.number() + }), + response: resultSchema + }, + async handler({ params, auth }) { + const currentUser = auth!; + const currentUserPid = currentUser.pnid.pid; + + const targetUserContent = await Content.findOne({ pid: params.id }); + const currentUserContent = await Content.findOne({ pid: currentUserPid }); + if (!targetUserContent || !currentUserContent) { + throw new errors.notFound(); + } + + const currentUserFollowedUsers = currentUserContent.followed_users; + const isFollowing = currentUserFollowedUsers.includes(targetUserContent.pid); + if (!isFollowing) { + return mapResult('success'); + } + + targetUserContent.following_users = targetUserContent.following_users.filter(pid => pid !== currentUserPid); + currentUserContent.followed_users = currentUserContent.followed_users.filter(pid => pid !== targetUserContent.pid); + await targetUserContent.save(); + await currentUserContent.save(); + + return mapResult('success'); + } +}); diff --git a/apps/miiverse-api/src/services/internal/server.ts b/apps/miiverse-api/src/services/internal/server.ts index b270ec81..a0e9d9ed 100644 --- a/apps/miiverse-api/src/services/internal/server.ts +++ b/apps/miiverse-api/src/services/internal/server.ts @@ -69,7 +69,7 @@ export async function setupGrpc(): Promise { const hasBody = methodsWithBody.includes(method as any); let baseRequest = superRequest(app)[method](request.path).set(headers); - if (hasBody) { + if (hasBody && request.payload.length > 0) { baseRequest = baseRequest.send(JSON.parse(request.payload)); } diff --git a/apps/miiverse-api/src/services/internal/utils/notifications.ts b/apps/miiverse-api/src/services/internal/utils/notifications.ts new file mode 100644 index 00000000..2e99604c --- /dev/null +++ b/apps/miiverse-api/src/services/internal/utils/notifications.ts @@ -0,0 +1,50 @@ +import { Notification } from '@/models/notification'; + +export type FollowNotificationOptions = { + userToFollow: number; + currentUser: number; +}; + +export async function createNewFollowNotification(ops: FollowNotificationOptions): Promise { + const now = new Date(); + const url = `/users/${ops.currentUser}`; + + // Same user has followed previously + const existingNotif = await Notification.findOne({ pid: ops.userToFollow, objectID: ops.currentUser }); + if (existingNotif) { + existingNotif.lastUpdated = now; + existingNotif.read = false; + await existingNotif.save(); + return; + } + + // Combine existing follower notification + const last60min = new Date(now.getTime() - 60 * 60 * 1000); + const groupedNotif = await Notification.findOne({ pid: ops.userToFollow, type: 'follow', lastUpdated: { $gte: last60min } }); + if (groupedNotif) { + groupedNotif.users.push({ + user: ops.currentUser, + timestamp: now + }); + groupedNotif.lastUpdated = now; + groupedNotif.link = url; + groupedNotif.objectID = ops.currentUser.toString(); + groupedNotif.read = false; + await groupedNotif.save(); + return; + } + + // Create new notification + await Notification.create({ + pid: ops.userToFollow, + type: 'follow', + users: [{ + user: ops.currentUser, + timestamp: now + }], + link: url, + objectID: ops.currentUser.toString(), + read: false, + lastUpdated: now + }); +} diff --git a/apps/miiverse-api/src/types/express.d.ts b/apps/miiverse-api/src/types/express.d.ts index e68ab165..eec1f7e5 100644 --- a/apps/miiverse-api/src/types/express.d.ts +++ b/apps/miiverse-api/src/types/express.d.ts @@ -17,6 +17,7 @@ declare global { // Locals used in internal/grpc interface Locals { account: AccountData | null; + accountAuthError: Error | null; // Set when authentication fails } } } diff --git a/apps/miiverse-api/src/types/mongoose/content.ts b/apps/miiverse-api/src/types/mongoose/content.ts index 12a80e81..c4ee18b1 100644 --- a/apps/miiverse-api/src/types/mongoose/content.ts +++ b/apps/miiverse-api/src/types/mongoose/content.ts @@ -1,10 +1,10 @@ -import type { Model, Types, HydratedDocument } from 'mongoose'; +import type { Model, HydratedDocument } from 'mongoose'; export interface IContent { pid: number; - followed_communities: Types.Array; - followed_users: Types.Array; - following_users: Types.Array; + followed_communities: Array; + followed_users: Array; + following_users: Array; } export type ContentModel = Model; diff --git a/apps/miiverse-api/src/types/mongoose/notification.ts b/apps/miiverse-api/src/types/mongoose/notification.ts index 986d55cd..eb327df9 100644 --- a/apps/miiverse-api/src/types/mongoose/notification.ts +++ b/apps/miiverse-api/src/types/mongoose/notification.ts @@ -12,7 +12,7 @@ export interface INotification { objectID: string; users: Types.Array; read: boolean; - lastUpdated: number; + lastUpdated: Date; } export type NotificationModel = Model;