Skip to content

Commit 1208f48

Browse files
authored
Merge pull request #143 from PretendoNetwork/dev
Release
2 parents 45296cb + 201e00b commit 1208f48

95 files changed

Lines changed: 4511 additions & 1166 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.docker/docker-compose.yml

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,12 +67,18 @@ services:
6767

6868
proxy:
6969
image: mitmproxy/mitmproxy
70-
command: mitmweb --mode regular@8888 -s /data/mitmproxy-local.py -v --web-host 0.0.0.0 -k --set tls_version_client_min=UNBOUNDED --set tls_version_server_min=UNBOUNDED --set web_password=letmein
70+
command: >
71+
mitmweb -v --mode regular@8888
72+
-s /data/mitmproxy-local.py
73+
--web-host 0.0.0.0 --set web_password=letmein
74+
-k --set tls_version_client_min=UNBOUNDED --set tls_version_server_min=UNBOUNDED
75+
--set key_size=1024
7176
ports:
7277
- 8888:8888
7378
- 127.0.0.1:8081:8081
7479
volumes:
7580
- "./mitmproxy-local.py:/data/mitmproxy-local.py"
81+
- "./mitmproxy-data/:/home/mitmproxy/.mitmproxy/"
7682
extra_hosts:
7783
- "host.docker.internal:host-gateway"
7884

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,5 @@ dist/
55
newman
66
config.json
77
uploads/
8+
9+
.docker/mitmproxy-data

.vscode/launch.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,13 @@
2323
"env": {
2424
"PN_JUXTAPOSITION_UI_USE_PRESETS": "docker"
2525
}
26+
},
27+
{
28+
"type": "node-terminal",
29+
"name": "Rebuild gRPC",
30+
"request": "launch",
31+
"command": "npm rebuild @repo/grpc-client",
32+
"cwd": "${workspaceFolder}"
2633
}
2734
],
2835
"compounds": [

apps/juxtaposition-ui/package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
"ejs": "^3.1.10",
2828
"esbuild-plugin-copy": "^2.1.1",
2929
"express": "^4.21.2",
30+
"express-async-errors": "^3.1.1",
3031
"express-prom-bundle": "^7.0.2",
3132
"express-rate-limit": "^7.5.0",
3233
"express-session": "^1.18.1",
@@ -52,7 +53,8 @@
5253
"redis": "^4.7.0",
5354
"sharp": "^0.33.5",
5455
"tga": "^1.0.7",
55-
"tsx": "^4.19.3"
56+
"tsx": "^4.19.3",
57+
"zod": "^3.25.56"
5658
},
5759
"devDependencies": {
5860
"@pretendonetwork/eslint-config": "^0.0.11",
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import { apiFetchUser } from '@/fetch';
2+
import type { UserTokens } from '@/fetch';
3+
4+
/* !!! HEY
5+
* This type lives in apps/miiverse-api/src/services/internal/contract/post.ts
6+
* Modify it there and copy-paste here! */
7+
8+
/* This type is the contract for the frontend. If we make changes to the db, this shape should be kept. */
9+
export type PostDto = {
10+
id: string;
11+
title_id?: string; // number
12+
screen_name: string;
13+
body: string;
14+
app_data?: string; // nintendo base64
15+
16+
painting?: string; // base64 or '', undef for PMs
17+
screenshot?: string; // URL frag (leading /) or '', undef for PMs
18+
screenshot_length?: number;
19+
20+
search_key?: string[]; // can be []
21+
topic_tag?: string; // can be ''
22+
23+
community_id: string; // number
24+
created_at: string; // ISO Z
25+
feeling_id?: number;
26+
27+
is_autopost: boolean;
28+
is_community_private_autopost: boolean;
29+
is_spoiler: boolean;
30+
is_app_jumpable: boolean;
31+
32+
empathy_count: number;
33+
country_id: number;
34+
language_id: number;
35+
36+
mii: string; // nintendo base64
37+
mii_face_url: string; // full URL (cdn., r2-cdn.)
38+
39+
pid: number;
40+
platform_id?: number;
41+
region_id?: number;
42+
parent: string | null;
43+
44+
reply_count: number;
45+
verified: boolean;
46+
47+
message_to_pid: string | null;
48+
removed: boolean;
49+
removed_by?: number;
50+
removed_at?: string; // ISO Z
51+
removed_reason?: string;
52+
53+
yeahs: number[];
54+
};
55+
56+
/**
57+
* Fetches a Post for a given ID.
58+
* @param tokens User to perform fetch as. Responses will be according to this users' permissions (user, moderator etc.)
59+
* @param post_id The ID of the post to get.
60+
* @returns Post object
61+
*/
62+
export async function getPostById(tokens: UserTokens, post_id: string): Promise<PostDto | null> {
63+
const post = await apiFetchUser<PostDto>(tokens, `/api/v1/posts/${post_id}`);
64+
return post;
65+
}

apps/juxtaposition-ui/src/config.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ const schema = z.object({
99
logSensitive: zodCoercedBoolean().default(false),
1010
httpPort: z.coerce.number().default(8080),
1111
httpCookieDomain: z.string().default('.pretendo.network'),
12+
/** "Safe" base origin for login-redirect validation */
13+
httpBaseUrl: z.string().default('https://juxt.pretendo.network'),
1214
/** Configures proxy trust (X-Forwarded-For etc.). Can be `true` to unconditionally trust, or
1315
* provide a numeric hop count, or comma-seperated CIDR ranges.
1416
* See https://expressjs.com/en/guide/behind-proxies.html
@@ -94,7 +96,8 @@ export const config = {
9496
http: {
9597
port: unmappedConfig.httpPort,
9698
cookieDomain: unmappedConfig.httpCookieDomain,
97-
trustProxy: unmappedConfig.httpTrustProxy
99+
trustProxy: unmappedConfig.httpTrustProxy,
100+
baseUrl: unmappedConfig.httpBaseUrl
98101
},
99102
metrics: {
100103
enabled: unmappedConfig.metricsEnabled,

apps/juxtaposition-ui/src/fetch.ts

Lines changed: 38 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,39 +1,72 @@
11
import { Metadata } from 'nice-grpc';
22
import { config } from '@/config';
33
import { grpcClient } from '@/grpc';
4+
import type { PacketResponse } from '@repo/grpc-client/out/packet';
45

56
export type FetchOptions = {
67
method?: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH';
7-
headers?: Record<string, string>;
8+
headers?: Record<string, string | undefined>;
89
body?: Record<string, any> | undefined | null;
910
};
1011

12+
export interface FetchError extends Error {
13+
name: 'FetchError';
14+
status: number;
15+
response: any;
16+
}
17+
function FetchError(response: PacketResponse, message: string): FetchError {
18+
const error = new Error(message) as FetchError;
19+
error.name = 'FetchError';
20+
error.status = response.status;
21+
error.response = response.payload; // parse json?
22+
return error;
23+
}
24+
1125
function isErrorHttpStatus(status: number): boolean {
1226
return status >= 400 && status < 600;
1327
}
1428

15-
export async function apiFetch<T>(path: string, options?: FetchOptions): Promise<T> {
29+
export async function apiFetch<T>(path: string, options?: FetchOptions): Promise<T | null> {
1630
const defaultedOptions = {
1731
method: 'GET',
1832
headers: {},
1933
...options
2034
};
2135

2236
const metadata = Metadata({
23-
...defaultedOptions.headers,
2437
'X-API-Key': config.grpc.miiverse.apiKey
2538
});
2639
const response = await grpcClient.sendPacket({
2740
path,
2841
method: defaultedOptions.method,
42+
headers: JSON.stringify(defaultedOptions.headers),
2943
payload: defaultedOptions.body ? JSON.stringify(defaultedOptions.body) : undefined
3044
}, {
3145
metadata
3246
});
3347

34-
if (isErrorHttpStatus(response.status)) {
35-
throw new Error(`HTTP error! status: ${response.status}`);
48+
if (response.status === 404) {
49+
return null;
50+
} else if (isErrorHttpStatus(response.status)) {
51+
throw FetchError(response, `HTTP error! status: ${response.status} ${response.payload}`);
3652
}
3753

3854
return JSON.parse(response.payload) as T;
3955
}
56+
57+
export type UserTokens = {
58+
serviceToken?: string;
59+
oauthToken?: string;
60+
};
61+
62+
export async function apiFetchUser<T>(tokens: UserTokens, path: string, options?: FetchOptions): Promise<T | null> {
63+
options = {
64+
...options,
65+
headers: {
66+
'x-service-token': tokens.serviceToken,
67+
'x-oauth-token': tokens.oauthToken,
68+
...options?.headers
69+
}
70+
};
71+
return apiFetch<T>(path, options);
72+
}
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
import { Buffer } from 'node:buffer';
2+
// @ts-expect-error Missing upstream types for this library
3+
import TGA from 'tga';
4+
// @ts-expect-error Missing upstream types for this library
5+
import imagePixels from 'image-pixels';
6+
import { inflate, deflate } from 'pako';
7+
import { PNG } from 'pngjs';
8+
import bmp from 'bmp-js';
9+
import sharp from 'sharp';
10+
import { logger } from '@/logger';
11+
12+
/** Ingests a BMP-format painting and converts it to the usual TGA format.
13+
* @param painting base64-encoded bmp image
14+
* @returns base64-encoded zlib-compressed TGA
15+
*/
16+
export async function processBmpPainting(painting: string): Promise<string | null> {
17+
const paintingBuffer = Buffer.from(painting, 'base64');
18+
const bitmap = bmp.decode(paintingBuffer);
19+
const tga = createTgaFromPixelData(bitmap.width, bitmap.height, bitmap.data, false);
20+
21+
let output: Uint8Array;
22+
try {
23+
output = deflate(tga, { level: 6 });
24+
} catch (err) {
25+
logger.error(err, 'Could not compress painting');
26+
return null;
27+
}
28+
return Buffer.from(output.buffer).toString('base64');
29+
}
30+
31+
/**
32+
* Ingests a raw painting and converts it for CDN upload.
33+
* @param painting base64-encoded zlib-compressed TGA
34+
* @returns PNG in buffer
35+
*/
36+
export async function processPainting(painting: string): Promise<Buffer | null> {
37+
const paintingBuffer = Buffer.from(painting, 'base64');
38+
let output: Uint8Array;
39+
try {
40+
output = inflate(paintingBuffer);
41+
} catch (err) {
42+
logger.error(err, 'Could not decompress painting');
43+
return null;
44+
}
45+
let tga: TGA;
46+
try {
47+
tga = new TGA(Buffer.from(output.buffer));
48+
} catch (e) {
49+
logger.error(e, 'Could not parse painting');
50+
return null;
51+
}
52+
const png = new PNG({
53+
width: tga.width,
54+
height: tga.height
55+
});
56+
png.data = tga.pixels;
57+
return PNG.sync.write(png);
58+
}
59+
60+
/**
61+
* Rezise an image.
62+
* @param data base64-encoded input image, most codecs OK (check Sharp docs)
63+
* @param width Target width in pixels
64+
* @param height Target height in pixels
65+
* @returns New image, same codec as the input (not base64'd!)
66+
*/
67+
export async function resizeImage(data: string, width: number, height: number): Promise<Buffer> {
68+
const image = Buffer.from(data, 'base64');
69+
return sharp(image)
70+
.resize({ height, width })
71+
.toBuffer()
72+
.catch((err) => {
73+
logger.error(err, 'Could not resize image!');
74+
throw err;
75+
});
76+
}
77+
78+
export async function getTGAFromPNG(image: Buffer): Promise<string | null> {
79+
const pngData = await imagePixels(Buffer.from(image));
80+
const tga = TGA.createTgaBuffer(pngData.width, pngData.height, pngData.data);
81+
let output: Uint8Array;
82+
try {
83+
output = deflate(tga, { level: 6 });
84+
} catch (err) {
85+
logger.error(err, 'Could not decompress image');
86+
return null;
87+
}
88+
return Buffer.from(output.buffer).toString('base64').trim();
89+
}
90+
91+
/**
92+
* Makes a new TGA from BGRX pixel data
93+
* @param width Width of the data
94+
* @param height Height of the data
95+
* @param pixels [B1, G1, R1, X1, B2, R2, G2, X2...]
96+
* @param dontFlipY Invert the y-axis
97+
* @returns Buffer with TGA data
98+
*
99+
* Modified from https://github.com/steel1990/tga/blob/dcd2bff6536c1c75719ed4309389cd66f991d8d3/src/index.js#L16-L45
100+
*/
101+
function createTgaFromPixelData(width: number, height: number, pixels: Buffer, dontFlipY: boolean): Buffer {
102+
const buffer = Buffer.alloc(18 + pixels.length);
103+
// write header
104+
buffer.writeInt8(0, 0);
105+
buffer.writeInt8(0, 1);
106+
buffer.writeInt8(2, 2);
107+
buffer.writeInt16LE(0, 3);
108+
buffer.writeInt16LE(0, 5);
109+
buffer.writeInt8(0, 7);
110+
buffer.writeInt16LE(0, 8);
111+
buffer.writeInt16LE(0, 10);
112+
buffer.writeInt16LE(width, 12);
113+
buffer.writeInt16LE(height, 14);
114+
buffer.writeInt8(32, 16);
115+
buffer.writeInt8(8, 17);
116+
117+
let offset = 18;
118+
for (let i = 0; i < height; i++) {
119+
for (let j = 0; j < width; j++) {
120+
const idx = ((dontFlipY ? i : height - i - 1) * width + j) * 4;
121+
buffer.writeUInt8(pixels[idx + 1], offset++); // b
122+
buffer.writeUInt8(pixels[idx + 2], offset++); // g
123+
buffer.writeUInt8(pixels[idx + 3], offset++); // r
124+
buffer.writeUInt8(255, offset++); // a
125+
}
126+
}
127+
128+
return buffer;
129+
}

apps/juxtaposition-ui/src/loggerHttp.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,9 @@ import { config } from '@/config';
44
import { decodeParamPack } from '@/util';
55
import type { SerializedRequest, SerializedResponse } from 'pino';
66
import type { Request } from 'express';
7+
import type { ParamPack } from '@/types/common/param-pack';
78

8-
type SerializedNintendoRequest = SerializedRequest & { param_pack?: Record<string, string> };
9+
type SerializedNintendoRequest = SerializedRequest & { param_pack?: ParamPack };
910

1011
function redactHeaders(headers: Record<string, string>, allowlist: string[]): Record<string, string> {
1112
if (!config.log.sensitive) {

apps/juxtaposition-ui/src/middleware/checkBan.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ async function checkBan(request, response, next) {
3838
if (!accessAllowed) {
3939
response.status(500);
4040
if (request.directory === 'web') {
41-
return response.render('web/login.ejs', { toast: 'No access. Must be tester or dev' });
41+
return response.render('web/login.ejs', { toast: 'No access. Must be tester or dev', redirect: request.originalUrl });
4242
} else {
4343
return response.render('portal/partials/ban_notification.ejs', {
4444
user: null,
@@ -66,7 +66,7 @@ async function checkBan(request, response, next) {
6666
default:
6767
banMessage = `${request.user.username} has been banned. \n\nIf you have any questions contact the moderators in the Discord server or forum.`;
6868
}
69-
return response.render('web/login.ejs', { toast: banMessage });
69+
return response.render('web/login.ejs', { toast: banMessage, redirect: request.originalUrl });
7070
} else {
7171
return response.render(request.directory + '/partials/ban_notification.ejs', {
7272
user: userSettings,

0 commit comments

Comments
 (0)