@kkuffour/solid-moderation-plugin 0.1.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/CONFIG-GUIDE.md +49 -0
- package/DEVELOPMENT.md +129 -0
- package/ENV-VARIABLES.md +137 -0
- package/INSTALLATION.md +90 -0
- package/LICENSE +21 -0
- package/MIGRATION.md +81 -0
- package/PRODUCTION.md +186 -0
- package/PUBLISHING.md +104 -0
- package/README.md +53 -0
- package/TESTING.md +64 -0
- package/components/components.jsonld +17 -0
- package/components/context.jsonld +211 -0
- package/config/context.jsonld +15 -0
- package/config/default.json +80 -0
- package/dist/ModerationConfig.d.ts +16 -0
- package/dist/ModerationConfig.d.ts.map +1 -0
- package/dist/ModerationConfig.js +18 -0
- package/dist/ModerationConfig.js.map +1 -0
- package/dist/ModerationConfig.jsonld +66 -0
- package/dist/ModerationMixin.d.ts +13 -0
- package/dist/ModerationMixin.d.ts.map +1 -0
- package/dist/ModerationMixin.js +136 -0
- package/dist/ModerationMixin.js.map +1 -0
- package/dist/ModerationMixin.jsonld +180 -0
- package/dist/ModerationOperationHandler.d.ts +16 -0
- package/dist/ModerationOperationHandler.d.ts.map +1 -0
- package/dist/ModerationOperationHandler.js +45 -0
- package/dist/ModerationOperationHandler.js.map +1 -0
- package/dist/ModerationOperationHandler.jsonld +140 -0
- package/dist/ModerationRecord.d.ts +20 -0
- package/dist/ModerationRecord.d.ts.map +1 -0
- package/dist/ModerationRecord.js +3 -0
- package/dist/ModerationRecord.js.map +1 -0
- package/dist/ModerationRecord.jsonld +59 -0
- package/dist/ModerationStore.d.ts +12 -0
- package/dist/ModerationStore.d.ts.map +1 -0
- package/dist/ModerationStore.js +37 -0
- package/dist/ModerationStore.js.map +1 -0
- package/dist/ModerationStore.jsonld +59 -0
- package/dist/components/components.jsonld +17 -0
- package/dist/components/context.jsonld +211 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +23 -0
- package/dist/index.js.map +1 -0
- package/dist/providers/SightEngineProvider.d.ts +52 -0
- package/dist/providers/SightEngineProvider.d.ts.map +1 -0
- package/dist/providers/SightEngineProvider.js +302 -0
- package/dist/providers/SightEngineProvider.js.map +1 -0
- package/dist/providers/SightEngineProvider.jsonld +209 -0
- package/dist/util/GuardedStream.d.ts +33 -0
- package/dist/util/GuardedStream.d.ts.map +1 -0
- package/dist/util/GuardedStream.js +89 -0
- package/dist/util/GuardedStream.js.map +1 -0
- package/package.json +40 -0
- package/simple-test.json +7 -0
- package/src/ModerationConfig.ts +29 -0
- package/src/ModerationMixin.ts +153 -0
- package/src/ModerationOperationHandler.ts +64 -0
- package/src/ModerationRecord.ts +19 -0
- package/src/ModerationStore.ts +41 -0
- package/src/index.ts +6 -0
- package/src/providers/SightEngineProvider.ts +367 -0
- package/src/util/GuardedStream.ts +101 -0
- package/tsconfig.json +20 -0
package/package.json
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@kkuffour/solid-moderation-plugin",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Content moderation plugin for Community Solid Server",
|
|
5
|
+
"main": "dist/index.js",
|
|
6
|
+
"types": "dist/index.d.ts",
|
|
7
|
+
"lsd:module": "https://linkedsoftwaredependencies.org/bundles/npm/@kkuffour/solid-moderation-plugin",
|
|
8
|
+
"lsd:components": "components/components.jsonld",
|
|
9
|
+
"lsd:contexts": {
|
|
10
|
+
"https://linkedsoftwaredependencies.org/bundles/npm/@kkuffour/solid-moderation-plugin/^0.1.0/components/context.jsonld": "components/context.jsonld",
|
|
11
|
+
"https://linkedsoftwaredependencies.org/bundles/npm/@kkuffour/solid-moderation-plugin/^0.1.0/config/context.jsonld": "config/context.jsonld"
|
|
12
|
+
},
|
|
13
|
+
"lsd:importPaths": {
|
|
14
|
+
"https://linkedsoftwaredependencies.org/bundles/npm/@solid/moderation-plugin/^1.0.0/components/": "dist/",
|
|
15
|
+
"https://linkedsoftwaredependencies.org/bundles/npm/@solid/moderation-plugin/^1.0.0/config/": "config/",
|
|
16
|
+
"https://linkedsoftwaredependencies.org/bundles/npm/@solid/moderation-plugin/^1.0.0/dist/": "dist/"
|
|
17
|
+
},
|
|
18
|
+
"scripts": {
|
|
19
|
+
"build": "npm run build:ts && npm run build:components",
|
|
20
|
+
"build:ts": "tsc",
|
|
21
|
+
"build:components": "componentsjs-generator",
|
|
22
|
+
"build:copy": "mkdir -p dist/components && cp components/*.jsonld dist/components/",
|
|
23
|
+
"test": "jest"
|
|
24
|
+
},
|
|
25
|
+
"keywords": ["solid", "moderation", "content-moderation", "community-solid-server"],
|
|
26
|
+
"author": "",
|
|
27
|
+
"license": "MIT",
|
|
28
|
+
"peerDependencies": {
|
|
29
|
+
"@solid/community-server": "^7.0.0"
|
|
30
|
+
},
|
|
31
|
+
"dependencies": {
|
|
32
|
+
"form-data": "^4.0.0"
|
|
33
|
+
},
|
|
34
|
+
"devDependencies": {
|
|
35
|
+
"@solid/community-server": "^7.0.0",
|
|
36
|
+
"componentsjs-generator": "^3.1.0",
|
|
37
|
+
"typescript": "^5.0.0",
|
|
38
|
+
"@types/node": "^20.0.0"
|
|
39
|
+
}
|
|
40
|
+
}
|
package/simple-test.json
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export interface ModerationConfig {
|
|
2
|
+
enabled: boolean;
|
|
3
|
+
auditLoggingEnabled: boolean;
|
|
4
|
+
auditLoggingStorePath: string;
|
|
5
|
+
sightEngineApiUser: string;
|
|
6
|
+
sightEngineApiSecret: string;
|
|
7
|
+
imagesEnabled: boolean;
|
|
8
|
+
textEnabled: boolean;
|
|
9
|
+
videoEnabled: boolean;
|
|
10
|
+
imageNudityThreshold: number;
|
|
11
|
+
textSexualThreshold: number;
|
|
12
|
+
textToxicThreshold: number;
|
|
13
|
+
videoNudityThreshold: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const DEFAULT_MODERATION_CONFIG: ModerationConfig = {
|
|
17
|
+
enabled: true,
|
|
18
|
+
auditLoggingEnabled: true,
|
|
19
|
+
auditLoggingStorePath: './data/moderation-logs',
|
|
20
|
+
sightEngineApiUser: '',
|
|
21
|
+
sightEngineApiSecret: '',
|
|
22
|
+
imagesEnabled: true,
|
|
23
|
+
textEnabled: true,
|
|
24
|
+
videoEnabled: true,
|
|
25
|
+
imageNudityThreshold: 0.5,
|
|
26
|
+
textSexualThreshold: 0.5,
|
|
27
|
+
textToxicThreshold: 0.5,
|
|
28
|
+
videoNudityThreshold: 0.5,
|
|
29
|
+
};
|
|
@@ -0,0 +1,153 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
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
|
+
}
|
package/src/index.ts
ADDED