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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/core/src/constants/event-bus.constant.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
export enum EventBusEvents {
EmailInit = 'email.init',
TokenExpired = 'token.expired',
OwnerRolesChanged = 'auth.owner-roles-changed',
CleanAggregateCache = 'cache.aggregate',
SystemException = 'system.exception',
ConfigChanged = 'config.changed',
Expand Down
7 changes: 6 additions & 1 deletion apps/core/src/modules/auth/auth.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,15 @@ import {
Injectable,
InternalServerErrorException,
} from '@nestjs/common'
import { EventEmitter2 } from '@nestjs/event-emitter'
import { hashPassword } from 'better-auth/crypto'
import { omit } from 'es-toolkit/compat'
import { customAlphabet } from 'nanoid'

import { SECRET_STORAGE_V2 } from '~/app.config'
import { RequestContext } from '~/common/contexts/request.context'
import { AppErrorCode, createAppException } from '~/common/errors'
import { EventBusEvents } from '~/constants/event-bus.constant'
import { alphabet } from '~/constants/other.constant'
import { SnowflakeService } from '~/shared/id/snowflake.service'
import { getAvatar } from '~/utils/tool.util'
Expand Down Expand Up @@ -49,6 +51,7 @@ export class AuthService {
@Inject(AuthInstanceInjectKey)
private readonly authInstance: InjectAuthInstance,
private readonly snowflakeService: SnowflakeService,
private readonly eventEmitter: EventEmitter2,
) {}

private normalizeOptional(value?: string | null) {
Expand Down Expand Up @@ -375,7 +378,7 @@ export class AuthService {
if (reader?.bannedAt) {
return null
}
if (reader?.role && !sessionUser.role) {
if (reader?.role) {
sessionUser = { ...sessionUser, role: reader.role }
}
}
Expand Down Expand Up @@ -418,6 +421,7 @@ export class AuthService {

await this.readerRepository.setOwnersExceptToReader(target.id)
await this.readerRepository.setRole(target.id, 'owner')
this.eventEmitter.emit(EventBusEvents.OwnerRolesChanged)

const ownerCount = await this.readerRepository.countOwners()
if (ownerCount !== 1) {
Expand Down Expand Up @@ -445,6 +449,7 @@ export class AuthService {
}

await this.readerRepository.setRole(target.id, 'reader')
this.eventEmitter.emit(EventBusEvents.OwnerRolesChanged)
return 'OK'
}

Expand Down
45 changes: 31 additions & 14 deletions apps/core/src/modules/backup/backup.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
} from 'node:fs/promises'
import { tmpdir } from 'node:os'
import path, { join, resolve } from 'node:path'
import { Readable } from 'node:stream'

import {
Injectable,
Expand Down Expand Up @@ -114,6 +115,9 @@ export class BackupService {
const zip = await new JSZip().loadAsync(buffer)

const entries = Object.values(zip.files)
if (entries.length > 10_000)
throw new Error('Backup exceeds entry count limit')
let expandedBytes = 0
for (const entry of entries) {
const name = entry.name
if (path.isAbsolute(name) || /^[a-z]:[/\\]/i.test(name)) {
Expand All @@ -139,8 +143,23 @@ export class BackupService {
continue
}
await mkdir(path.dirname(target), { recursive: true })
const content = await entry.async('nodebuffer')
await writeFile(target, Uint8Array.from(content))
async function* boundedContent() {
let entryBytes = 0
for await (const chunk of new Readable().wrap(
entry.nodeStream('nodebuffer'),
)) {
entryBytes += chunk.length
expandedBytes += chunk.length
if (
entryBytes > 256 * 1024 * 1024 ||
expandedBytes > 1024 * 1024 * 1024
) {
throw new Error('Backup exceeds expanded size limit')
}
yield chunk
}
}
await writeFile(target, boundedContent())
}
}

Expand Down Expand Up @@ -437,20 +456,18 @@ export class BackupService {

const backupDataDirFilenames = await readdir(backupDataDir)

await Promise.all(
backupDataDirFilenames.map(async (filename) => {
// Containment: source must stay within the extracted backup_data dir,
// destination within DATA_DIR. Rejects `..`/absolute filenames.
const fullpath = this.assertContained(backupDataDir, filename)
const targetPath = this.assertContained(DATA_DIR, filename)
for (const filename of backupDataDirFilenames) {
// Containment: source must stay within the extracted backup_data dir,
// destination within DATA_DIR. Rejects `..`/absolute filenames.
const fullpath = this.assertContained(backupDataDir, filename)
const targetPath = this.assertContained(DATA_DIR, filename)

await rm(targetPath, { recursive: true, force: true })
await rm(targetPath, { recursive: true, force: true })

await $throw(
`cp -r ${this.shellQuote(fullpath)} ${this.shellQuote(targetPath)}`,
)
}),
)
await $throw(
`cp -r ${this.shellQuote(fullpath)} ${this.shellQuote(targetPath)}`,
)
}

// SECURITY: never auto-install dependencies from a restored archive.
// An uploaded archive is attacker-controlled; running `npm install` on its
Expand Down
60 changes: 23 additions & 37 deletions apps/core/src/modules/comment/comment.interceptor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,53 +4,39 @@ import type {
NestInterceptor,
} from '@nestjs/common'
import { Injectable } from '@nestjs/common'
import { isNotNil } from 'es-toolkit'
import { cloneDeep, isArrayLike, isObjectLike } from 'es-toolkit/compat'
import { map } from 'rxjs'

import { getNestExecutionContextRequest } from '~/transformers/get-req.transformer'
import { getAvatar } from '~/utils/tool.util'

import { CommentViews } from './comment.views'

@Injectable()
export class CommentFilterEmailInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler) {
const request = this.getRequest(context)
// Already authenticated — skip filtering
const isAuthenticated = request.user
if (isAuthenticated) {
return next.handle()
}
return next.handle().pipe(
map(function handle(data: any) {
if (!data) {
return data
}
try {
if (isArrayLike(data?.data)) {
data?.data?.forEach((item: any, i: number) => {
if (isNotNil(item.mail)) {
data.data[i].avatar = getAvatar(item.mail)
delete data.data[i].mail
}
if (item.replies) {
handle({ data: data.data[i].replies })
}
})
}

if (isObjectLike(data)) {
Reflect.deleteProperty(data, 'mail')
}
if (request.hasAdminAccess ?? request.isAuthenticated) return next.handle()
return next.handle().pipe(map((data) => this.projectPublic(data)))
}

return cloneDeep(data)
} catch (error) {
if (isDev) {
console.error(error)
}
return cloneDeep(data)
}
}),
)
private projectPublic(value: any): any {
if (!value || typeof value !== 'object' || value instanceof Date)
return value
if (Array.isArray(value))
return value.map((item) => this.projectPublic(item))
if ('id' in value && 'text' in value && 'refType' in value) {
return CommentViews.public.parse({
...value,
avatar: value.avatar || (value.mail ? getAvatar(value.mail) : null),
replies: value.replies?.map((item: any) => this.projectPublic(item)),
children: value.children?.map((item: any) => this.projectPublic(item)),
parent: value.parent,
})
}
// Keep the explicit-envelope symbol and pagination metadata intact.
for (const key of Object.keys(value))
value[key] = this.projectPublic(value[key])
return value
}

get getRequest() {
Expand Down
51 changes: 51 additions & 0 deletions apps/core/src/modules/comment/comment.views.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,57 @@ const AuthorActivityItemSchema = z.object({
})

export const CommentViews = {
public: CommentDetailSchema.omit({
mail: true,
ip: true,
agent: true,
location: true,
state: true,
})
.extend({
ref: CommentRefSummarySchema.strip().nullable().optional(),
moderation: z
.object({
status: z.enum(['published', 'pending', 'rejected']),
reviewer: z.enum(['ai', 'owner']).optional(),
receipt: z.string().optional(),
})
.optional(),
replyWindow: z
.object({
total: z.number(),
returned: z.number(),
threshold: z.number(),
hasHidden: z.boolean(),
hiddenCount: z.number(),
nextCursor: z.string().optional(),
})
.optional(),
anchor: z.unknown().optional(),
reader: z
.object({
id: z.string(),
isMember: z.boolean().optional(),
name: z.string().nullable().optional(),
handle: z.string().nullable().optional(),
username: z.string().nullable().optional(),
displayUsername: z.string().nullable().optional(),
image: z.string().nullable().optional(),
membership: z
.object({
status: z.string(),
plan: z.string(),
})
.nullable()
.optional(),
})
.nullable()
.optional(),
replies: z.array(z.unknown()).optional(),
children: z.array(z.unknown()).optional(),
parent: CommentParentPreviewSchema.nullable().optional(),
})
.strip(),
moderation: z.object({
status: z.enum(['published', 'pending', 'rejected']),
reviewer: z.enum(['ai', 'owner']).optional(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -252,16 +252,20 @@ export class BrowserFetchService {
}
}

private buildBaseArgs(slot: PoolSlot): string[] {
private async buildBaseArgs(slot: PoolSlot): Promise<string[]> {
return [
'--session',
slot.name,
'--proxy',
await this.pool.getProxyUrl(),
'--proxy-bypass',
'<-loopback>',
'--user-agent',
OG_USER_AGENT,
'--headers',
JSON.stringify({ 'Accept-Language': OG_ACCEPT_LANGUAGE }),
'--args',
OG_LAUNCH_ARGS,
`${OG_LAUNCH_ARGS},--disable-quic,--force-webrtc-ip-handling-policy=disable_non_proxied_udp`,
]
}

Expand All @@ -275,7 +279,7 @@ export class BrowserFetchService {
const { stdout } = await execFileAsync(
executable,
[
...this.buildBaseArgs(slot),
...(await this.buildBaseArgs(slot)),
'batch',
'--bail',
'--json',
Expand Down Expand Up @@ -303,7 +307,7 @@ export class BrowserFetchService {
const { stdout } = await execFileAsync(
executable,
[
...this.buildBaseArgs(slot),
...(await this.buildBaseArgs(slot)),
'batch',
'--bail',
'--json',
Expand Down Expand Up @@ -334,7 +338,7 @@ export class BrowserFetchService {
const res = await execFileAsync(
executable,
[
...this.buildBaseArgs(slot),
...(await this.buildBaseArgs(slot)),
'network',
'requests',
'--filter',
Expand Down Expand Up @@ -416,7 +420,7 @@ export class BrowserFetchService {
await execFileAsync(
executable,
[
...this.buildBaseArgs(slot),
...(await this.buildBaseArgs(slot)),
'set',
'viewport',
String(SCREENSHOT_VIEWPORT_WIDTH),
Expand All @@ -433,7 +437,7 @@ export class BrowserFetchService {
await execFileAsync(
executable,
[
...this.buildBaseArgs(slot),
...(await this.buildBaseArgs(slot)),
'screenshot',
'--screenshot-format',
SCREENSHOT_CLI_FORMAT,
Expand Down
Loading
Loading