@kkuffour/solid-moderation-plugin 0.2.2 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.data/.internal/idp/keys/cookie-secret$.json +1 -0
- package/.data/.internal/idp/keys/jwks$.json +1 -0
- package/.data/.internal/setup/current-base-url$.json +1 -0
- package/PLUGIN_DEVELOPER_GUIDE.md +213 -0
- package/README.md +25 -28
- package/components/context.jsonld +6 -245
- package/config/default.json +14 -28
- package/dist/ModerationHandler.d.ts +21 -0
- package/dist/ModerationHandler.d.ts.map +1 -0
- package/dist/ModerationHandler.js +158 -0
- package/dist/ModerationHandler.js.map +1 -0
- package/dist/ModerationHandler.jsonld +126 -0
- package/dist/components/components.jsonld +13 -0
- package/dist/components/context.jsonld +11 -0
- package/dist/index.d.ts +1 -6
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -6
- package/dist/index.js.map +1 -1
- package/dist/providers/SightEngineProvider.jsonld +2 -2
- package/package.json +11 -11
- package/src/ModerationHandler.ts +189 -0
- package/src/index.ts +1 -6
- package/ARCHITECTURE.md +0 -52
- package/CONFIG-GUIDE.md +0 -49
- package/DEVELOPMENT.md +0 -129
- package/ENV-VARIABLES.md +0 -137
- package/INSTALLATION.md +0 -90
- package/MIGRATION.md +0 -81
- package/PRODUCTION.md +0 -186
- package/PUBLISHING.md +0 -104
- package/TESTING.md +0 -93
- package/components/components.jsonld +0 -18
- package/dist/ModerationConfig.d.ts +0 -16
- package/dist/ModerationConfig.d.ts.map +0 -1
- package/dist/ModerationConfig.js +0 -18
- package/dist/ModerationConfig.js.map +0 -1
- package/dist/ModerationConfig.jsonld +0 -66
- package/dist/ModerationMixin.d.ts +0 -13
- package/dist/ModerationMixin.d.ts.map +0 -1
- package/dist/ModerationMixin.js +0 -136
- package/dist/ModerationMixin.js.map +0 -1
- package/dist/ModerationMixin.jsonld +0 -180
- package/dist/ModerationOperationHandler.d.ts +0 -16
- package/dist/ModerationOperationHandler.d.ts.map +0 -1
- package/dist/ModerationOperationHandler.js +0 -45
- package/dist/ModerationOperationHandler.js.map +0 -1
- package/dist/ModerationOperationHandler.jsonld +0 -140
- package/dist/ModerationRecord.d.ts +0 -20
- package/dist/ModerationRecord.d.ts.map +0 -1
- package/dist/ModerationRecord.js +0 -3
- package/dist/ModerationRecord.js.map +0 -1
- package/dist/ModerationRecord.jsonld +0 -59
- package/dist/ModerationResourceStore.d.ts +0 -30
- package/dist/ModerationResourceStore.d.ts.map +0 -1
- package/dist/ModerationResourceStore.js +0 -167
- package/dist/ModerationResourceStore.js.map +0 -1
- package/dist/ModerationResourceStore.jsonld +0 -157
- package/dist/ModerationStore.d.ts +0 -12
- package/dist/ModerationStore.d.ts.map +0 -1
- package/dist/ModerationStore.js +0 -37
- package/dist/ModerationStore.js.map +0 -1
- package/dist/ModerationStore.jsonld +0 -59
- package/dist/util/GuardedStream.d.ts +0 -33
- package/dist/util/GuardedStream.d.ts.map +0 -1
- package/dist/util/GuardedStream.js +0 -89
- package/dist/util/GuardedStream.js.map +0 -1
- package/simple-test.json +0 -7
- package/src/ModerationConfig.ts +0 -29
- package/src/ModerationMixin.ts +0 -153
- package/src/ModerationOperationHandler.ts +0 -64
- package/src/ModerationRecord.ts +0 -19
- package/src/ModerationResourceStore.ts +0 -227
- package/src/ModerationStore.ts +0 -41
- package/src/util/GuardedStream.ts +0 -101
package/src/ModerationMixin.ts
DELETED
|
@@ -1,153 +0,0 @@
|
|
|
1
|
-
import { Readable } from 'node:stream';
|
|
2
|
-
import { promises as fs } from 'node:fs';
|
|
3
|
-
import type { Operation } from '@solid/community-server';
|
|
4
|
-
import { getLoggerFor, ForbiddenHttpError, guardStream } from '@solid/community-server';
|
|
5
|
-
import type { ModerationConfig } from './ModerationConfig';
|
|
6
|
-
import { SightEngineProvider } from './providers/SightEngineProvider';
|
|
7
|
-
import { ModerationStore } from './ModerationStore';
|
|
8
|
-
|
|
9
|
-
export class ModerationMixin {
|
|
10
|
-
protected readonly logger = getLoggerFor(this);
|
|
11
|
-
private readonly moderationConfig: ModerationConfig;
|
|
12
|
-
private readonly moderationStore?: ModerationStore;
|
|
13
|
-
|
|
14
|
-
public constructor(moderationConfig: ModerationConfig) {
|
|
15
|
-
this.moderationConfig = moderationConfig;
|
|
16
|
-
this.moderationStore = moderationConfig.auditLoggingEnabled ?
|
|
17
|
-
new ModerationStore(moderationConfig.auditLoggingStorePath) :
|
|
18
|
-
undefined;
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
public async moderateContent(operation: Operation): Promise<void> {
|
|
22
|
-
if (!this.moderationConfig.enabled || !operation.body?.data) {
|
|
23
|
-
return;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
const contentType = operation.body.metadata.contentType;
|
|
27
|
-
if (contentType?.startsWith('image/') && this.moderationConfig.imagesEnabled) {
|
|
28
|
-
await this.moderateImageContent(operation);
|
|
29
|
-
} else if (contentType?.startsWith('text/') && this.moderationConfig.textEnabled) {
|
|
30
|
-
await this.moderateTextContent(operation);
|
|
31
|
-
} else if (contentType?.startsWith('video/') && this.moderationConfig.videoEnabled) {
|
|
32
|
-
await this.moderateVideoContent(operation);
|
|
33
|
-
}
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
private async moderateImageContent(operation: Operation): Promise<void> {
|
|
37
|
-
const chunks: Buffer[] = [];
|
|
38
|
-
for await (const chunk of operation.body.data) {
|
|
39
|
-
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk as Uint8Array));
|
|
40
|
-
}
|
|
41
|
-
const buffer = Buffer.concat(chunks);
|
|
42
|
-
operation.body.data = guardStream(Readable.from(buffer));
|
|
43
|
-
|
|
44
|
-
const tempFile = `/tmp/moderation_${Date.now()}.jpg`;
|
|
45
|
-
await fs.writeFile(tempFile, buffer);
|
|
46
|
-
|
|
47
|
-
try {
|
|
48
|
-
const client = new SightEngineProvider(
|
|
49
|
-
this.moderationConfig.sightEngineApiUser,
|
|
50
|
-
this.moderationConfig.sightEngineApiSecret,
|
|
51
|
-
);
|
|
52
|
-
const result = await client.analyzeImage(tempFile);
|
|
53
|
-
this.logger.info(`MODERATION: Image analysis result - nudity score: ${result.nudity?.raw}, threshold: ${this.moderationConfig.imageNudityThreshold}`);
|
|
54
|
-
|
|
55
|
-
if (result.nudity?.raw && result.nudity.raw > this.moderationConfig.imageNudityThreshold) {
|
|
56
|
-
if (this.moderationStore) {
|
|
57
|
-
await this.moderationStore.recordViolation({
|
|
58
|
-
contentType: 'image',
|
|
59
|
-
resourcePath: operation.target.path,
|
|
60
|
-
violations: [{ model: 'nudity', score: result.nudity.raw, threshold: this.moderationConfig.imageNudityThreshold }],
|
|
61
|
-
contentSize: buffer.length,
|
|
62
|
-
});
|
|
63
|
-
}
|
|
64
|
-
throw new ForbiddenHttpError('Upload blocked: Content violates community guidelines');
|
|
65
|
-
}
|
|
66
|
-
} catch (error: unknown) {
|
|
67
|
-
if (error instanceof ForbiddenHttpError) {
|
|
68
|
-
throw error;
|
|
69
|
-
}
|
|
70
|
-
this.logger.warn(`MODERATION: Image check failed (fail-open): ${error}`);
|
|
71
|
-
} finally {
|
|
72
|
-
await fs.unlink(tempFile).catch(() => {});
|
|
73
|
-
}
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
private async moderateTextContent(operation: Operation): Promise<void> {
|
|
77
|
-
const chunks: Buffer[] = [];
|
|
78
|
-
for await (const chunk of operation.body.data) {
|
|
79
|
-
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk as Uint8Array));
|
|
80
|
-
}
|
|
81
|
-
const buffer = Buffer.concat(chunks);
|
|
82
|
-
const text = buffer.toString('utf8');
|
|
83
|
-
operation.body.data = guardStream(Readable.from(buffer));
|
|
84
|
-
|
|
85
|
-
if (!text?.trim()) {
|
|
86
|
-
return;
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
try {
|
|
90
|
-
const client = new SightEngineProvider(
|
|
91
|
-
this.moderationConfig.sightEngineApiUser,
|
|
92
|
-
this.moderationConfig.sightEngineApiSecret,
|
|
93
|
-
);
|
|
94
|
-
const result = await client.analyzeText(text);
|
|
95
|
-
|
|
96
|
-
if (result.sexual > this.moderationConfig.textSexualThreshold || result.toxic > this.moderationConfig.textToxicThreshold) {
|
|
97
|
-
if (this.moderationStore) {
|
|
98
|
-
await this.moderationStore.recordViolation({
|
|
99
|
-
contentType: 'text',
|
|
100
|
-
resourcePath: operation.target.path,
|
|
101
|
-
violations: [{ model: 'text', score: Math.max(result.sexual, result.toxic), threshold: Math.min(this.moderationConfig.textSexualThreshold, this.moderationConfig.textToxicThreshold) }],
|
|
102
|
-
contentSize: buffer.length,
|
|
103
|
-
});
|
|
104
|
-
}
|
|
105
|
-
throw new ForbiddenHttpError('Upload blocked: Content violates community guidelines');
|
|
106
|
-
}
|
|
107
|
-
} catch (error: unknown) {
|
|
108
|
-
if (error instanceof ForbiddenHttpError) {
|
|
109
|
-
throw error;
|
|
110
|
-
}
|
|
111
|
-
this.logger.warn(`MODERATION: Text check failed (fail-open): ${error}`);
|
|
112
|
-
}
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
private async moderateVideoContent(operation: Operation): Promise<void> {
|
|
116
|
-
const chunks: Buffer[] = [];
|
|
117
|
-
for await (const chunk of operation.body.data) {
|
|
118
|
-
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk as Uint8Array));
|
|
119
|
-
}
|
|
120
|
-
const buffer = Buffer.concat(chunks);
|
|
121
|
-
operation.body.data = guardStream(Readable.from(buffer));
|
|
122
|
-
|
|
123
|
-
const tempFile = `/tmp/moderation_${Date.now()}.mp4`;
|
|
124
|
-
await fs.writeFile(tempFile, buffer);
|
|
125
|
-
|
|
126
|
-
try {
|
|
127
|
-
const client = new SightEngineProvider(
|
|
128
|
-
this.moderationConfig.sightEngineApiUser,
|
|
129
|
-
this.moderationConfig.sightEngineApiSecret,
|
|
130
|
-
);
|
|
131
|
-
const result = await client.analyzeVideo(tempFile);
|
|
132
|
-
|
|
133
|
-
if (result.nudity?.raw && result.nudity.raw > this.moderationConfig.videoNudityThreshold) {
|
|
134
|
-
if (this.moderationStore) {
|
|
135
|
-
await this.moderationStore.recordViolation({
|
|
136
|
-
contentType: 'video',
|
|
137
|
-
resourcePath: operation.target.path,
|
|
138
|
-
violations: [{ model: 'nudity', score: result.nudity.raw, threshold: this.moderationConfig.videoNudityThreshold }],
|
|
139
|
-
contentSize: buffer.length,
|
|
140
|
-
});
|
|
141
|
-
}
|
|
142
|
-
throw new ForbiddenHttpError('Upload blocked: Content violates community guidelines');
|
|
143
|
-
}
|
|
144
|
-
} catch (error: unknown) {
|
|
145
|
-
if (error instanceof ForbiddenHttpError) {
|
|
146
|
-
throw error;
|
|
147
|
-
}
|
|
148
|
-
this.logger.warn(`MODERATION: Video check failed (fail-open): ${error}`);
|
|
149
|
-
} finally {
|
|
150
|
-
await fs.unlink(tempFile).catch(() => {});
|
|
151
|
-
}
|
|
152
|
-
}
|
|
153
|
-
}
|
|
@@ -1,64 +0,0 @@
|
|
|
1
|
-
import { getLoggerFor } from '@solid/community-server';
|
|
2
|
-
import type { ModerationConfig } from './ModerationConfig';
|
|
3
|
-
import type { ResponseDescription, OperationHandlerInput, OperationHandler } from '@solid/community-server';
|
|
4
|
-
import { ModerationMixin } from './ModerationMixin';
|
|
5
|
-
|
|
6
|
-
/**
|
|
7
|
-
* Wraps an OperationHandler to add content moderation for operations with body data.
|
|
8
|
-
* Checks PUT, POST, and PATCH operations before passing them to the wrapped handler.
|
|
9
|
-
*
|
|
10
|
-
* @module
|
|
11
|
-
*/
|
|
12
|
-
export class ModerationOperationHandler {
|
|
13
|
-
protected readonly logger = getLoggerFor(this);
|
|
14
|
-
private readonly source: OperationHandler;
|
|
15
|
-
private readonly moderationMixin: ModerationMixin;
|
|
16
|
-
|
|
17
|
-
public constructor(
|
|
18
|
-
source: OperationHandler,
|
|
19
|
-
enabled: boolean,
|
|
20
|
-
auditLoggingEnabled: boolean,
|
|
21
|
-
auditLoggingStorePath: string,
|
|
22
|
-
sightEngineApiUser: string,
|
|
23
|
-
sightEngineApiSecret: string,
|
|
24
|
-
imagesEnabled: boolean,
|
|
25
|
-
textEnabled: boolean,
|
|
26
|
-
videoEnabled: boolean,
|
|
27
|
-
imageNudityThreshold: number,
|
|
28
|
-
textSexualThreshold: number,
|
|
29
|
-
textToxicThreshold: number,
|
|
30
|
-
videoNudityThreshold: number,
|
|
31
|
-
) {
|
|
32
|
-
this.source = source;
|
|
33
|
-
this.moderationMixin = new ModerationMixin({
|
|
34
|
-
enabled,
|
|
35
|
-
auditLoggingEnabled,
|
|
36
|
-
auditLoggingStorePath,
|
|
37
|
-
sightEngineApiUser,
|
|
38
|
-
sightEngineApiSecret,
|
|
39
|
-
imagesEnabled,
|
|
40
|
-
textEnabled,
|
|
41
|
-
videoEnabled,
|
|
42
|
-
imageNudityThreshold,
|
|
43
|
-
textSexualThreshold,
|
|
44
|
-
textToxicThreshold,
|
|
45
|
-
videoNudityThreshold,
|
|
46
|
-
});
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
public async canHandle(input: OperationHandlerInput): Promise<void> {
|
|
50
|
-
return this.source.canHandle(input);
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
public async handle(input: OperationHandlerInput): Promise<ResponseDescription> {
|
|
54
|
-
const { operation } = input;
|
|
55
|
-
|
|
56
|
-
// Only moderate operations with body data
|
|
57
|
-
if (operation.body?.data && [ 'PUT', 'POST', 'PATCH' ].includes(operation.method)) {
|
|
58
|
-
this.logger.info(`MODERATION: Checking ${operation.method} request to ${operation.target.path}`);
|
|
59
|
-
await this.moderationMixin.moderateContent(operation);
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
return this.source.handle(input);
|
|
63
|
-
}
|
|
64
|
-
}
|
package/src/ModerationRecord.ts
DELETED
|
@@ -1,19 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Record of a content moderation violation for admin review.
|
|
3
|
-
*/
|
|
4
|
-
export interface ModerationRecord {
|
|
5
|
-
id: string;
|
|
6
|
-
timestamp: Date;
|
|
7
|
-
contentType: 'image' | 'text' | 'video';
|
|
8
|
-
resourcePath: string;
|
|
9
|
-
userWebId?: string;
|
|
10
|
-
userAgent?: string;
|
|
11
|
-
clientIp?: string;
|
|
12
|
-
violations: {
|
|
13
|
-
model: string;
|
|
14
|
-
score: number;
|
|
15
|
-
threshold: number;
|
|
16
|
-
}[];
|
|
17
|
-
contentSize: number;
|
|
18
|
-
contentHash?: string;
|
|
19
|
-
}
|
|
@@ -1,227 +0,0 @@
|
|
|
1
|
-
import type { ResourceStore, ChangeMap, Representation, ResourceIdentifier, RepresentationPreferences, Conditions, Patch } from '@solid/community-server';
|
|
2
|
-
import { getLoggerFor, ForbiddenHttpError, guardedStreamFrom } from '@solid/community-server';
|
|
3
|
-
import { writeFileSync, unlinkSync } from 'fs';
|
|
4
|
-
import type { SightEngineProvider } from './providers/SightEngineProvider';
|
|
5
|
-
|
|
6
|
-
/**
|
|
7
|
-
* @module
|
|
8
|
-
* Wraps a ResourceStore to add content moderation on write operations.
|
|
9
|
-
*/
|
|
10
|
-
export class ModerationResourceStore implements ResourceStore {
|
|
11
|
-
private readonly logger = getLoggerFor(this);
|
|
12
|
-
private readonly source: ResourceStore;
|
|
13
|
-
private readonly client: SightEngineProvider;
|
|
14
|
-
private readonly enabled: boolean;
|
|
15
|
-
private readonly imageNudityThreshold: number;
|
|
16
|
-
private readonly textSexualThreshold: number;
|
|
17
|
-
private readonly textToxicThreshold: number;
|
|
18
|
-
private readonly videoNudityThreshold: number;
|
|
19
|
-
|
|
20
|
-
public constructor(
|
|
21
|
-
source: ResourceStore,
|
|
22
|
-
client: SightEngineProvider,
|
|
23
|
-
enabled: boolean,
|
|
24
|
-
imageNudityThreshold: number,
|
|
25
|
-
textSexualThreshold: number,
|
|
26
|
-
textToxicThreshold: number,
|
|
27
|
-
videoNudityThreshold: number,
|
|
28
|
-
) {
|
|
29
|
-
this.source = source;
|
|
30
|
-
this.client = client;
|
|
31
|
-
this.enabled = enabled;
|
|
32
|
-
this.imageNudityThreshold = imageNudityThreshold;
|
|
33
|
-
this.textSexualThreshold = textSexualThreshold;
|
|
34
|
-
this.textToxicThreshold = textToxicThreshold;
|
|
35
|
-
this.videoNudityThreshold = videoNudityThreshold;
|
|
36
|
-
|
|
37
|
-
this.logger.info('ModerationResourceStore initialized');
|
|
38
|
-
this.logger.info(` Enabled: ${enabled}`);
|
|
39
|
-
this.logger.info(` Thresholds: image=${imageNudityThreshold}, text=${textSexualThreshold}/${textToxicThreshold}, video=${videoNudityThreshold}`);
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
public async hasResource(identifier: ResourceIdentifier): Promise<boolean> {
|
|
43
|
-
return this.source.hasResource(identifier);
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
public async getRepresentation(
|
|
47
|
-
identifier: ResourceIdentifier,
|
|
48
|
-
preferences: RepresentationPreferences,
|
|
49
|
-
conditions?: Conditions,
|
|
50
|
-
): Promise<Representation> {
|
|
51
|
-
return this.source.getRepresentation(identifier, preferences, conditions);
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
public async setRepresentation(
|
|
55
|
-
identifier: ResourceIdentifier,
|
|
56
|
-
representation: Representation,
|
|
57
|
-
conditions?: Conditions,
|
|
58
|
-
): Promise<ChangeMap> {
|
|
59
|
-
await this.moderateRepresentation(identifier, representation);
|
|
60
|
-
return this.source.setRepresentation(identifier, representation, conditions);
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
public async addResource(
|
|
64
|
-
container: ResourceIdentifier,
|
|
65
|
-
representation: Representation,
|
|
66
|
-
conditions?: Conditions,
|
|
67
|
-
): Promise<ChangeMap> {
|
|
68
|
-
await this.moderateRepresentation(container, representation);
|
|
69
|
-
return this.source.addResource(container, representation, conditions);
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
public async deleteResource(
|
|
73
|
-
identifier: ResourceIdentifier,
|
|
74
|
-
conditions?: Conditions,
|
|
75
|
-
): Promise<ChangeMap> {
|
|
76
|
-
return this.source.deleteResource(identifier, conditions);
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
public async modifyResource(
|
|
80
|
-
identifier: ResourceIdentifier,
|
|
81
|
-
patch: Patch,
|
|
82
|
-
conditions?: Conditions,
|
|
83
|
-
): Promise<ChangeMap> {
|
|
84
|
-
return this.source.modifyResource(identifier, patch, conditions);
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
private async moderateRepresentation(
|
|
88
|
-
identifier: ResourceIdentifier,
|
|
89
|
-
representation: Representation,
|
|
90
|
-
): Promise<void> {
|
|
91
|
-
if (!this.enabled || !representation.data) {
|
|
92
|
-
return;
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
const contentType = representation.metadata.contentType;
|
|
96
|
-
if (!contentType) {
|
|
97
|
-
return;
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
const shouldModerate = this.shouldModerateContentType(contentType);
|
|
101
|
-
if (!shouldModerate) {
|
|
102
|
-
return;
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
this.logger.info(`Moderating ${contentType} upload to ${identifier.path}`);
|
|
106
|
-
|
|
107
|
-
try {
|
|
108
|
-
const chunks: Buffer[] = [];
|
|
109
|
-
for await (const chunk of representation.data) {
|
|
110
|
-
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
111
|
-
}
|
|
112
|
-
const buffer = Buffer.concat(chunks);
|
|
113
|
-
|
|
114
|
-
if (contentType.startsWith('image/')) {
|
|
115
|
-
await this.moderateImage(identifier.path, buffer, contentType);
|
|
116
|
-
} else if (contentType.startsWith('video/')) {
|
|
117
|
-
await this.moderateVideo(identifier.path, buffer, contentType);
|
|
118
|
-
} else if (contentType.startsWith('text/')) {
|
|
119
|
-
await this.moderateText(identifier.path, buffer);
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
representation.data = guardedStreamFrom(buffer);
|
|
123
|
-
|
|
124
|
-
} catch (error: unknown) {
|
|
125
|
-
if (error instanceof ForbiddenHttpError) {
|
|
126
|
-
throw error;
|
|
127
|
-
}
|
|
128
|
-
this.logger.error(`Moderation failed for ${identifier.path}: ${(error as Error).message}`);
|
|
129
|
-
this.logger.warn('Allowing content through due to moderation failure (fail-open policy)');
|
|
130
|
-
|
|
131
|
-
// Re-create stream from buffer if we have it
|
|
132
|
-
const chunks: Buffer[] = [];
|
|
133
|
-
try {
|
|
134
|
-
for await (const chunk of representation.data) {
|
|
135
|
-
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
136
|
-
}
|
|
137
|
-
representation.data = guardedStreamFrom(Buffer.concat(chunks));
|
|
138
|
-
} catch {
|
|
139
|
-
// Stream already consumed, can't recover
|
|
140
|
-
}
|
|
141
|
-
}
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
private shouldModerateContentType(contentType: string): boolean {
|
|
145
|
-
return contentType.startsWith('image/') ||
|
|
146
|
-
contentType.startsWith('video/') ||
|
|
147
|
-
contentType.startsWith('text/');
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
private async moderateImage(path: string, buffer: Buffer, contentType: string): Promise<void> {
|
|
151
|
-
const tempFile = `/tmp/moderation_${Date.now()}.${this.getFileExtension(contentType)}`;
|
|
152
|
-
writeFileSync(tempFile, buffer);
|
|
153
|
-
|
|
154
|
-
try {
|
|
155
|
-
const result = await this.client.analyzeImage(tempFile);
|
|
156
|
-
|
|
157
|
-
if (result.nudity?.raw && result.nudity.raw > this.imageNudityThreshold) {
|
|
158
|
-
this.logger.warn(`Image BLOCKED: ${path} - nudity score ${result.nudity.raw.toFixed(2)} > ${this.imageNudityThreshold}`);
|
|
159
|
-
throw new ForbiddenHttpError(`Image contains inappropriate content (nudity: ${result.nudity.raw.toFixed(2)})`);
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
this.logger.info(`Image APPROVED: ${path}`);
|
|
163
|
-
} finally {
|
|
164
|
-
try {
|
|
165
|
-
unlinkSync(tempFile);
|
|
166
|
-
} catch {
|
|
167
|
-
this.logger.warn(`Failed to cleanup temp file: ${tempFile}`);
|
|
168
|
-
}
|
|
169
|
-
}
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
private async moderateVideo(path: string, buffer: Buffer, contentType: string): Promise<void> {
|
|
173
|
-
const tempFile = `/tmp/moderation_${Date.now()}.${this.getFileExtension(contentType)}`;
|
|
174
|
-
writeFileSync(tempFile, buffer);
|
|
175
|
-
|
|
176
|
-
try {
|
|
177
|
-
const result = await this.client.analyzeVideo(tempFile);
|
|
178
|
-
|
|
179
|
-
if (result.nudity?.raw && result.nudity.raw > this.videoNudityThreshold) {
|
|
180
|
-
this.logger.warn(`Video BLOCKED: ${path} - nudity score ${result.nudity.raw.toFixed(2)} > ${this.videoNudityThreshold}`);
|
|
181
|
-
throw new ForbiddenHttpError(`Video contains inappropriate content (nudity: ${result.nudity.raw.toFixed(2)})`);
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
this.logger.info(`Video APPROVED: ${path}`);
|
|
185
|
-
} finally {
|
|
186
|
-
try {
|
|
187
|
-
unlinkSync(tempFile);
|
|
188
|
-
} catch {
|
|
189
|
-
this.logger.warn(`Failed to cleanup temp file: ${tempFile}`);
|
|
190
|
-
}
|
|
191
|
-
}
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
private async moderateText(path: string, buffer: Buffer): Promise<void> {
|
|
195
|
-
const text = buffer.toString('utf-8');
|
|
196
|
-
const result = await this.client.analyzeText(text);
|
|
197
|
-
|
|
198
|
-
const violations: string[] = [];
|
|
199
|
-
|
|
200
|
-
if (result.sexual > this.textSexualThreshold) {
|
|
201
|
-
violations.push(`sexual content (${result.sexual.toFixed(2)} > ${this.textSexualThreshold})`);
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
if (result.toxic > this.textToxicThreshold) {
|
|
205
|
-
violations.push(`toxic content (${result.toxic.toFixed(2)} > ${this.textToxicThreshold})`);
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
if (violations.length > 0) {
|
|
209
|
-
this.logger.warn(`Text BLOCKED: ${path} - ${violations.join(', ')}`);
|
|
210
|
-
throw new ForbiddenHttpError(`Text contains inappropriate content: ${violations.join(', ')}`);
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
this.logger.info(`Text APPROVED: ${path}`);
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
private getFileExtension(contentType: string): string {
|
|
217
|
-
const map: Record<string, string> = {
|
|
218
|
-
'image/jpeg': 'jpg',
|
|
219
|
-
'image/png': 'png',
|
|
220
|
-
'image/gif': 'gif',
|
|
221
|
-
'image/webp': 'webp',
|
|
222
|
-
'video/mp4': 'mp4',
|
|
223
|
-
'video/webm': 'webm',
|
|
224
|
-
};
|
|
225
|
-
return map[contentType] || 'bin';
|
|
226
|
-
}
|
|
227
|
-
}
|
package/src/ModerationStore.ts
DELETED
|
@@ -1,41 +0,0 @@
|
|
|
1
|
-
import { promises as fs } from 'node:fs';
|
|
2
|
-
import { join } from 'node:path';
|
|
3
|
-
import { getLoggerFor } from '@solid/community-server';
|
|
4
|
-
import type { ModerationRecord } from './ModerationRecord';
|
|
5
|
-
|
|
6
|
-
/**
|
|
7
|
-
* Service for storing moderation violation records.
|
|
8
|
-
*/
|
|
9
|
-
export class ModerationStore {
|
|
10
|
-
protected readonly logger = getLoggerFor(this);
|
|
11
|
-
|
|
12
|
-
private readonly storePath: string;
|
|
13
|
-
|
|
14
|
-
public constructor(storePath = './data/moderation-logs') {
|
|
15
|
-
this.storePath = storePath;
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
public async recordViolation(record: Omit<ModerationRecord, 'id' | 'timestamp'>): Promise<void> {
|
|
19
|
-
try {
|
|
20
|
-
await fs.mkdir(this.storePath, { recursive: true });
|
|
21
|
-
|
|
22
|
-
const fullRecord: ModerationRecord = {
|
|
23
|
-
...record,
|
|
24
|
-
id: this.generateId(),
|
|
25
|
-
timestamp: new Date(),
|
|
26
|
-
};
|
|
27
|
-
|
|
28
|
-
const filename = `${new Date().toISOString().split('T')[0]}.jsonl`;
|
|
29
|
-
const filepath = join(this.storePath, filename);
|
|
30
|
-
const logEntry = `${JSON.stringify(fullRecord)}\n`;
|
|
31
|
-
|
|
32
|
-
await fs.appendFile(filepath, logEntry);
|
|
33
|
-
} catch (error: unknown) {
|
|
34
|
-
this.logger.error(`Failed to record moderation violation: ${String(error)}`);
|
|
35
|
-
}
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
private generateId(): string {
|
|
39
|
-
return Date.now().toString(36) + Math.random().toString(36).slice(2);
|
|
40
|
-
}
|
|
41
|
-
}
|
|
@@ -1,101 +0,0 @@
|
|
|
1
|
-
import { getLoggerFor } from '@solid/community-server';
|
|
2
|
-
|
|
3
|
-
const logger = getLoggerFor('GuardedStream');
|
|
4
|
-
|
|
5
|
-
// Using symbols to make sure we don't override existing parameters
|
|
6
|
-
const guardedErrors = Symbol('guardedErrors');
|
|
7
|
-
const guardedTimeout = Symbol('guardedTimeout');
|
|
8
|
-
|
|
9
|
-
// Private fields for guarded streams
|
|
10
|
-
class Guard {
|
|
11
|
-
// Workaround for the fact that we don't initialize this variable as expected
|
|
12
|
-
declare private [guardedErrors]: Error[];
|
|
13
|
-
private [guardedTimeout]?: NodeJS.Timeout;
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
/**
|
|
17
|
-
* A stream that is guarded from emitting errors when there are no listeners.
|
|
18
|
-
* If an error occurs while no listener is attached,
|
|
19
|
-
* it will store the error and emit it once a listener is added (or a timeout occurs).
|
|
20
|
-
*/
|
|
21
|
-
export type Guarded<T extends NodeJS.EventEmitter = NodeJS.EventEmitter> = T & Guard;
|
|
22
|
-
|
|
23
|
-
/**
|
|
24
|
-
* Determines whether the stream is guarded against emitting errors.
|
|
25
|
-
*/
|
|
26
|
-
export function isGuarded<T extends NodeJS.EventEmitter>(stream: T): stream is Guarded<T> {
|
|
27
|
-
return typeof (stream as unknown as Guarded)[guardedErrors] === 'object';
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
/**
|
|
31
|
-
* Callback that is used when a stream emits an error and no other error listener is attached.
|
|
32
|
-
* Used to store the error and start the logger timer.
|
|
33
|
-
*
|
|
34
|
-
* It is important that this listener always remains attached for edge cases where an error listener gets removed
|
|
35
|
-
* and the number of error listeners is checked immediately afterwards.
|
|
36
|
-
* See https://github.com/CommunitySolidServer/CommunitySolidServer/pull/462#issuecomment-758013492 .
|
|
37
|
-
*/
|
|
38
|
-
function guardingErrorListener(this: Guarded, error: Error): void {
|
|
39
|
-
// Only fall back to this if no new listeners are attached since guarding started.
|
|
40
|
-
const errorListeners = this.listeners('error');
|
|
41
|
-
if (errorListeners.at(-1) === guardingErrorListener) {
|
|
42
|
-
this[guardedErrors].push(error);
|
|
43
|
-
if (!this[guardedTimeout]) {
|
|
44
|
-
this[guardedTimeout] = setTimeout((): void => {
|
|
45
|
-
logger.error(`No error listener was attached but error was thrown: ${error.message}`);
|
|
46
|
-
}, 1000);
|
|
47
|
-
}
|
|
48
|
-
}
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
/**
|
|
52
|
-
* Callback that is used when a new listener is attached and there are errors that were not emitted yet.
|
|
53
|
-
*/
|
|
54
|
-
function emitStoredErrors(this: Guarded, event: string, func: (error: Error) => void): void {
|
|
55
|
-
if (event === 'error' && func !== guardingErrorListener) {
|
|
56
|
-
// Cancel an error timeout
|
|
57
|
-
if (this[guardedTimeout]) {
|
|
58
|
-
clearTimeout(this[guardedTimeout]);
|
|
59
|
-
this[guardedTimeout] = undefined;
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
// Emit any errors that were guarded
|
|
63
|
-
const errors = this[guardedErrors];
|
|
64
|
-
if (errors.length > 0) {
|
|
65
|
-
this[guardedErrors] = [];
|
|
66
|
-
setImmediate((): void => {
|
|
67
|
-
for (const error of errors) {
|
|
68
|
-
this.emit('error', error);
|
|
69
|
-
}
|
|
70
|
-
});
|
|
71
|
-
}
|
|
72
|
-
}
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
/**
|
|
76
|
-
* Makes sure that listeners always receive the error event of a stream,
|
|
77
|
-
* even if it was thrown before the listener was attached.
|
|
78
|
-
*
|
|
79
|
-
* When guarding a stream it is assumed that error listeners already attached should be ignored,
|
|
80
|
-
* only error listeners attached after the stream is guarded will prevent an error from being logged.
|
|
81
|
-
*
|
|
82
|
-
* If the input is already guarded the guard will be reset,
|
|
83
|
-
* which means ignoring error listeners already attached.
|
|
84
|
-
*
|
|
85
|
-
* @param stream - Stream that can potentially throw an error.
|
|
86
|
-
*
|
|
87
|
-
* @returns The stream.
|
|
88
|
-
*/
|
|
89
|
-
export function guardStream<T extends NodeJS.EventEmitter>(stream: T): Guarded<T> {
|
|
90
|
-
const guarded = stream as Guarded<T>;
|
|
91
|
-
if (isGuarded(stream)) {
|
|
92
|
-
// This makes sure the guarding error listener is the last one in the list again
|
|
93
|
-
guarded.removeListener('error', guardingErrorListener);
|
|
94
|
-
guarded.on('error', guardingErrorListener);
|
|
95
|
-
} else {
|
|
96
|
-
guarded[guardedErrors] = [];
|
|
97
|
-
guarded.on('error', guardingErrorListener);
|
|
98
|
-
guarded.on('newListener', emitStoredErrors);
|
|
99
|
-
}
|
|
100
|
-
return guarded;
|
|
101
|
-
}
|