Skip to content

Commit eddbf08

Browse files
James Youngbloodmarkharding
authored andcommitted
[#96] Use PhotoDNA in Moderation Service
Changelog: feature
1 parent ec4edc3 commit eddbf08

19 files changed

Lines changed: 1274 additions & 32 deletions

File tree

.gitlab-ci.yml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,15 +11,16 @@ include:
1111
- local: '.gitlab/ci/deploy_services.yml'
1212
- local: '.gitlab/ci/deploy_pages.yml'
1313
- local: '.gitlab/ci/scan_dependencies.yml'
14+
- local: '.gitlab/ci/test_moderation.yml'
1415
- local: '.gitlab/ci/release.yml'
1516
stages:
1617
- setup
1718
- check
1819
- build
20+
- test
1921
- publish
2022
- deploy
2123
- release
22-
- test
2324
variables:
2425
CARGO_HOME: ${CI_PROJECT_DIR}/.cargo
2526
CARGO_TARGET_DIR: ${CI_PROJECT_DIR}/target
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
#!/usr/bin/env bash
2+
#
3+
# Runs the server stack with Docker, then runs the moderation service
4+
# end-to-end CSAM test using cargo.
5+
#
6+
# Env:
7+
# KEEP_STACK=1 leave the Docker stack running on exit (default: tear down)
8+
set -euo pipefail
9+
10+
cd "$(dirname "$0")/../../.."
11+
12+
# A fixed project name keeps the network name (<project>_default) independent
13+
# of the checkout directory, for both the connect below and teardown.
14+
export COMPOSE_PROJECT_NAME=polycentric
15+
NETWORK="${COMPOSE_PROJECT_NAME}_default"
16+
17+
# Host:port the readiness probe waits on (and, locally, the test connects to).
18+
SERVER_HOST=localhost
19+
SERVER_PORT=3000
20+
21+
# The job's own container ID. GitLab sets the build container's hostname to a
22+
# short predefined name (runner-…-concurrent-N) that the daemon does not know
23+
# it by, so `docker network connect <hostname>` fails. Recover the real 64-hex
24+
# ID from the bind mounts Docker sets up for /etc/hostname, /etc/hosts, etc.
25+
# (sourced from /var/lib/docker/containers/<id>/…), falling back to hostname.
26+
self_container() {
27+
local id
28+
id=$(grep -oE 'containers/[0-9a-f]{64}' /proc/self/mountinfo | head -1 | cut -d/ -f2)
29+
echo "${id:-$(cat /etc/hostname)}"
30+
}
31+
32+
cleanup() {
33+
if [[ "${CI:-}" == "true" ]]; then
34+
docker network disconnect "$NETWORK" "$(self_container)" >/dev/null 2>&1 || true
35+
fi
36+
if [[ "${KEEP_STACK:-0}" != "1" ]]; then
37+
docker compose down -v >/dev/null 2>&1 || true
38+
fi
39+
}
40+
trap cleanup EXIT
41+
42+
echo "==> Bringing up the server stack…"
43+
docker compose up -d --build --wait postgres rustfs kafka server
44+
45+
if [[ "${CI:-}" == "true" ]]; then
46+
echo "==> Joining the job container to the stack network ($NETWORK)…"
47+
docker network connect "$NETWORK" "$(self_container)"
48+
# Reach the services by their in-network names instead of localhost. Kafka
49+
# is reached on its INTERNAL listener, which advertises kafka:19092 on this
50+
# network (the EXTERNAL listener advertises localhost:9092, for local use).
51+
SERVER_HOST=server
52+
export POLYCENTRIC_TEST_SERVER="http://server:3000"
53+
export POLYCENTRIC_TEST_DATABASE_URL="postgres://postgres:testing@postgres:5432"
54+
export POLYCENTRIC_TEST_OS_ENDPOINT="http://rustfs:9000"
55+
export POLYCENTRIC_TEST_KAFKA_BROKERS="kafka:19092"
56+
fi
57+
58+
echo "==> Waiting for the server gRPC port ($SERVER_HOST:$SERVER_PORT)…"
59+
for _ in $(seq 1 60); do
60+
if (exec 3<>"/dev/tcp/$SERVER_HOST/$SERVER_PORT") 2>/dev/null; then
61+
exec 3>&- 3<&-
62+
echo " server is accepting connections"
63+
break
64+
fi
65+
sleep 1
66+
done
67+
68+
echo "==> Applying server database migrations…"
69+
docker compose exec -T server /app/migration up
70+
71+
echo "==> Running the moderation CSAM pipeline test…"
72+
cargo test -p moderation-service --test csam_pipeline -- --ignored --nocapture

.gitlab/ci/setup_images.yml

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
variables:
22
RUST_IMAGE: '${CI_REGISTRY}/${CI_PROJECT_PATH}:rust'
3+
RUST_DIND_IMAGE: '${CI_REGISTRY}/${CI_PROJECT_PATH}:rust-dind'
34

45
.setup-images-workflow:
56
stage: setup
@@ -32,3 +33,17 @@ setup-image-rust:
3233
--import-cache type=registry,ref=$RUST_IMAGE \
3334
--export-cache type=inline \
3435
--output type=image,name=$RUST_IMAGE,push=true
36+
37+
setup-image-rust-dind:
38+
extends: .setup-images-workflow
39+
needs: ['setup-image-rust']
40+
script:
41+
- |
42+
buildctl-daemonless.sh build \
43+
--frontend dockerfile.v0 \
44+
--opt build-arg:RUST_IMAGE=$RUST_IMAGE \
45+
--local context=.gitlab/images/rust-dind \
46+
--local dockerfile=.gitlab/images/rust-dind \
47+
--import-cache type=registry,ref=$RUST_DIND_IMAGE \
48+
--export-cache type=inline \
49+
--output type=image,name=$RUST_DIND_IMAGE,push=true

.gitlab/ci/test_moderation.yml

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
########################################################################
2+
# moderation — integration tests
3+
#
4+
# End-to-end CSAM pipeline test verifying that the moderation service
5+
# purges and reports PhotoDNA CSAM matches. Uses a mock PhotoDNA
6+
# endpoint which always returns a match.
7+
########################################################################
8+
9+
.moderation-integration-workflow:
10+
image: $RUST_DIND_IMAGE
11+
stage: test
12+
rules:
13+
- if: $CI_COMMIT_TAG =~ /^moderation-/
14+
when: always
15+
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
16+
changes:
17+
- services/moderation/**/*
18+
- services/server/**/*
19+
- compose.yml
20+
- .gitlab/ci/test_moderation.yml
21+
- .gitlab/ci/scripts/integration-moderation.sh
22+
- if: $CI_COMMIT_BRANCH && $CI_OPEN_MERGE_REQUESTS
23+
when: never
24+
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
25+
changes:
26+
- services/moderation/**/*
27+
- services/server/**/*
28+
- compose.yml
29+
- .gitlab/ci/test_moderation.yml
30+
- .gitlab/ci/scripts/integration-moderation.sh
31+
variables:
32+
RUNNER_SCRIPT_TIMEOUT: 30m
33+
34+
moderation-integration-csam:
35+
extends: .moderation-integration-workflow
36+
script:
37+
- .gitlab/ci/scripts/integration-moderation.sh
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
ARG RUST_IMAGE
2+
3+
# Debian bookworm (the rust base) has no Compose v2 plugin in its repos, so
4+
# we copy the binaries from the official docker image, pinned by tag.
5+
FROM docker:28-cli AS docker
6+
FROM ${RUST_IMAGE}
7+
8+
COPY --from=docker /usr/local/bin/docker /usr/local/bin/docker
9+
COPY --from=docker /usr/local/libexec/docker/cli-plugins/docker-compose /usr/local/libexec/docker/cli-plugins/docker-compose
10+
11+
# Fail the build if the CLI can't find the compose plugin.
12+
RUN docker compose version

compose.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,11 +28,16 @@ services:
2828
- CONTENT_BLOB_OS_FORCE_PATH_STYLE=true
2929
- CONTENT_BLOB_OS_ACCESS_KEY=rustfsadmin
3030
- CONTENT_BLOB_OS_SECRET_KEY=rustfsadmin
31+
# In-network listener: the default (localhost:9092) points at the server
32+
# container itself, making Kafka unreachable
33+
- POLYCENTRIC_KAFKA_BROKERS=kafka:19092
3134
depends_on:
3235
postgres:
3336
condition: service_healthy
3437
rustfs-init:
3538
condition: service_completed_successfully
39+
kafka:
40+
condition: service_started
3641

3742
rustfs:
3843
image: rustfs/rustfs:latest

docs/content/protocol/data-model.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,7 @@ message Content {
221221
ProfileUpdate profile_update = 7;
222222
Identity identity = 8;
223223
Repost repost = 9;
224+
Report report = 10;
224225
}
225226
}
226227
@@ -298,6 +299,33 @@ message ProfileUpdate {
298299
}
299300
```
300301

302+
### Report
303+
304+
A `Report` flags another event for a category of policy violation. Reports are
305+
themselves signed events, so a server can record who reported what and respond
306+
according to its own moderation policy (moderation is per-server). `additional_info`
307+
carries optional free-text context.
308+
309+
```protobuf
310+
message Report {
311+
// Event being reported
312+
EventKey event_key = 1;
313+
ReportCategory category = 2;
314+
string additional_info = 3;
315+
}
316+
317+
enum ReportCategory {
318+
REPORT_CATEGORY_UNSPECIFIED = 0;
319+
REPORT_CATEGORY_SPAM = 1;
320+
REPORT_CATEGORY_ABUSE = 2;
321+
REPORT_CATEGORY_CHILD_SAFETY = 3;
322+
REPORT_CATEGORY_TERRORISM = 4;
323+
REPORT_CATEGORY_ILLEGAL = 5;
324+
REPORT_CATEGORY_COPYRIGHT = 6;
325+
REPORT_CATEGORY_SERVER_POLICY = 7;
326+
}
327+
```
328+
301329
### Blob, Image, ImageSet
302330

303331
Media is stored as blobs in object storage and referenced by digest. Images may carry

docs/content/running-a-server.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,26 @@ Most traffic is gRPC, but a few plain-HTTP routes are served directly:
133133

134134
See [Protocol → gRPC](/protocol/grpc) for the gRPC services.
135135

136+
## Content moderation & removal
137+
138+
Moderation on Polycentric is per-server: each server decides what it serves and
139+
curates its own discovery feeds, while signed content stays reachable from other
140+
servers.
141+
142+
The server stores blob bodies (images and other media) in the object store, and
143+
never truly deletes them. A [`Delete`](/protocol/data-model#delete) event
144+
acts as a *tombstone*: clients stop showing the content, but the blob bytes
145+
remain in the object store.
146+
147+
Removing blob bodies can be done by a separate moderation service. FUTO runs
148+
one, and you can
149+
[run your own](https://gitlab.futo.org/polycentric/polycentric/-/blob/develop/services/moderation/README.md).
150+
It scans images (for example, matching against PhotoDNA to detect known CSAM),
151+
deletes matching blobs directly from the object store, and publishes a
152+
[`Report`](/protocol/data-model#report) event recording the violation. Because it
153+
deletes objects, the moderation service's `CONTENT_BLOB_OS_*` credentials need
154+
delete permission on the bucket, while the server's own credentials do not.
155+
136156
## TLS and production
137157

138158
The server speaks cleartext h2c and HTTP; it has no built-in TLS. For a public

packages/rs-common/src/models/collections.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,4 +7,5 @@ pub const FEED: i32 = 2;
77
pub const PROFILE: i32 = 3;
88
pub const INTERACTIONS: i32 = 4;
99
pub const SOCIAL_GRAPH: i32 = 5;
10+
pub const REPORTS: i32 = 6;
1011
pub const LABELS: i32 = 7;

services/common/object-store/src/lib.rs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,19 @@ impl ObjectStore {
125125
Ok(())
126126
}
127127

128+
/// Delete a blob by its digest. Deleting an absent blob is a no-op.
129+
pub async fn delete_blob(&self, digest: &ContentDigest) -> io::Result<()> {
130+
let key = blob_key(digest);
131+
self.client
132+
.delete_object()
133+
.bucket(&self.bucket)
134+
.key(key)
135+
.send()
136+
.await
137+
.map_err(sdk_err)?;
138+
Ok(())
139+
}
140+
128141
/// Read a blob body by its digest. Returns `NotFound` when the
129142
/// object does not exist in the bucket.
130143
pub async fn read_blob(&self, digest: &ContentDigest) -> io::Result<Vec<u8>> {

0 commit comments

Comments
 (0)