Skip to content
Merged
Show file tree
Hide file tree
Changes from 24 commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
9a43270
feat(vscode): Add option to rebuild gRPC bindings
ashquarky May 16, 2025
d6a5d2d
feat(grpc): Move headers into the JSON object
ashquarky May 16, 2025
f2b6938
fix(grpc): Log the error object
ashquarky May 16, 2025
a4da331
feat(grpc): helpers for responding with error codes
ashquarky May 16, 2025
93e3ad1
feat(grpc): Work-in-progress authentication middleware
ashquarky May 16, 2025
a4f5153
chore(internal): Refactor error handling
ashquarky May 16, 2025
c695ef2
feat(internal): Implement authentication and tweak error handling more
ashquarky May 16, 2025
d9f70e9
feat(internal): Check for banned accounts
ashquarky May 16, 2025
06ab562
feat(internal): WIP posts endpoint (auth not working)
ashquarky May 16, 2025
2be4de2
fix(ui): Don't crash on promise rejection (please)
ashquarky May 24, 2025
fc8204c
fix(internal): enable posts route
ashquarky May 24, 2025
5c91b11
fix(server): Fix incorrect Express error handler
ashquarky Jun 8, 2025
952c758
feat(internal): Validate and use posts endpoint, tweak tokens handling
ashquarky Jun 8, 2025
771b649
feat(internal): Set up guest access controls per-endpoint
ashquarky Jun 8, 2025
126378b
fix(server): Force re-login if backend rejects user token
ashquarky Jun 8, 2025
9668d68
feat(ui): Support redirecting out of the login page
ashquarky Jun 8, 2025
6d8eced
fix(login): Validate redirect path
ashquarky Jun 10, 2025
70907d2
fix(api): Audit and fix schema to reflect real db
ashquarky Jun 11, 2025
83221c9
fix(login): Validation for the validation gods
ashquarky Jun 11, 2025
4b8ca00
feat(api): Rate-limiting for the ratelimiting gods
ashquarky Jun 11, 2025
299db63
feat(internal): Introduce dto contract type
ashquarky Jun 12, 2025
586e77d
feat(internal): Add explicit nulls for 404s
ashquarky Jun 12, 2025
77d331c
fix(internal): Restructure auth and guard middlewares
ashquarky Jun 12, 2025
5716ddd
Merge branch 'dev' into work/grpc
ashquarky Jun 12, 2025
eda12fb
fix(api): Tweak ratelimits
ashquarky Jun 13, 2025
e81f230
feat(internal): Stronger checks on account status + mod mode
ashquarky Jun 13, 2025
1e1f0bf
feat(internal): Show moderators removed posts
ashquarky Jun 13, 2025
dad0b14
fix(web): Provide redirect on all paths
ashquarky Jun 13, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .vscode/launch.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,13 @@
"env": {
"PN_JUXTAPOSITION_UI_USE_PRESETS": "docker"
}
},
{
"type": "node-terminal",
"name": "Rebuild gRPC",
"request": "launch",
"command": "npm rebuild @repo/grpc-client",
"cwd": "${workspaceFolder}"
}
],
"compounds": [
Expand Down
4 changes: 3 additions & 1 deletion apps/juxtaposition-ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
"ejs": "^3.1.10",
"esbuild-plugin-copy": "^2.1.1",
"express": "^4.21.2",
"express-async-errors": "^3.1.1",
"express-prom-bundle": "^7.0.2",
"express-rate-limit": "^7.5.0",
"express-session": "^1.18.1",
Expand All @@ -49,7 +50,8 @@
"redis": "^4.7.0",
"sharp": "^0.33.5",
"tga": "^1.0.7",
"tsx": "^4.19.3"
"tsx": "^4.19.3",
"zod": "^3.25.56"
},
"devDependencies": {
"@pretendonetwork/eslint-config": "^0.0.8",
Expand Down
65 changes: 65 additions & 0 deletions apps/juxtaposition-ui/src/api/post.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { apiFetchUser } from '@/fetch';
import type { UserTokens } from '@/fetch';

/* !!! HEY
* This type lives in apps/miiverse-api/src/services/internal/contract/post.ts
* Modify it there and copy-paste here! */

/* This type is the contract for the frontend. If we make changes to the db, this shape should be kept. */
export type PostDto = {
id: string;
title_id?: string; // number
screen_name: string;
body: string;
app_data?: string; // nintendo base64

painting?: string; // base64 or '', undef for PMs
screenshot?: string; // URL frag (leading /) or '', undef for PMs
screenshot_length?: number;

search_key?: string[]; // can be []
topic_tag?: string; // can be ''

community_id: string; // number
created_at: string; // ISO Z
feeling_id?: number;

is_autopost: boolean;
is_community_private_autopost: boolean;
is_spoiler: boolean;
is_app_jumpable: boolean;

empathy_count: number;
country_id: number;
language_id: number;

mii: string; // nintendo base64
mii_face_url: string; // full URL (cdn., r2-cdn.)

pid: number;
platform_id?: number;
region_id?: number;
parent: string | null;

reply_count: number;
verified: boolean;

message_to_pid: string | null;
removed: boolean;
removed_by?: number;
removed_at?: string; // ISO Z
removed_reason?: string;

yeahs: number[];
};

/**
* Fetches a Post for a given ID.
* @param tokens User to perform fetch as. Responses will be according to this users' permissions (user, moderator etc.)
* @param post_id The ID of the post to get.
* @returns Post object
*/
export async function getPostById(tokens: UserTokens, post_id: string): Promise<PostDto | null> {
const post = await apiFetchUser<PostDto>(tokens, `/api/v1/posts/${post_id}`);
return post;
}
5 changes: 4 additions & 1 deletion apps/juxtaposition-ui/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ const schema = z.object({
logSensitive: zodCoercedBoolean().default(false),
httpPort: z.coerce.number().default(8080),
httpCookieDomain: z.string().default('.pretendo.network'),
/** "Safe" base origin for login-redirect validation */
httpBaseUrl: z.string().default('https://juxt.pretendo.network'),
/** Configures proxy trust (X-Forwarded-For etc.). Can be `true` to unconditionally trust, or
* provide a numeric hop count, or comma-seperated CIDR ranges.
* See https://expressjs.com/en/guide/behind-proxies.html
Expand Down Expand Up @@ -94,7 +96,8 @@ export const config = {
http: {
port: unmappedConfig.httpPort,
cookieDomain: unmappedConfig.httpCookieDomain,
trustProxy: unmappedConfig.httpTrustProxy
trustProxy: unmappedConfig.httpTrustProxy,
baseUrl: unmappedConfig.httpBaseUrl
},
metrics: {
enabled: unmappedConfig.metricsEnabled,
Expand Down
43 changes: 38 additions & 5 deletions apps/juxtaposition-ui/src/fetch.ts
Original file line number Diff line number Diff line change
@@ -1,39 +1,72 @@
import { Metadata } from 'nice-grpc';
import { config } from '@/config';
import { grpcClient } from '@/grpc';
import type { PacketResponse } from '@repo/grpc-client/out/packet';

export type FetchOptions = {
method?: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH';
headers?: Record<string, string>;
headers?: Record<string, string | undefined>;
body?: Record<string, any> | undefined | null;
};

export interface FetchError extends Error {
name: 'FetchError';
status: number;
response: any;
}
function FetchError(response: PacketResponse, message: string): FetchError {
const error = new Error(message) as FetchError;
error.name = 'FetchError';
error.status = response.status;
error.response = response.payload; // parse json?
return error;
}

function isErrorHttpStatus(status: number): boolean {
return status >= 400 && status < 600;
}

export async function apiFetch<T>(path: string, options?: FetchOptions): Promise<T> {
export async function apiFetch<T>(path: string, options?: FetchOptions): Promise<T | null> {
const defaultedOptions = {
method: 'GET',
headers: {},
...options
};

const metadata = Metadata({
...defaultedOptions.headers,
'X-API-Key': config.grpc.miiverse.apiKey
});
const response = await grpcClient.sendPacket({
path,
method: defaultedOptions.method,
headers: JSON.stringify(defaultedOptions.headers),
payload: defaultedOptions.body ? JSON.stringify(defaultedOptions.body) : undefined
}, {
metadata
});

if (isErrorHttpStatus(response.status)) {
throw new Error(`HTTP error! status: ${response.status}`);
if (response.status === 404) {
return null;
} else if (isErrorHttpStatus(response.status)) {
throw FetchError(response, `HTTP error! status: ${response.status} ${response.payload}`);
}

return JSON.parse(response.payload) as T;
}

export type UserTokens = {
serviceToken?: string;
oauthToken?: string;
};

export async function apiFetchUser<T>(tokens: UserTokens, path: string, options?: FetchOptions): Promise<T | null> {
options = {
...options,
headers: {
'x-service-token': tokens.serviceToken,
'x-oauth-token': tokens.oauthToken,
...options?.headers
}
};
return apiFetch<T>(path, options);
}
3 changes: 3 additions & 0 deletions apps/juxtaposition-ui/src/middleware/consoleAuth.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,15 @@ async function auth(request, response, next) {
if (request.session && request.session.user && request.session.pid && !request.isWrite) {
request.user = request.session.user;
request.pid = request.session.pid;
request.tokens = request.session.tokens;
} else {
request.tokens = { serviceToken: request.headers['x-nintendo-servicetoken'] };
request.pid = request.headers['x-nintendo-servicetoken'] ? await util.processServiceToken(request.headers['x-nintendo-servicetoken']) : null;
request.user = request.pid ? await util.getUserDataFromPid(request.pid) : null;

request.session.user = request.user;
request.session.pid = request.pid;
request.session.tokens = request.tokens;
}

// Set headers
Expand Down
4 changes: 2 additions & 2 deletions apps/juxtaposition-ui/src/middleware/webAuth.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,14 +24,14 @@ async function webAuth(request, response, next) {
response.clearCookie('token_type', { domain: cookieDomain, path: '/' });
if (request.path === '/login') {
response.locals.lang = util.processLanguage();
request.token = request.cookies.access_token;
request.tokens = {};
request.paramPackData = null;
return next();
}
}
}

request.token = request.cookies.access_token;
request.tokens = { oauthToken: request.cookies.access_token };

// Open access pages
if (isStartOfPath(request.path, '/users/') ||
Expand Down
30 changes: 22 additions & 8 deletions apps/juxtaposition-ui/src/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ const cookieParser = require('cookie-parser');
const session = require('express-session');
const { RedisStore } = require('connect-redis');
const expressMetrics = require('express-prom-bundle');
require('express-async-errors'); // See package docs
const database = require('@/database');
const { logger } = require('@/logger');
const { loggerHttp } = require('@/loggerHttp');
Expand Down Expand Up @@ -75,23 +76,36 @@ app.use(juxt_web);
logger.info('Creating 404 status handler');
app.use((req, res) => {
req.log.warn('Page not found');
res.status(404);
res.render(req.directory + '/error.ejs', {
code: 404,
message: 'Page not found'
message: 'Page not found',
id: req.id
});
});

// non-404 error handler
logger.info('Creating non-404 status handler');
app.use((error, request, response) => {
const status = error.status || 500;
app.use((error, req, res, next) => {
if (res.headersSent) {
return next(error);
}

// small hack because token expiry is weird
if (error.status === 401) {
req.session.user = undefined;
req.session.pid = undefined;
return res.redirect(`/login?redirect=${req.originalUrl}`);
}

response.status(status);
const status = error.status || 500;
res.status(status);

response.json({
app: 'api',
status,
error: error.message
req.log.error(error, 'Request failed!');
res.render(req.directory + '/error.ejs', {
code: status,
message: 'Error',
id: req.id
});
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ const upload = multer({ dest: 'uploads/' });
const redis = require('@/redisCache');
const { config } = require('@/config');
const router = express.Router();
const { getPostById } = require('@/api/post');

const postLimit = rateLimit({
windowMs: 15 * 1000, // 30 seconds
Expand Down Expand Up @@ -41,7 +42,10 @@ const yeahLimit = rateLimit({
});

router.get('/:post_id/oembed.json', async function (req, res) {
const post = await database.getPostByID(req.params.post_id.toString());
const post = await getPostById(req.tokens, req.params.post_id);
if (!post) {
return res.sendStatus(404);
}
const doc = {
author_name: post.screen_name,
author_url: 'https://juxt.pretendo.network/users/show?pid=' + post.pid
Expand Down Expand Up @@ -108,16 +112,17 @@ router.post('/new', postLimit, upload.none(), async function (req, res) {
router.get('/:post_id', async function (req, res) {
const userSettings = await database.getUserSettings(req.pid);
const userContent = await database.getUserContent(req.pid);
let post = await database.getPostByID(req.params.post_id.toString());
if (post === null) {

const post = await getPostById(req.tokens, req.params.post_id);
if (!post) {
return res.redirect('/404');
}
if (post.parent) {
post = await database.getPostByID(post.parent);
if (post === null) {
return res.sendStatus(404);
const parent = await getPostById(req.tokens, post.parent);
if (!parent) {
return res.redirect('/404');
}
return res.redirect(`/posts/${post.id}`);
return res.redirect(`/posts/${parent.id}`);
}
const community = await database.getCommunityByID(post.community_id);
const communityMap = await util.getCommunityHash();
Expand Down Expand Up @@ -165,7 +170,7 @@ router.post('/:post_id/new', postLimit, upload.none(), async function (req, res)

router.post('/:post_id/report', upload.none(), async function (req, res) {
const { reason, message, post_id } = req.body;
const post = await database.getPostByID(post_id);
const post = await getPostById(req.tokens, post_id);
if (!reason || !post_id || !post) {
return res.redirect('/404');
}
Expand Down
18 changes: 11 additions & 7 deletions apps/juxtaposition-ui/src/services/juxt-web/routes/web/login.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,22 +8,22 @@ const { logger } = require('@/logger');
const cookieDomain = config.http.cookieDomain;

router.get('/', async function (req, res) {
res.render(req.directory + '/login.ejs', { toast: null });
res.render(req.directory + '/login.ejs', { toast: null, redirect: req.query.redirect ?? '/' });
});

router.post('/', async (req, res) => {
const { username, password } = req.body;
const { username, password, redirect } = req.body;
const login = await util.login(username, password).catch((e) => {
switch (e.details) {
case 'INVALID_ARGUMENT: User not found':
res.render(req.directory + '/login.ejs', { toast: 'Username was invalid.' });
res.render(req.directory + '/login.ejs', { toast: 'Username was invalid.', redirect });
break;
case 'INVALID_ARGUMENT: Password is incorrect':
res.render(req.directory + '/login.ejs', { toast: 'Password was incorrect.' });
res.render(req.directory + '/login.ejs', { toast: 'Password was incorrect.', redirect });
break;
default:
logger.error(e, `Login error for ${username}`);
res.render(req.directory + '/login.ejs', { toast: 'Invalid username or password.' });
res.render(req.directory + '/login.ejs', { toast: 'Invalid username or password.', redirect });
break;
}
});
Expand All @@ -33,7 +33,7 @@ router.post('/', async (req, res) => {

const PNID = await util.getUserDataFromToken(login.accessToken);
if (!PNID) {
return res.render(req.directory + '/login.ejs', { toast: 'Invalid username or password.' });
return res.render(req.directory + '/login.ejs', { toast: 'Invalid username or password.', redirect });
}

let discovery = await database.getEndPoint(config.serverEnvironment);
Expand Down Expand Up @@ -65,7 +65,11 @@ router.post('/', async (req, res) => {
res.cookie('access_token', login.accessToken, { domain: cookieDomain, maxAge: expiration });
res.cookie('refresh_token', login.refreshToken, { domain: cookieDomain });
res.cookie('token_type', 'Bearer', { domain: cookieDomain });
res.redirect('/');

/* Only allow relative URLs (leading /) or absolute ones on config.http.baseUrl. */
const safe_redirect = new URL(redirect, config.http.baseUrl).origin == config.http.baseUrl ? redirect : '/';

res.redirect(safe_redirect);
});

module.exports = router;
Loading