diff --git a/API.md b/API.md new file mode 100644 index 00000000..7d44dec9 --- /dev/null +++ b/API.md @@ -0,0 +1,268 @@ +# API + +The Lysand project uses the Mastodon API to interact with clients. However, the moderation API is custom-made for Lysand Server, as it allows for more fine-grained control over the server's behavior. + +## Flags, ModTags and ModNotes + +Flags are used by Lysand Server to automatically attribute tags to a status or account based on rules. ModTags and ModNotes are used by moderators to manually tag and take notes on statuses and accounts. + +The difference between flags and modtags is that flags are automatically attributed by the server, while modtags are manually attributed by moderators. + +### Flag Types + +- `content_filter`: (Statuses only) The status contains content that was filtered by the server's content filter. +- `bio_filter`: (Accounts only) The account's bio contains content that was filtered by the server's content filter. +- `emoji_filter`: The status or account contains an emoji that was filtered by the server's content filter. +- `reported`: The status or account was previously reported by a user. +- `suspended`: The status or account was previously suspended by a moderator. +- `silenced`: The status or account was previously silenced by a moderator. + +### ModTag Types + +ModTag do not have set types and can be anything. Lysand Server autosuggest previously used tags when a moderator is adding a new tag to avoid duplicates. + +### Data Format + +```ts +type Flag = { + id: string, + // One of the following two fields will be present + flaggedStatus?: Status, + flaggedUser?: User, + flagType: string, + createdAt: string, +} + +type ModTag = { + id: string, + // One of the following two fields will be present + taggedStatus?: Status, + taggedUser?: User, + mod: User, + tag: string, + createdAt: string, +} + +type ModNote = { + id: string, + // One of the following two fields will be present + notedStatus?: Status, + notedUser?: User, + mod: User, + note: string, + createdAt: string, +} +``` + +The `User` and `Status` types are the same as the ones in the Mastodon API. + +## Moderation API Routes + +### `GET /api/v1/moderation/accounts/:id` + +Returns full moderation data and flags for the account with the given ID. + +Output format: + +```ts +{ + id: string, // Same ID as in account field + flags: Flag[], + modtags: ModTag[], + modnotes: ModNote[], + account: User, +} +``` + +### `GET /api/v1/moderation/statuses/:id` + +Returns full moderation data and flags for the status with the given ID. + +Output format: + +```ts +{ + id: string, // Same ID as in status field + flags: Flag[], + modtags: ModTag[], + modnotes: ModNote[], + status: Status, +} +``` + +### `POST /api/v1/moderation/accounts/:id/modtags` + +Params: +- `tag`: string + +Adds a modtag to the account with the given ID + +### `POST /api/v1/moderation/statuses/:id/modtags` + +Params: +- `tag`: string + +Adds a modtag to the status with the given ID + +### `POST /api/v1/moderation/accounts/:id/modnotes` + +Params: +- `note`: string + +Adds a modnote to the account with the given ID + +### `POST /api/v1/moderation/statuses/:id/modnotes` + +Params: +- `note`: string + +Adds a modnote to the status with the given ID + +### `DELETE /api/v1/moderation/accounts/:id/modtags/:modtag_id` + +Deletes the modtag with the given ID from the account with the given ID + +### `DELETE /api/v1/moderation/statuses/:id/modtags/:modtag_id` + +Deletes the modtag with the given ID from the status with the given ID + +### `DELETE /api/v1/moderation/accounts/:id/modnotes/:modnote_id` + +Deletes the modnote with the given ID from the account with the given ID + +### `DELETE /api/v1/moderation/statuses/:id/modnotes/:modnote_id` + +Deletes the modnote with the given ID from the status with the given ID + +### `GET /api/v1/moderation/modtags` + +Returns a list of all modtags previously used by moderators + +Output format: + +```ts +{ + tags: string[], +} +``` + +### `GET /api/v1/moderation/accounts/flags/search` + +Allows moderators to search for accounts based on their flags, this can also include status flags + +Params: +- `limit`: Number +- `min_id`: String. Returns results immediately newer than this ID. In effect, sets a cursor at this ID and paginates forward. +- `max_id`: String. All results returned will be lesser than this ID. In effect, sets an upper bound on results. +- `since_id`: String. All results returned will be greater than this ID. In effect, sets a lower bound on results. +- `flags`: String (optional). Comma-separated list of flag types to filter by. Can be left out to return accounts with at least one flag +- `flag_count`: Number (optional). Minimum number of flags to filter by +- `include_statuses`: Boolean (optional). If true, includes status flags in the search results +- `account_id`: Array of strings (optional). Filters accounts by account ID + +This method returns a `Link` header the same way Mastodon does, to allow for pagination. + +Output format: + +```ts +{ + accounts: { + account: User, + modnotes: ModNote[], + flags: Flag[], + statuses?: { + status: Status, + modnotes: ModNote[], + flags: Flag[], + }[], + }[], +} +``` + +### `GET /api/v1/moderation/statuses/flags/search` + +Allows moderators to search for statuses based on their flags + +Params: +- `limit`: Number +- `min_id`: String. Returns results immediately newer than this ID. In effect, sets a cursor at this ID and paginates forward. +- `max_id`: String. All results returned will be lesser than this ID. In effect, sets an upper bound on results. +- `since_id`: String. All results returned will be greater than this ID. In effect, sets a lower bound on results. +- `flags`: String (optional). Comma-separated list of flag types to filter by. Can be left out to return statuses with at least one flag +- `flag_count`: Number (optional). Minimum number of flags to filter by +- `account_id`: Array of strings (optional). Filters statuses by account ID + +This method returns a `Link` header the same way Mastodon does, to allow for pagination. + +Output format: + +```ts +{ + statuses: { + status: Status, + modnotes: ModNote[], + flags: Flag[], + }[], +} +``` + +### `GET /api/v1/moderation/accounts/modtags/search` + +Allows moderators to search for accounts based on their modtags + +Params: +- `limit`: Number +- `min_id`: String. Returns results immediately newer than this ID. In effect, sets a cursor at this ID and paginates forward. +- `max_id`: String. All results returned will be lesser than this ID. In effect, sets an upper bound on results. +- `since_id`: String. All results returned will be greater than this ID. In effect, sets a lower bound on results. +- `tags`: String (optional). Comma-separated list of tags to filter by. Can be left out to return accounts with at least one tag +- `tag_count`: Number (optional). Minimum number of tags to filter by +- `include_statuses`: Boolean (optional). If true, includes status tags in the search results +- `account_id`: Array of strings (optional). Filters accounts by account ID + +This method returns a `Link` header the same way Mastodon does, to allow for pagination. + +Output format: + +```ts +{ + accounts: { + account: User, + modnotes: ModNote[], + modtags: ModTag[], + statuses?: { + status: Status, + modnotes: ModNote[], + modtags: ModTag[], + }[], + }[], +} +``` + +### `GET /api/v1/moderation/statuses/modtags/search` + +Allows moderators to search for statuses based on their modtags + +Params: +- `limit`: Number +- `min_id`: String. Returns results immediately newer than this ID. In effect, sets a cursor at this ID and paginates forward. +- `max_id`: String. All results returned will be lesser than this ID. In effect, sets an upper bound on results. +- `since_id`: String. All results returned will be greater than this ID. In effect, sets a lower bound on results. +- `tags`: String (optional). Comma-separated list of tags to filter by. Can be left out to return statuses with at least one tag +- `tag_count`: Number (optional). Minimum number of tags to filter by +- `account_id`: Array of strings (optional). Filters statuses by account ID +- `include_statuses`: Boolean (optional). If true, includes status tags in the search results + +This method returns a `Link` header the same way Mastodon does, to allow for pagination. + +Output format: + +```ts +{ + statuses: { + status: Status, + modnotes: ModNote[], + modtags: ModTag[], + }[], +} +``` diff --git a/prisma/migrations/20240304042832_rework_moderation/migration.sql b/prisma/migrations/20240304042832_rework_moderation/migration.sql new file mode 100644 index 00000000..460be492 --- /dev/null +++ b/prisma/migrations/20240304042832_rework_moderation/migration.sql @@ -0,0 +1,76 @@ +/* + Warnings: + + - You are about to drop the column `statusId` on the `Flag` table. All the data in the column will be lost. + - You are about to drop the column `userId` on the `Flag` table. All the data in the column will be lost. + - You are about to drop the `ModerationData` table. If the table is not empty, all the data it contains will be lost. + +*/ +-- DropForeignKey +ALTER TABLE "Flag" DROP CONSTRAINT "Flag_statusId_fkey"; + +-- DropForeignKey +ALTER TABLE "Flag" DROP CONSTRAINT "Flag_userId_fkey"; + +-- DropForeignKey +ALTER TABLE "ModerationData" DROP CONSTRAINT "ModerationData_creatorId_fkey"; + +-- DropForeignKey +ALTER TABLE "ModerationData" DROP CONSTRAINT "ModerationData_statusId_fkey"; + +-- AlterTable +ALTER TABLE "Flag" DROP COLUMN "statusId", +DROP COLUMN "userId", +ADD COLUMN "flaggeStatusId" UUID, +ADD COLUMN "flaggedUserId" UUID; + +-- DropTable +DROP TABLE "ModerationData"; + +-- CreateTable +CREATE TABLE "ModNote" ( + "id" UUID NOT NULL DEFAULT uuid_generate_v7(), + "notedStatusId" UUID, + "notedUserId" UUID, + "modId" UUID NOT NULL, + "note" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "ModNote_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "ModTag" ( + "id" UUID NOT NULL DEFAULT uuid_generate_v7(), + "taggedStatusId" UUID, + "taggedUserId" UUID, + "modId" UUID NOT NULL, + "tag" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "ModTag_pkey" PRIMARY KEY ("id") +); + +-- AddForeignKey +ALTER TABLE "ModNote" ADD CONSTRAINT "ModNote_notedStatusId_fkey" FOREIGN KEY ("notedStatusId") REFERENCES "Status"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ModNote" ADD CONSTRAINT "ModNote_notedUserId_fkey" FOREIGN KEY ("notedUserId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ModNote" ADD CONSTRAINT "ModNote_modId_fkey" FOREIGN KEY ("modId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ModTag" ADD CONSTRAINT "ModTag_taggedStatusId_fkey" FOREIGN KEY ("taggedStatusId") REFERENCES "Status"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ModTag" ADD CONSTRAINT "ModTag_taggedUserId_fkey" FOREIGN KEY ("taggedUserId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ModTag" ADD CONSTRAINT "ModTag_modId_fkey" FOREIGN KEY ("modId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Flag" ADD CONSTRAINT "Flag_flaggeStatusId_fkey" FOREIGN KEY ("flaggeStatusId") REFERENCES "Status"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Flag" ADD CONSTRAINT "Flag_flaggedUserId_fkey" FOREIGN KEY ("flaggedUserId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 593c9a8b..2e81d8a3 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -94,62 +94,76 @@ model Relationship { } model Status { - id String @id @default(dbgenerated("uuid_generate_v7()")) @db.Uuid - uri String @unique - author User @relation("UserStatuses", fields: [authorId], references: [id], onDelete: Cascade) - authorId String @db.Uuid - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - reblog Status? @relation("StatusToStatus", fields: [reblogId], references: [id], onDelete: Cascade) - reblogId String? @db.Uuid + id String @id @default(dbgenerated("uuid_generate_v7()")) @db.Uuid + uri String @unique + author User @relation("UserStatuses", fields: [authorId], references: [id], onDelete: Cascade) + authorId String @db.Uuid + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + reblog Status? @relation("StatusToStatus", fields: [reblogId], references: [id], onDelete: Cascade) + reblogId String? @db.Uuid isReblog Boolean - content String @default("") - contentType String @default("text/plain") - contentSource String @default("") + content String @default("") + contentType String @default("text/plain") + contentSource String @default("") visibility String - inReplyToPost Status? @relation("StatusToStatusReply", fields: [inReplyToPostId], references: [id], onDelete: SetNull) - inReplyToPostId String? @db.Uuid - quotingPost Status? @relation("StatusToStatusQuote", fields: [quotingPostId], references: [id], onDelete: SetNull) - quotingPostId String? @db.Uuid - instance Instance? @relation(fields: [instanceId], references: [id], onDelete: Cascade) - instanceId String? @db.Uuid + inReplyToPost Status? @relation("StatusToStatusReply", fields: [inReplyToPostId], references: [id], onDelete: SetNull) + inReplyToPostId String? @db.Uuid + quotingPost Status? @relation("StatusToStatusQuote", fields: [quotingPostId], references: [id], onDelete: SetNull) + quotingPostId String? @db.Uuid + instance Instance? @relation(fields: [instanceId], references: [id], onDelete: Cascade) + instanceId String? @db.Uuid sensitive Boolean - spoilerText String @default("") - application Application? @relation(fields: [applicationId], references: [id], onDelete: SetNull) - applicationId String? @db.Uuid - emojis Emoji[] @relation + spoilerText String @default("") + application Application? @relation(fields: [applicationId], references: [id], onDelete: SetNull) + applicationId String? @db.Uuid + emojis Emoji[] @relation mentions User[] - likes Like[] @relation("LikedToStatus") - reblogs Status[] @relation("StatusToStatus") - replies Status[] @relation("StatusToStatusReply") - quotes Status[] @relation("StatusToStatusQuote") - pinnedBy User[] @relation("UserPinnedNotes") + likes Like[] @relation("LikedToStatus") + reblogs Status[] @relation("StatusToStatus") + replies Status[] @relation("StatusToStatusReply") + quotes Status[] @relation("StatusToStatusQuote") + pinnedBy User[] @relation("UserPinnedNotes") attachments Attachment[] relatedNotifications Notification[] flags Flag[] - moderationData ModerationData[] + modNotes ModNote[] + modTags ModTag[] } -model ModerationData { - id String @id @default(dbgenerated("uuid_generate_v7()")) @db.Uuid - status Status @relation(fields: [statusId], references: [id], onDelete: Cascade) - statusId String @db.Uuid - creator User @relation(fields: [creatorId], references: [id], onDelete: Cascade) - creatorId String @db.Uuid - note String - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt +model ModNote { + id String @id @default(dbgenerated("uuid_generate_v7()")) @db.Uuid + notedStatus Status? @relation(fields: [notedStatusId], references: [id], onDelete: Cascade) + notedStatusId String? @db.Uuid + notedUser User? @relation("ModNoteToUser", fields: [notedUserId], references: [id], onDelete: Cascade) + notedUserId String? @db.Uuid + mod User @relation("ModNoteToMod", fields: [modId], references: [id], onDelete: Cascade) + modId String @db.Uuid + note String + createdAt DateTime @default(now()) } -// Used for moderation purposes +model ModTag { + id String @id @default(dbgenerated("uuid_generate_v7()")) @db.Uuid + taggedStatus Status? @relation(fields: [taggedStatusId], references: [id], onDelete: Cascade) + taggedStatusId String? @db.Uuid + taggedUser User? @relation("ModNoteToTaggedUser", fields: [taggedUserId], references: [id], onDelete: Cascade) + taggedUserId String? @db.Uuid + mod User @relation("ModTagToMod", fields: [modId], references: [id], onDelete: Cascade) + modId String @db.Uuid + tag String + createdAt DateTime @default(now()) +} + +// Used to tag notes and accounts with automatic moderation infractions model Flag { - id String @id @default(dbgenerated("uuid_generate_v7()")) @db.Uuid - status Status @relation(fields: [statusId], references: [id], onDelete: Cascade) - statusId String @db.Uuid - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - userId String @db.Uuid - flagType String @default("other") - createdAt DateTime @default(now()) + id String @id @default(dbgenerated("uuid_generate_v7()")) @db.Uuid + flaggedStatus Status? @relation(fields: [flaggeStatusId], references: [id], onDelete: Cascade) + flaggeStatusId String? @db.Uuid + flaggedUser User? @relation(fields: [flaggedUserId], references: [id], onDelete: Cascade) + flaggedUserId String? @db.Uuid + flagType String @default("other") + createdAt DateTime @default(now()) } model Token { @@ -204,42 +218,45 @@ model Notification { } model User { - id String @id @default(dbgenerated("uuid_generate_v7()")) @db.Uuid - uri String @unique - username String @unique + id String @id @default(dbgenerated("uuid_generate_v7()")) @db.Uuid + uri String @unique + username String @unique displayName String password String? // Nullable - email String? @unique // Nullable - note String @default("") - isAdmin Boolean @default(false) + email String? @unique // Nullable + note String @default("") + isAdmin Boolean @default(false) endpoints Json? // Nullable source Json avatar String header String - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - isBot Boolean @default(false) - isLocked Boolean @default(false) - isDiscoverable Boolean @default(false) - sanctions String[] @default([]) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + isBot Boolean @default(false) + isLocked Boolean @default(false) + isDiscoverable Boolean @default(false) + sanctions String[] @default([]) publicKey String privateKey String? // Nullable - relationships Relationship[] @relation("OwnerToRelationship") // One to many relation with Relationship - relationshipSubjects Relationship[] @relation("SubjectToRelationship") // One to many relation with Relationship - instance Instance? @relation(fields: [instanceId], references: [id], onDelete: Cascade) // Many to one relation with Instance - instanceId String? @db.Uuid - pinnedNotes Status[] @relation("UserPinnedNotes") // Many to many relation with Status + relationships Relationship[] @relation("OwnerToRelationship") // One to many relation with Relationship + relationshipSubjects Relationship[] @relation("SubjectToRelationship") // One to many relation with Relationship + instance Instance? @relation(fields: [instanceId], references: [id], onDelete: Cascade) // Many to one relation with Instance + instanceId String? @db.Uuid + pinnedNotes Status[] @relation("UserPinnedNotes") // Many to many relation with Status emojis Emoji[] // Many to many relation with Emoji - statuses Status[] @relation("UserStatuses") // One to many relation with Status + statuses Status[] @relation("UserStatuses") // One to many relation with Status tokens Token[] // One to many relation with Token - likes Like[] @relation("UserLiked") // One to many relation with Like + likes Like[] @relation("UserLiked") // One to many relation with Like statusesMentioned Status[] // Many to many relation with Status notifications Notification[] // One to many relation with Notification - notified Notification[] @relation("NotificationToNotified") // One to many relation with Notification + notified Notification[] @relation("NotificationToNotified") // One to many relation with Notification linkedOpenIdAccounts OpenIdAccount[] // One to many relation with OpenIdAccount - flags Flag[] @relation - moderationData ModerationData[] - disableAutomoderation Boolean @default(false) + flags Flag[] + modNotes ModNote[] @relation("ModNoteToUser") + modTags ModTag[] @relation("ModNoteToTaggedUser") + disableAutomoderation Boolean @default(false) + createdModTags ModTag[] @relation("ModTagToMod") + createdModNotes ModNote[] @relation("ModNoteToMod") } model OpenIdAccount {