diff --git a/locales/en-US.yml b/locales/en-US.yml index 02a2c6aef..a2c205936 100644 --- a/locales/en-US.yml +++ b/locales/en-US.yml @@ -1132,6 +1132,11 @@ openInMainColumn: "Open in main column" searchNotLoggedIn_1: "You have to be authenticated in order to use full text search." searchNotLoggedIn_2: "However, you can search using hashtags, and search users." searchEmptyQuery: "Please enter a search term." +bite: "Bite" +biteBack: "Bite back" +bittenBack: "Bitten back" +bitYou: "bit you" +bitYouBack: "bit you back" _sensitiveMediaDetection: description: "Reduces the effort of server moderation through automatically recognizing @@ -2108,6 +2113,7 @@ _notification: followRequestAccepted: "Accepted follow requests" groupInvited: "Group invitations" app: "Notifications from linked apps" + bite: "Bites" _actions: followBack: "followed you back" reply: "Reply" diff --git a/packages/backend/src/db/postgre.ts b/packages/backend/src/db/postgre.ts index e1db91fc2..8d5f18cf7 100644 --- a/packages/backend/src/db/postgre.ts +++ b/packages/backend/src/db/postgre.ts @@ -77,6 +77,7 @@ import { OAuthToken } from "@/models/entities/oauth-token.js"; import { HtmlNoteCacheEntry } from "@/models/entities/html-note-cache-entry.js"; import { HtmlUserCacheEntry } from "@/models/entities/html-user-cache-entry.js"; import { TypeORMLoggingOptions } from "@/config/types.js"; +import { Bite } from "@/models/entities/bite.js"; const sqlLogger = dbLogger.createSubLogger("sql", "gray", false); const isLogEnabled = (level: TypeORMLoggingOptions): boolean => { @@ -194,6 +195,7 @@ export const entities = [ OAuthToken, HtmlNoteCacheEntry, HtmlUserCacheEntry, + Bite, ...charts, ]; diff --git a/packages/backend/src/migration/1705528046452-federated-bite.ts b/packages/backend/src/migration/1705528046452-federated-bite.ts new file mode 100644 index 000000000..90d31c5b0 --- /dev/null +++ b/packages/backend/src/migration/1705528046452-federated-bite.ts @@ -0,0 +1,45 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class FederatedBite1705528046452 implements MigrationInterface { + name = 'FederatedBite1705528046452' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`CREATE TYPE "public"."bite_targettype_enum" AS ENUM('user', 'bite')`); + await queryRunner.query(`CREATE TABLE "bite" ("id" character varying(32) NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL, "uri" character varying(512), "userId" character varying(32) NOT NULL, "targetType" "public"."bite_targettype_enum" NOT NULL, "targetUserId" character varying(32), "targetBiteId" character varying(32), "replied" boolean NOT NULL DEFAULT true, CONSTRAINT "CHK_c3a20c5756ccff3133f8927500" CHECK ("targetUserId" IS NOT NULL OR "targetBiteId" IS NOT NULL), CONSTRAINT "PK_1887f3f621a4a7655a1b78bfd66" PRIMARY KEY ("id")); COMMENT ON COLUMN "bite"."uri" IS 'null if local'`); + await queryRunner.query(`ALTER TABLE "notification" ADD "biteId" character varying(32)`); + await queryRunner.query(`ALTER TYPE "public"."user_profile_mutingnotificationtypes_enum" RENAME TO "user_profile_mutingnotificationtypes_enum_old"`); + await queryRunner.query(`CREATE TYPE "public"."user_profile_mutingnotificationtypes_enum" AS ENUM('follow', 'mention', 'reply', 'renote', 'quote', 'reaction', 'pollVote', 'pollEnded', 'receiveFollowRequest', 'followRequestAccepted', 'groupInvited', 'app', 'bite')`); + await queryRunner.query(`ALTER TABLE "user_profile" ALTER COLUMN "mutingNotificationTypes" DROP DEFAULT`); + await queryRunner.query(`ALTER TABLE "user_profile" ALTER COLUMN "mutingNotificationTypes" TYPE "public"."user_profile_mutingnotificationtypes_enum"[] USING "mutingNotificationTypes"::"text"::"public"."user_profile_mutingnotificationtypes_enum"[]`); + await queryRunner.query(`ALTER TABLE "user_profile" ALTER COLUMN "mutingNotificationTypes" SET DEFAULT '{}'`); + await queryRunner.query(`DROP TYPE "public"."user_profile_mutingnotificationtypes_enum_old"`); + await queryRunner.query(`ALTER TABLE "bite" ADD CONSTRAINT "FK_8d00aa79e157364ac1f60c15098" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "bite" ADD CONSTRAINT "FK_a646fbbeb6efa2531c75fec46b9" FOREIGN KEY ("targetUserId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "bite" ADD CONSTRAINT "FK_5d5f68610583f2e0b6785d3c0e9" FOREIGN KEY ("targetBiteId") REFERENCES "bite"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "notification" ADD CONSTRAINT "FK_c54844158c1eead7042e7ca4c83" FOREIGN KEY ("biteId") REFERENCES "bite"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TYPE "public"."notification_type_enum" RENAME TO "notification_type_enum_old"`); + await queryRunner.query(`CREATE TYPE "public"."notification_type_enum" AS ENUM('follow', 'mention', 'reply', 'renote', 'quote', 'reaction', 'pollVote', 'pollEnded', 'receiveFollowRequest', 'followRequestAccepted', 'groupInvited', 'app', 'bite')`); + await queryRunner.query(`ALTER TABLE "notification" ALTER COLUMN "type" TYPE "public"."notification_type_enum" USING "type"::"text"::"public"."notification_type_enum"`); + await queryRunner.query(`DROP TYPE "public"."notification_type_enum_old"`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "notification" DROP CONSTRAINT "FK_c54844158c1eead7042e7ca4c83"`); + await queryRunner.query(`ALTER TABLE "bite" DROP CONSTRAINT "FK_5d5f68610583f2e0b6785d3c0e9"`); + await queryRunner.query(`ALTER TABLE "bite" DROP CONSTRAINT "FK_a646fbbeb6efa2531c75fec46b9"`); + await queryRunner.query(`ALTER TABLE "bite" DROP CONSTRAINT "FK_8d00aa79e157364ac1f60c15098"`); + await queryRunner.query(`CREATE TYPE "public"."user_profile_mutingnotificationtypes_enum_old" AS ENUM('follow', 'mention', 'reply', 'renote', 'quote', 'reaction', 'pollVote', 'pollEnded', 'receiveFollowRequest', 'followRequestAccepted', 'groupInvited', 'app')`); + await queryRunner.query(`ALTER TABLE "user_profile" ALTER COLUMN "mutingNotificationTypes" DROP DEFAULT`); + await queryRunner.query(`ALTER TABLE "user_profile" ALTER COLUMN "mutingNotificationTypes" TYPE "public"."user_profile_mutingnotificationtypes_enum_old"[] USING "mutingNotificationTypes"::"text"::"public"."user_profile_mutingnotificationtypes_enum_old"[]`); + await queryRunner.query(`ALTER TABLE "user_profile" ALTER COLUMN "mutingNotificationTypes" SET DEFAULT '{}'`); + await queryRunner.query(`DROP TYPE "public"."user_profile_mutingnotificationtypes_enum"`); + await queryRunner.query(`ALTER TYPE "public"."user_profile_mutingnotificationtypes_enum_old" RENAME TO "user_profile_mutingnotificationtypes_enum"`); + await queryRunner.query(`ALTER TABLE "notification" DROP COLUMN "biteId"`); + await queryRunner.query(`DROP TABLE "bite"`); + await queryRunner.query(`DROP TYPE "public"."bite_targettype_enum"`); + await queryRunner.query(`CREATE TYPE "public"."notification_type_enum_old" AS ENUM('follow', 'mention', 'reply', 'renote', 'quote', 'reaction', 'pollVote', 'pollEnded', 'receiveFollowRequest', 'followRequestAccepted', 'groupInvited', 'app')`); + await queryRunner.query(`ALTER TABLE "notification" ALTER COLUMN "type" TYPE "public"."notification_type_enum_old" USING "type"::"text"::"public"."notification_type_enum_old"`); + await queryRunner.query(`DROP TYPE "public"."notification_type_enum"`); + await queryRunner.query(`ALTER TYPE "public"."notification_type_enum_old" RENAME TO "notification_type_enum"`); + } +} diff --git a/packages/backend/src/misc/schema.ts b/packages/backend/src/misc/schema.ts index 6e03d30d9..317856c89 100644 --- a/packages/backend/src/misc/schema.ts +++ b/packages/backend/src/misc/schema.ts @@ -31,6 +31,7 @@ import { packedQueueCountSchema } from "@/models/schema/queue.js"; import { packedGalleryPostSchema } from "@/models/schema/gallery-post.js"; import { packedEmojiSchema } from "@/models/schema/emoji.js"; import { packedNoteEdit } from "@/models/schema/note-edit.js"; +import { packedBiteSchema } from "@/models/schema/bite.js"; export const refs = { UserLite: packedUserLiteSchema, @@ -65,6 +66,7 @@ export const refs = { FederationInstance: packedFederationInstanceSchema, GalleryPost: packedGalleryPostSchema, Emoji: packedEmojiSchema, + Bite: packedBiteSchema, }; export type Packed = SchemaType; diff --git a/packages/backend/src/models/entities/bite.ts b/packages/backend/src/models/entities/bite.ts new file mode 100644 index 000000000..ee7e3aea1 --- /dev/null +++ b/packages/backend/src/models/entities/bite.ts @@ -0,0 +1,56 @@ +import { Check, Column, Entity, ManyToOne, PrimaryColumn } from "typeorm"; +import { id } from "../id.js"; +import { User } from "./user.js"; + +@Entity() +@Check(`"targetUserId" IS NOT NULL OR "targetBiteId" IS NOT NULL`) +export class Bite { + @PrimaryColumn(id()) + public id: string; + + @Column("timestamp with time zone") + public createdAt: Date; + + @Column("varchar", { + length: 512, + nullable: true, + comment: "null if local", + }) + public uri: string | null; + + @Column(id()) + public userId: string; + + @ManyToOne(() => User, { + onDelete: "CASCADE", + }) + public user: User; + + @Column("enum", { + enum: ["user", "bite"], + }) + public targetType: "user" | "bite"; + + @Column({ ...id(), nullable: true }) + public targetUserId: string | null; + + @ManyToOne(() => User, { + onDelete: "CASCADE", + nullable: true, + }) + public targetUser: User | null; + + @Column({ ...id(), nullable: true }) + public targetBiteId: string | null; + + @ManyToOne(() => Bite, { + onDelete: "CASCADE", + nullable: true, + }) + public targetBite: Bite | null; + + @Column("boolean", { + default: true, + }) + public replied: boolean; +} diff --git a/packages/backend/src/models/entities/notification.ts b/packages/backend/src/models/entities/notification.ts index da23f7d3e..f8619a655 100644 --- a/packages/backend/src/models/entities/notification.ts +++ b/packages/backend/src/models/entities/notification.ts @@ -13,6 +13,7 @@ import { FollowRequest } from "./follow-request.js"; import { UserGroupInvitation } from "./user-group-invitation.js"; import { AccessToken } from "./access-token.js"; import { notificationTypes } from "@/types.js"; +import { Bite } from "./bite.js"; @Entity() export class Notification { @@ -181,4 +182,12 @@ export class Notification { }) @JoinColumn() public appAccessToken: AccessToken | null; + + @Column({ ...id(), nullable: true }) + public biteId: Bite["id"] | null; + + @ManyToOne((type) => Bite, { + onDelete: "CASCADE", nullable: true + }) + public bite: Bite | null; } diff --git a/packages/backend/src/models/index.ts b/packages/backend/src/models/index.ts index 2f229689b..4ca2666f7 100644 --- a/packages/backend/src/models/index.ts +++ b/packages/backend/src/models/index.ts @@ -70,6 +70,7 @@ import { OAuthToken } from "@/models/entities/oauth-token.js"; import { UserProfileRepository } from "@/models/repositories/user-profile.js"; import { HtmlNoteCacheEntry } from "@/models/entities/html-note-cache-entry.js"; import { HtmlUserCacheEntry } from "@/models/entities/html-user-cache-entry.js"; +import { BiteRespository } from "./repositories/bite.js"; export const Announcements = db.getRepository(Announcement); export const AnnouncementReads = db.getRepository(AnnouncementRead); @@ -138,3 +139,4 @@ export const OAuthApps = db.getRepository(OAuthApp); export const OAuthTokens = db.getRepository(OAuthToken); export const HtmlUserCacheEntries = db.getRepository(HtmlUserCacheEntry); export const HtmlNoteCacheEntries = db.getRepository(HtmlNoteCacheEntry); +export const Bites = BiteRespository; diff --git a/packages/backend/src/models/repositories/bite.ts b/packages/backend/src/models/repositories/bite.ts new file mode 100644 index 000000000..18b242c54 --- /dev/null +++ b/packages/backend/src/models/repositories/bite.ts @@ -0,0 +1,71 @@ +import { db } from "@/db/postgre.js"; +import { Bite } from "../entities/bite.js"; +import { Packed } from "@/misc/schema.js"; +import { Bites, Users } from "../index.js"; +import { User } from "../entities/user.js"; +import { awaitAll } from "@/prelude/await-all.js"; +import config from "@/config/index.js"; + +export const BiteRespository = db.getRepository(Bite).extend({ + async pack( + src: Bite | Bite["id"], + me?: { id: User["id"] } | null | undefined, + ): Promise> { + const bite = + typeof src === "object" ? src : await this.findOneByOrFail({ id: src }); + return await awaitAll({ + id: bite.id, + user: Users.pack(bite.user ?? bite.userId, me, { detail: false }), + targetType: bite.targetType, + target: this.packTarget(bite, me), + replied: bite.replied, + }); + }, + + async packTarget( + bite: Bite, + me?: { id: User["id"] } | null | undefined, + ): Promise | Packed<"Bite">> { + switch (bite.targetType) { + case "user": + return await Users.pack(bite.targetUser ?? bite.targetUserId!, me, { + detail: false, + }); + case "bite": + return await this.pack(bite.targetBite ?? bite.targetBiteId!, me); + } + }, + + async targetUri(bite: Bite): Promise { + switch (bite.targetType) { + case "user": { + bite.targetUser = + bite.targetUser ?? + (await Users.findOneOrFail({ where: { id: bite.targetUserId! } })); + return ( + bite.targetUser.uri || `${config.url}/users/${bite.targetUserId}` + ); + } + case "bite": { + bite.targetBite = + bite.targetBite ?? + (await Bites.findOneOrFail({ where: { id: bite.targetBiteId! } })); + return ( + bite.targetBite.uri || `${config.url}/bites/${bite.targetBiteId}` + ); + } + } + }, + + async targetUserId(bite: Bite): Promise { + switch (bite.targetType) { + case "user": + return bite.targetUserId!; + case "bite": + bite.targetBite = + bite.targetBite ?? + (await Bites.findOneByOrFail({ id: bite.targetBiteId! })); + return bite.targetBite.userId; + } + }, +}); diff --git a/packages/backend/src/models/repositories/notification.ts b/packages/backend/src/models/repositories/notification.ts index 9c649cc53..79526fe10 100644 --- a/packages/backend/src/models/repositories/notification.ts +++ b/packages/backend/src/models/repositories/notification.ts @@ -14,6 +14,7 @@ import { UserGroupInvitations, AccessTokens, NoteReactions, + Bites, } from "../index.js"; export const NotificationRepository = db.getRepository(Notification).extend({ @@ -143,6 +144,11 @@ export const NotificationRepository = db.getRepository(Notification).extend({ icon: notification.customIcon || token?.iconUrl, } : {}), + ...(notification.type === "bite" + ? { + bite: notification.bite ?? await Bites.findOneBy({ id: notification.biteId! }), + } + : {}), }); }, diff --git a/packages/backend/src/models/schema/bite.ts b/packages/backend/src/models/schema/bite.ts new file mode 100644 index 000000000..2314ed6bc --- /dev/null +++ b/packages/backend/src/models/schema/bite.ts @@ -0,0 +1,31 @@ +export const packedBiteSchema = { + type: "object", + properties: { + id: { + type: "string", + format: "id", + optional: false, + nullable: false, + }, + user: { + type: "object", + ref: "UserLite", + }, + targetType: { + type: "string", + enum: ["user", "bite"], + }, + target: { + oneOf: [ + { + type: "object", + ref: "UserLite", + }, + { + type: "object", + ref: "Bite", + }, + ], + }, + }, +} as const; diff --git a/packages/backend/src/models/schema/notification.ts b/packages/backend/src/models/schema/notification.ts index 97fd16339..cb6bc47fe 100644 --- a/packages/backend/src/models/schema/notification.ts +++ b/packages/backend/src/models/schema/notification.ts @@ -75,5 +75,11 @@ export const packedNotificationSchema = { optional: true, nullable: true, }, + bite: { + type: "object", + ref: "Bite", + optional: true, + nullable: true, + }, }, } as const; diff --git a/packages/backend/src/remote/activitypub/kernel/bite.ts b/packages/backend/src/remote/activitypub/kernel/bite.ts new file mode 100644 index 000000000..ecce5cc7e --- /dev/null +++ b/packages/backend/src/remote/activitypub/kernel/bite.ts @@ -0,0 +1,79 @@ +import { CacheableRemoteUser } from "@/models/entities/user.js"; +import { IActivity, IBite } from "../type.js"; +import Resolver from "../resolver.js"; +import { fetchPerson } from "../models/person.js"; +import config from "@/config/index.js"; +import { genId } from "@/misc/gen-id.js"; +import { createBite } from "@/services/create-bite.js"; +import { Bite } from "@/models/entities/bite.js"; + +export default async ( + actor: CacheableRemoteUser, + bite: IBite, +): Promise => { + if (actor.uri !== bite.actor) { + return "skip: actor uri mismatch"; + } + + if (bite.id === null) { + return "skip: bite id not specified"; + } + + const resolver = new Resolver(); + const biteActor = await fetchPerson(bite.actor, resolver); + if (biteActor === null) { + return "skip: biteActor is null"; + } + if (!bite.target.startsWith(`${config.url}/`)) { + return "skip: target is not local"; + } + + const localId = genId(); + const fields = { + id: localId, + userId: biteActor.id, + replied: false, + } as any; + + const parts = bite.target.split("/"); + const targetDbId = parts.pop(); + const targetPathType = parts.pop(); + + let targetType: Bite["targetType"]; + let targetId; + + if (targetPathType === "users") { + targetType = "user"; + targetId = targetDbId; + } else if (targetPathType === "bites") { + targetType = "bite"; + targetId = targetDbId; + } else { + // fallback for unknown object types + targetType = "user"; + if (bite.to !== undefined) { + const to = Array.isArray(bite.to) ? bite.to[0] : bite.to; + targetId = (to as string).split("/").pop(); + } else { + const biteTarget = await resolver.resolve(bite.target); + const targetActor = + (biteTarget as IActivity).actor || biteTarget.attributedTo; + const targetActorId = + typeof targetActor === "string" ? targetActor : (targetActor as any).id; + if (!targetActorId.startsWith(`${config.url}/`)) { + return "skip: indirect target is not local"; + } + targetId = targetActorId.split("/").pop(); + } + } + + await createBite( + biteActor, + targetType, + targetId, + bite.id!, + bite.published ? new Date(bite.published) : null, + ); + + return "ok"; +}; diff --git a/packages/backend/src/remote/activitypub/kernel/index.ts b/packages/backend/src/remote/activitypub/kernel/index.ts index 58e354a51..f618ebe4f 100644 --- a/packages/backend/src/remote/activitypub/kernel/index.ts +++ b/packages/backend/src/remote/activitypub/kernel/index.ts @@ -19,6 +19,7 @@ import { isFlag, isMove, getApId, + isBite, } from "../type.js"; import { apLogger } from "../logger.js"; import Resolver from "../resolver.js"; @@ -37,6 +38,7 @@ import remove from "./remove/index.js"; import block from "./block/index.js"; import flag from "./flag/index.js"; import move from "./move/index.js"; +import bite from "./bite.js"; import type { IObject } from "../type.js"; import { extractDbHost } from "@/misc/convert-host.js"; import { shouldBlockInstance } from "@/misc/should-block-instance.js"; @@ -105,6 +107,8 @@ async function performOneActivity( await flag(actor, activity); } else if (isMove(activity)) { await move(actor, activity); + } else if (isBite(activity)) { + await bite(actor, activity); } else { apLogger.warn(`unrecognized activity type: ${(activity as any).type}`); } diff --git a/packages/backend/src/remote/activitypub/misc/contexts.ts b/packages/backend/src/remote/activitypub/misc/contexts.ts index 5d2009219..ae9e854e9 100644 --- a/packages/backend/src/remote/activitypub/misc/contexts.ts +++ b/packages/backend/src/remote/activitypub/misc/contexts.ts @@ -560,6 +560,8 @@ export const WellKnownContext = { litepub: "http://litepub.social/ns#", EmojiReact: "litepub:EmojiReact", EmojiReaction: "litepub:EmojiReaction", + // mia + Bite: "https://ns.mia.jetzt/as#Bite", }, ], }; diff --git a/packages/backend/src/remote/activitypub/renderer/bite.ts b/packages/backend/src/remote/activitypub/renderer/bite.ts new file mode 100644 index 000000000..9630b6467 --- /dev/null +++ b/packages/backend/src/remote/activitypub/renderer/bite.ts @@ -0,0 +1,12 @@ +import config from "@/config/index.js"; +import { Bites } from "@/models/index.js"; +import { Bite } from "@/models/entities/bite.js"; + +export default async (bite: Bite) => ({ + id: `${config.url}/bites/${bite.id}`, + type: "Bite", + actor: `${config.url}/users/${bite.userId}`, + target: await Bites.targetUri(bite), + published: bite.createdAt.toISOString(), + to: await Bites.targetUserId(bite), +}); diff --git a/packages/backend/src/remote/activitypub/resolver.ts b/packages/backend/src/remote/activitypub/resolver.ts index 53185cd42..8baa2958a 100644 --- a/packages/backend/src/remote/activitypub/resolver.ts +++ b/packages/backend/src/remote/activitypub/resolver.ts @@ -13,6 +13,7 @@ import { NoteReactions, Polls, Users, + Bites, } from "@/models/index.js"; import { parseUri } from "./db-resolver.js"; import renderNote from "@/remote/activitypub/renderer/note.js"; @@ -25,6 +26,7 @@ import renderFollow from "@/remote/activitypub/renderer/follow.js"; import { shouldBlockInstance } from "@/misc/should-block-instance.js"; import { apLogger } from "@/remote/activitypub/logger.js"; import { In, IsNull, Not } from "typeorm"; +import renderBite from "@/remote/activitypub/renderer/bite.js"; export default class Resolver { private history: Set; @@ -219,6 +221,10 @@ export default class Resolver { throw new Error("resolveLocal: invalid follow URI"); } return renderActivity(renderFollow(follower, followee, url)); + case "bites": + return Bites.findOneByOrFail({ id: parsed.id }).then((bite) => + renderActivity(renderBite(bite)), + ); default: throw new Error(`resolveLocal: type ${type} unhandled`); } diff --git a/packages/backend/src/remote/activitypub/type.ts b/packages/backend/src/remote/activitypub/type.ts index 7b54b2320..d7ba1bdc1 100644 --- a/packages/backend/src/remote/activitypub/type.ts +++ b/packages/backend/src/remote/activitypub/type.ts @@ -322,6 +322,12 @@ export interface IMove extends IActivity { target: IObject | string; } +export interface IBite extends IActivity { + type: "Bite"; + actor: string; + target: string; +} + export const isCreate = (object: IObject): object is ICreate => getApType(object) === "Create"; export const isDelete = (object: IObject): object is IDelete => @@ -354,3 +360,5 @@ export const isFlag = (object: IObject): object is IFlag => getApType(object) === "Flag"; export const isMove = (object: IObject): object is IMove => getApType(object) === "Move"; +export const isBite = (object: IObject): object is IBite => + getApType(object) === "Bite"; diff --git a/packages/backend/src/server/activitypub.ts b/packages/backend/src/server/activitypub.ts index 9df6a0c7d..4c22a1cbe 100644 --- a/packages/backend/src/server/activitypub.ts +++ b/packages/backend/src/server/activitypub.ts @@ -16,6 +16,7 @@ import { Emojis, NoteReactions, FollowRequests, + Bites, } from "@/models/index.js"; import type { ILocalUser, User } from "@/models/entities/user.js"; import { renderLike } from "@/remote/activitypub/renderer/like.js"; @@ -35,6 +36,7 @@ import Outbox, { packActivity } from "./activitypub/outbox.js"; import { serverLogger } from "./index.js"; import config from "@/config/index.js"; import Koa from "koa"; +import renderBite from "@/remote/activitypub/renderer/bite.js"; // Init router const router = new Router(); @@ -480,4 +482,33 @@ router.get("/follows/:followRequestId", async (ctx: Router.RouterContext) => { setResponseType(ctx); }); +// bite +router.get("/bites/:biteId", async (ctx: Router.RouterContext) => { + const verify = await checkFetch(ctx.req); + if (verify !== 200) { + ctx.status = verify; + return; + } + + const bite = await Bites.findOne({ + where: { id: ctx.params.biteId }, + relations: ["targetUser", "targetBite"], + }); + + if (bite === null) { + ctx.status = 404; + return; + } + + const meta = await fetchMeta(); + if (meta.secureMode || meta.privateMode) { + ctx.set("Cache-Control", "private, max-age=0, must-revalidate"); + } else { + ctx.set("Cache-Control", "public, max-age=180"); + } + + ctx.body = renderActivity(await renderBite(bite)); + setResponseType(ctx); +}); + export default router; diff --git a/packages/backend/src/server/api/endpoints.ts b/packages/backend/src/server/api/endpoints.ts index 430a0496e..bf12e4f83 100644 --- a/packages/backend/src/server/api/endpoints.ts +++ b/packages/backend/src/server/api/endpoints.ts @@ -334,6 +334,8 @@ import * as ep___users_show from "./endpoints/users/show.js"; import * as ep___users_stats from "./endpoints/users/stats.js"; import * as ep___fetchRss from "./endpoints/fetch-rss.js"; import * as ep___admin_driveCapOverride from "./endpoints/admin/drive-capacity-override.js"; +import * as ep___bites_create from "./endpoints/bites/create.js"; +import * as ep___bites_show from "./endpoints/bites/show.js"; //Iceshrimp Move import * as ep___i_move from "./endpoints/i/move.js"; @@ -682,6 +684,8 @@ const eps = [ ["admin/drive-capacity-override", ep___admin_driveCapOverride], ["fetch-rss", ep___fetchRss], ["get-sounds", ep___sounds], + ["bites/create", ep___bites_create], + ["bites/show", ep___bites_show], ]; export interface IEndpointMeta { diff --git a/packages/backend/src/server/api/endpoints/bites/create.ts b/packages/backend/src/server/api/endpoints/bites/create.ts new file mode 100644 index 000000000..c4e28dee2 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/bites/create.ts @@ -0,0 +1,37 @@ +import { Bites } from "@/models/index.js"; +import define from "../../define.js"; +import { createBite } from "@/services/create-bite.js"; +import { MINUTE } from "@/const.js"; + +export const meta = { + tags: ["bites"], + + requireCredential: true, + + limit: { + duration: MINUTE, + max: 30, + }, + + res: { + type: "object", + optional: false, + nullable: false, + ref: "Bite", + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + targetType: { type: "string", enum: ["user", "bite"] }, + targetId: { type: "string", format: "misskey:id" }, + }, + required: ["targetType", "targetId"], +} as const; + +export default define(meta, paramDef, async (ps, user) => { + const biteId = await createBite(user, ps.targetType, ps.targetId); + + return await Bites.pack(biteId, user); +}); diff --git a/packages/backend/src/server/api/endpoints/bites/show.ts b/packages/backend/src/server/api/endpoints/bites/show.ts new file mode 100644 index 000000000..533496dc2 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/bites/show.ts @@ -0,0 +1,25 @@ +import { Bites } from "@/models/index.js"; +import define from "../../define.js"; + +export const meta = { + tags: ["bites"], + + res: { + type: "object", + optional: false, + nullable: false, + ref: "Bite", + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + biteId: { type: "string", format: "misskey:id" }, + }, + required: ["biteId"], +} as const; + +export default define(meta, paramDef, async (ps, user) => { + return await Bites.pack(ps.biteId, user); +}); diff --git a/packages/backend/src/services/create-bite.ts b/packages/backend/src/services/create-bite.ts new file mode 100644 index 000000000..dea96e20d --- /dev/null +++ b/packages/backend/src/services/create-bite.ts @@ -0,0 +1,74 @@ +import { genId } from "@/misc/gen-id.js"; +import { Bites, Users } from "@/models/index.js"; +import { Bite } from "@/models/entities/bite.js"; +import { User } from "@/models/entities/user.js"; +import { renderActivity } from "@/remote/activitypub/renderer/index.js"; +import renderBite from "@/remote/activitypub/renderer/bite.js"; +import { deliverToUser } from "@/remote/activitypub/deliver-manager.js"; +import { createNotification } from "./create-notification.js"; + +export async function createBite( + sender: User, + targetType: Bite["targetType"], + targetId: string, + remoteUri: Bite["uri"] = null, + createdAt: Date | null = null, +): Promise { + const id = genId(); + + const insert = { + id, + createdAt: createdAt ?? new Date(), + userId: sender.id, + targetType, + replied: false, + uri: remoteUri, + } as any; + + switch (targetType) { + case "user": + insert.targetUserId = targetId; + break; + case "bite": + insert.targetBiteId = targetId; + break; + } + + await Bites.insert(insert); + + const bite = await Bites.findOneOrFail({ + where: { id }, + relations: ["targetUser", "targetBite"], + }); + + let deliverTarget: User; + + switch (targetType) { + case "user": + deliverTarget = bite.targetUser!; + break; + case "bite": + await Bites.update({ id: bite.targetBiteId! }, { replied: true }); + deliverTarget = + bite.targetBite!.user ?? + (await Users.findOneByOrFail({ id: bite.targetBite!.userId })); + break; + } + + if (Users.isLocalUser(sender) && Users.isRemoteUser(deliverTarget)) { + await deliverToUser( + sender, + renderActivity(await renderBite(bite)), + deliverTarget, + ); + } + + if (Users.isLocalUser(deliverTarget)) { + await createNotification(deliverTarget.id, "bite", { + notifierId: sender.id, + biteId: bite.id, + }); + } + + return id; +} diff --git a/packages/backend/src/services/create-notification.ts b/packages/backend/src/services/create-notification.ts index 9bfd1a7bf..d89cb8549 100644 --- a/packages/backend/src/services/create-notification.ts +++ b/packages/backend/src/services/create-notification.ts @@ -25,7 +25,7 @@ export async function createNotification( if ( data.notifierId && - ["mention", "reply", "renote", "quote", "reaction"].includes(type) + ["mention", "reply", "renote", "quote", "reaction", "bite"].includes(type) ) { const notifier = await Users.findOneBy({ id: data.notifierId }); // suppress if the notifier does not exist or is silenced. diff --git a/packages/backend/src/types.ts b/packages/backend/src/types.ts index b77b5afb4..2204d2ff0 100644 --- a/packages/backend/src/types.ts +++ b/packages/backend/src/types.ts @@ -11,6 +11,7 @@ export const notificationTypes = [ "followRequestAccepted", "groupInvited", "app", + "bite", ] as const; export const noteVisibilities = [ diff --git a/packages/client/src/components/MkBiteButton.vue b/packages/client/src/components/MkBiteButton.vue new file mode 100644 index 000000000..6af0969e5 --- /dev/null +++ b/packages/client/src/components/MkBiteButton.vue @@ -0,0 +1,124 @@ + + + + + diff --git a/packages/client/src/components/MkNotification.vue b/packages/client/src/components/MkNotification.vue index bf5b873b5..cec021b7a 100644 --- a/packages/client/src/components/MkNotification.vue +++ b/packages/client/src/components/MkNotification.vue @@ -222,6 +222,21 @@ :hideMenu="true" /> + {{ + notification.bite.targetType === 'user' + ? i18n.ts.bitYou + : i18n.ts.bitYouBack + }} +
+