Skip to content

Commit f4361a5

Browse files
committed
feat(services/flipcash): add moderation service
Signed-off-by: Brandon McAnsh <git@bmcreations.dev>
1 parent 4a9829d commit f4361a5

14 files changed

Lines changed: 434 additions & 6 deletions

File tree

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
syntax = "proto3";
2+
package flipcash.moderation.v1;
3+
option go_package = "github.com/code-payments/flipcash2-protobuf-api/generated/go/moderation/v1;moderationpb";
4+
option java_package = "com.codeinc.flipcash.gen.moderation.v1";
5+
option objc_class_prefix = "FPBModerationV1";
6+
import "common/v1/common.proto";
7+
import "google/protobuf/timestamp.proto";
8+
9+
service Moderation {
10+
// ModerateText checks text content against moderation policies
11+
rpc ModerateText(ModerateTextRequest) returns (ModerateTextResponse);
12+
// ModerateImage checks image content against moderation policies
13+
rpc ModerateImage(ModerateImageRequest) returns (ModerateImageResponse);
14+
}
15+
message ModerateTextRequest {
16+
// The text content to moderate
17+
string text = 1 ;
18+
common.v1.Auth auth = 2;
19+
}
20+
message ModerateTextResponse {
21+
Result result = 1;
22+
enum Result {
23+
OK = 0;
24+
DENIED = 1;
25+
}
26+
// Whether the text content is allowed
27+
bool is_allowed = 2;
28+
// Signed attestation of the moderation result when content is allowed
29+
ModerationAttestation attestation = 3;
30+
// The best fit flagged category when content is not allowed
31+
FlaggedCategory flagged_category = 4;
32+
}
33+
message ModerateImageRequest {
34+
// The raw image data to moderate
35+
bytes image_data = 1 ;
36+
common.v1.Auth auth = 2;
37+
}
38+
message ModerateImageResponse {
39+
Result result = 1;
40+
enum Result {
41+
OK = 0;
42+
DENIED = 1;
43+
UNSUPPORTED_FORMAT = 2;
44+
}
45+
// Whether the image content is allowed
46+
bool is_allowed = 2;
47+
// Signed attestation of the moderation result when content is allowed
48+
ModerationAttestation attestation = 3;
49+
// The best fit flagged category when content is not allowed
50+
FlaggedCategory flagged_category = 4;
51+
}
52+
// ModerationAttestation is a signed proof of the moderation result.
53+
// The signature is computed over this message without the signature field set.
54+
message ModerationAttestation {
55+
// SHA-256 hash of the moderated content to be allowed
56+
bytes content_hash = 1;
57+
// Timestamp of the moderation
58+
google.protobuf.Timestamp timestamp = 2;
59+
// The user who submitted the content
60+
common.v1.UserId user_id = 3;
61+
// Public key of the attestor that signed this message
62+
common.v1.PublicKey attestor = 4;
63+
// Attestor signature over this message
64+
common.v1.Signature signature = 5;
65+
}
66+
enum FlaggedCategory {
67+
NONE = 0;
68+
OTHER = 1; // Fallback category when flagged content does not fit into a well-defined FlaggedCategory
69+
NSFW = 2;
70+
IMPERSONATION = 3;
71+
MISLEADING = 4;
72+
SPAM = 5;
73+
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
package com.flipcash.services.controllers
2+
3+
import android.net.Uri
4+
import com.flipcash.services.models.ModerationResult
5+
import com.flipcash.services.repository.ModerationRepository
6+
import com.flipcash.services.user.UserManager
7+
import com.getcode.util.resources.ContentReader
8+
import javax.inject.Inject
9+
10+
class ModerationController @Inject constructor(
11+
private val repository: ModerationRepository,
12+
private val userManager: UserManager,
13+
private val contentReader: ContentReader,
14+
) {
15+
suspend fun moderateText(text: String): Result<ModerationResult.Text> {
16+
val owner = userManager.accountCluster?.authority?.keyPair
17+
?: return Result.failure(Throwable("No account cluster in UserManager"))
18+
19+
return repository.moderateText(text, owner)
20+
}
21+
22+
suspend fun moderateImage(uri: Uri): Result<ModerationResult.Image> {
23+
val owner = userManager.accountCluster?.authority?.keyPair
24+
?: return Result.failure(Throwable("No account cluster in UserManager"))
25+
val imageBytes = contentReader.readBytes(uri)
26+
?: return Result.failure(Throwable("Failed to retrieve bytes from image @ $uri"))
27+
28+
return repository.moderateImage(imageBytes, owner)
29+
}
30+
}

services/flipcash/src/main/kotlin/com/flipcash/services/inject/FlipcashModule.kt

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,15 @@ import android.content.Context
44
import com.flipcash.services.internal.annotations.FlipcashManagedChannel
55
import com.flipcash.services.internal.annotations.FlipcashProtocol
66
import com.flipcash.services.internal.domain.ActivityFeedMessageMapper
7+
import com.flipcash.services.internal.domain.ImageModerationResponseMapper
78
import com.flipcash.services.internal.domain.UserFlagsMapper
89
import com.flipcash.services.internal.domain.SocialAccountMapper
10+
import com.flipcash.services.internal.domain.TextModerationResponseMapper
911
import com.flipcash.services.internal.domain.UserProfileMapper
1012
import com.flipcash.services.internal.network.services.AccountService
1113
import com.flipcash.services.internal.network.services.ActivityFeedService
1214
import com.flipcash.services.internal.network.services.EmailVerificationService
15+
import com.flipcash.services.internal.network.services.ModerationService
1316
import com.flipcash.services.internal.network.services.PhoneVerificationService
1417
import com.flipcash.services.internal.network.services.ProfileService
1518
import com.flipcash.services.internal.network.services.PurchaseService
@@ -19,6 +22,7 @@ import com.flipcash.services.internal.network.services.ThirdPartyService
1922
import com.flipcash.services.internal.repositories.InternalAccountRepository
2023
import com.flipcash.services.internal.repositories.InternalActivityFeedRepository
2124
import com.flipcash.services.internal.repositories.InternalContactVerificationRepository
25+
import com.flipcash.services.internal.repositories.InternalModerationRepository
2226
import com.flipcash.services.internal.repositories.InternalProfileRepository
2327
import com.flipcash.services.internal.repositories.InternalPurchaseRepository
2428
import com.flipcash.services.internal.repositories.InternalPushRepository
@@ -27,6 +31,7 @@ import com.flipcash.services.internal.repositories.InternalThirdPartyRepository
2731
import com.flipcash.services.repository.AccountRepository
2832
import com.flipcash.services.repository.ActivityFeedRepository
2933
import com.flipcash.services.repository.ContactVerificationRepository
34+
import com.flipcash.services.repository.ModerationRepository
3035
import com.flipcash.services.repository.ProfileRepository
3136
import com.flipcash.services.repository.PurchaseRepository
3237
import com.flipcash.services.repository.PushRepository
@@ -58,12 +63,13 @@ internal object FlipcashModule {
5863
fun providesFlipcashProtocolConfig(
5964
@ApplicationContext context: Context
6065
): ProtocolConfig {
61-
return object: ProtocolConfig {
66+
return object : ProtocolConfig {
6267
override val baseUrl: String
6368
get() = "fc-v2.api.flipcash-infra.net"
6469
override val userAgent: String
6570
get() {
66-
val version = context.packageManager.getPackageInfo(context.packageName, 0).versionName
71+
val version =
72+
context.packageManager.getPackageInfo(context.packageName, 0).versionName
6773
return "Flipcash/Core/Android/$version"
6874
}
6975
}
@@ -143,14 +149,25 @@ internal object FlipcashModule {
143149
internal fun providesContactVerificationRepository(
144150
emailService: EmailVerificationService,
145151
phoneService: PhoneVerificationService,
146-
): ContactVerificationRepository = InternalContactVerificationRepository(emailService, phoneService)
152+
): ContactVerificationRepository =
153+
InternalContactVerificationRepository(emailService, phoneService)
147154

148155
@Provides
149156
internal fun providesProfileRepository(
150157
service: ProfileService,
151158
userProfileMapper: UserProfileMapper,
152159
socialAccountMapper: SocialAccountMapper,
153-
): ProfileRepository = InternalProfileRepository(service, userProfileMapper, socialAccountMapper)
154-
160+
): ProfileRepository =
161+
InternalProfileRepository(service, userProfileMapper, socialAccountMapper)
155162

163+
@Provides
164+
internal fun providesModerationRepository(
165+
service: ModerationService,
166+
textModerationResponseMapper: TextModerationResponseMapper,
167+
imageModerationResponseMapper: ImageModerationResponseMapper,
168+
): ModerationRepository = InternalModerationRepository(
169+
service,
170+
textModerationResponseMapper,
171+
imageModerationResponseMapper
172+
)
156173
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
package com.flipcash.services.internal.domain
2+
3+
import com.codeinc.flipcash.gen.moderation.v1.ModerationService
4+
import com.flipcash.services.internal.domain.mapper.Mapper
5+
import com.flipcash.services.internal.model.moderation.ModerationRequest
6+
import com.flipcash.services.models.ModerationResult
7+
import javax.inject.Inject
8+
9+
class ImageModerationResponseMapper @Inject constructor(
10+
private val attestationMapper: ModerationAttestationMapper,
11+
): Mapper<ModerationRequest.Image, ModerationResult.Image> {
12+
override fun map(from: ModerationRequest.Image): ModerationResult.Image {
13+
val (image, response) = from
14+
return ModerationResult.Image(
15+
image = image.toList(),
16+
isAllowed = response.isAllowed,
17+
attestation = attestationMapper.map(response.attestation),
18+
flaggedCategory = ModerationResult.FlaggedCategory.valueOf(response.flaggedCategory.name)
19+
)
20+
}
21+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
package com.flipcash.services.internal.domain
2+
3+
import com.codeinc.flipcash.gen.moderation.v1.ModerationService
4+
import com.flipcash.services.internal.network.extensions.toId
5+
import com.flipcash.services.internal.network.extensions.toPublicKey
6+
import com.flipcash.services.internal.network.extensions.toSignature
7+
import com.flipcash.services.models.ModerationResult
8+
import com.getcode.crypt.Sha256Hash
9+
import com.getcode.opencode.mapper.Mapper
10+
import javax.inject.Inject
11+
import kotlin.time.Instant
12+
13+
class ModerationAttestationMapper @Inject constructor(): Mapper<ModerationService.ModerationAttestation, ModerationResult.Attestation> {
14+
override fun map(from: ModerationService.ModerationAttestation): ModerationResult.Attestation {
15+
return ModerationResult.Attestation(
16+
hash = Sha256Hash.of(from.contentHash.toByteArray()),
17+
timestamp = Instant.fromEpochSeconds(from.timestamp.seconds * 1000L),
18+
userId = from.userId.toId(),
19+
attestor = from.attestor.toPublicKey(),
20+
signature = from.signature.toSignature()
21+
)
22+
}
23+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
package com.flipcash.services.internal.domain
2+
3+
import com.flipcash.services.internal.domain.mapper.Mapper
4+
import com.flipcash.services.internal.model.moderation.ModerationRequest
5+
import com.flipcash.services.models.ModerationResult
6+
import javax.inject.Inject
7+
8+
class TextModerationResponseMapper @Inject constructor(
9+
private val attestationMapper: ModerationAttestationMapper,
10+
): Mapper<ModerationRequest.Text, ModerationResult.Text> {
11+
override fun map(from: ModerationRequest.Text): ModerationResult.Text {
12+
val (text, response) = from
13+
return ModerationResult.Text(
14+
text = text,
15+
isAllowed = response.isAllowed,
16+
attestation = attestationMapper.map(response.attestation),
17+
flaggedCategory = ModerationResult.FlaggedCategory.valueOf(response.flaggedCategory.name)
18+
)
19+
}
20+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
package com.flipcash.services.internal.model.moderation
2+
3+
import com.codeinc.flipcash.gen.moderation.v1.ModerationService
4+
5+
interface ModerationRequest {
6+
data class Text(val text: String, val response: ModerationService.ModerateTextResponse): ModerationRequest
7+
data class Image(val imageBytes: ByteArray, val response: ModerationService.ModerateImageResponse): ModerationRequest {
8+
override fun equals(other: Any?): Boolean {
9+
if (this === other) return true
10+
if (javaClass != other?.javaClass) return false
11+
12+
other as Image
13+
14+
if (!imageBytes.contentEquals(other.imageBytes)) return false
15+
if (response != other.response) return false
16+
17+
return true
18+
}
19+
20+
override fun hashCode(): Int {
21+
var result = imageBytes.contentHashCode()
22+
result = 31 * result + response.hashCode()
23+
return result
24+
}
25+
}
26+
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
package com.flipcash.services.internal.network.api
2+
3+
import com.codeinc.flipcash.gen.moderation.v1.ModerationGrpcKt
4+
import com.codeinc.flipcash.gen.moderation.v1.ModerationService
5+
import com.flipcash.services.internal.annotations.FlipcashManagedChannel
6+
import com.flipcash.services.internal.network.extensions.authenticate
7+
import com.getcode.ed25519.Ed25519
8+
import com.getcode.opencode.internal.network.core.GrpcApi
9+
import com.getcode.utils.toByteString
10+
import io.grpc.ManagedChannel
11+
import kotlinx.coroutines.Dispatchers
12+
import kotlinx.coroutines.withContext
13+
import javax.inject.Inject
14+
import javax.inject.Singleton
15+
16+
@Singleton
17+
internal class ModerationApi @Inject constructor(
18+
@FlipcashManagedChannel
19+
managedChannel: ManagedChannel,
20+
) : GrpcApi(managedChannel) {
21+
private val api = ModerationGrpcKt.ModerationCoroutineStub(managedChannel)
22+
.withWaitForReady()
23+
24+
suspend fun moderateText(text: String, owner: Ed25519.KeyPair): ModerationService.ModerateTextResponse {
25+
val request = ModerationService.ModerateTextRequest.newBuilder()
26+
.setText(text)
27+
.apply { setAuth(authenticate(owner)) }
28+
.build()
29+
30+
return withContext(Dispatchers.IO) {
31+
api.moderateText(request)
32+
}
33+
}
34+
35+
suspend fun moderateImage(imageBytes: ByteArray, owner: Ed25519.KeyPair): ModerationService.ModerateImageResponse {
36+
val request = ModerationService.ModerateImageRequest.newBuilder()
37+
.setImageData(imageBytes.toByteString())
38+
.apply { setAuth(authenticate(owner)) }
39+
.build()
40+
41+
return withContext(Dispatchers.IO) {
42+
api.moderateImage(request)
43+
}
44+
}
45+
}

services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/extensions/ProtobufToLocal.kt

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package com.flipcash.services.internal.network.extensions
33

44
import com.codeinc.flipcash.gen.common.v1.Common
55
import com.codeinc.flipcash.gen.common.v1.Common.UserId
6+
import com.codeinc.flipcash.gen.moderation.v1.ModerationService
67
import com.codeinc.flipcash.gen.push.v1.navigationOrNull
78
import com.codeinc.flipcash.gen.push.v1.Model as PushModels
89
import com.flipcash.services.internal.extensions.toMint
@@ -12,10 +13,11 @@ import com.flipcash.services.models.NotificationPayload
1213
import com.getcode.opencode.model.core.ID
1314
import com.getcode.solana.keys.Mint
1415
import com.getcode.solana.keys.PublicKey
16+
import com.getcode.solana.keys.Signature
1517
import com.codeinc.flipcash.gen.activity.v1.Model as ActivityModels
1618

1719
internal fun ActivityModels.NotificationId.toId(): ID = value.toByteArray().toList()
18-
internal fun UserId.toId(): ID = value.toByteArray().toList()
20+
internal fun Common.UserId.toId(): ID = value.toByteArray().toList()
1921
internal fun Common.PublicKey.toPublicKey(): PublicKey = value.toByteArray().toPublicKey()
2022
internal fun Common.PublicKey.toMint(): Mint = value.toByteArray().toMint()
2123

@@ -33,4 +35,8 @@ internal fun PushModels.Payload.asPayload(): NotificationPayload {
3335
return NotificationPayload(
3436
navigation = navigationTrigger
3537
)
38+
}
39+
40+
internal fun Common.Signature.toSignature(): Signature {
41+
return Signature(value.toByteArray().toList())
3642
}

0 commit comments

Comments
 (0)