From dc8b02988617ee7670fc4672874ede112e3c40a4 Mon Sep 17 00:00:00 2001 From: samunohito <46447427+samunohito@users.noreply.github.com> Date: Mon, 18 May 2026 19:00:27 +0900 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20=E3=82=A2=E3=83=B3=E3=83=86?= =?UTF-8?q?=E3=83=8A=E3=81=AE=E5=85=AC=E9=96=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 2 +- locales/ja-JP.yml | 8 + .../migration/1779092613181-antenna-share.js | 32 ++ .../src/core/entities/AntennaEntityService.ts | 33 +- packages/backend/src/di-symbols.ts | 1 + packages/backend/src/models/Antenna.ts | 5 + .../backend/src/models/AntennaFavorite.ts | 35 ++ .../backend/src/models/RepositoryModule.ts | 9 + packages/backend/src/models/_.ts | 3 + .../backend/src/models/json-schema/antenna.ts | 23 + packages/backend/src/postgres.ts | 2 + .../ExportAntennasProcessorService.ts | 1 + .../ImportAntennasProcessorService.ts | 3 + .../backend/src/server/api/endpoint-list.ts | 4 + .../server/api/endpoints/antennas/create.ts | 14 +- .../server/api/endpoints/antennas/favorite.ts | 83 ++++ .../src/server/api/endpoints/antennas/list.ts | 2 +- .../api/endpoints/antennas/my-favorites.ts | 54 +++ .../server/api/endpoints/antennas/notes.ts | 36 +- .../src/server/api/endpoints/antennas/show.ts | 10 +- .../api/endpoints/antennas/unfavorite.ts | 71 +++ .../server/api/endpoints/antennas/update.ts | 16 +- .../server/api/endpoints/users/antennas.ts | 63 +++ .../src/server/api/stream/channels/antenna.ts | 10 +- packages/backend/test/e2e/antennas.ts | 412 ++++++++++++++++++ packages/frontend/src/cache.ts | 1 + .../src/components/MkAntennaEditor.vue | 22 +- .../frontend/src/pages/antenna-timeline.vue | 104 ++++- .../frontend/src/pages/my-antennas/edit.vue | 6 + .../frontend/src/pages/my-antennas/index.vue | 51 ++- packages/frontend/src/pages/timeline.vue | 18 +- packages/frontend/src/pages/user/antennas.vue | 52 +++ packages/frontend/src/pages/user/index.vue | 6 + .../frontend/src/ui/deck/antenna-column.vue | 59 ++- .../frontend/src/widgets/WidgetTimeline.vue | 19 +- packages/i18n/src/autogen/locale.ts | 26 ++ packages/misskey-js/etc/misskey-js.api.md | 22 +- .../misskey-js/src/autogen/apiClientJSDoc.ts | 48 +- packages/misskey-js/src/autogen/endpoint.ts | 9 + packages/misskey-js/src/autogen/entities.ts | 5 + packages/misskey-js/src/autogen/types.ts | 307 ++++++++++++- packages/misskey-js/src/consts.ts | 2 + 42 files changed, 1626 insertions(+), 63 deletions(-) create mode 100644 packages/backend/migration/1779092613181-antenna-share.js create mode 100644 packages/backend/src/models/AntennaFavorite.ts create mode 100644 packages/backend/src/server/api/endpoints/antennas/favorite.ts create mode 100644 packages/backend/src/server/api/endpoints/antennas/my-favorites.ts create mode 100644 packages/backend/src/server/api/endpoints/antennas/unfavorite.ts create mode 100644 packages/backend/src/server/api/endpoints/users/antennas.ts create mode 100644 packages/frontend/src/pages/user/antennas.vue diff --git a/CHANGELOG.md b/CHANGELOG.md index 760801da543..2adeb135dc1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,7 @@ ## Unreleased ### General -- +- Feat: アンテナを公開して他のユーザーが購読・お気に入りできるように (#11132) ### Client - diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index 26ef054b9f1..c4abf799679 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -2540,6 +2540,8 @@ _permissions: "read:invite-codes": "招待コードを取得する" "write:clip-favorite": "クリップのいいねを操作する" "read:clip-favorite": "クリップのいいねを見る" + "write:antenna-favorite": "アンテナのお気に入りを操作する" + "read:antenna-favorite": "アンテナのお気に入りを見る" "read:federation": "連合に関する情報を取得する" "write:report-abuse": "違反を報告する" "write:chat": "ダイレクトメッセージを操作する" @@ -2567,6 +2569,12 @@ _antennaSources: userList: "指定したリストのユーザーのノート" userBlacklist: "指定した一人または複数のユーザーを除いた全てのノート" +_antenna: + public: "公開する" + publicDescription: "公開すると、他のユーザーがこのアンテナをタイムラインとして購読・お気に入りできます。あなたから見えるノートのうち、購読ユーザーから見えるものだけが配信されます。" + publicNonAllSrcNotAllowed: "受信ソースが「全てのノート」のアンテナのみ公開できます。" + favoritedPublicAntennas: "お気に入りの公開アンテナ" + _weekday: sunday: "日曜日" monday: "月曜日" diff --git a/packages/backend/migration/1779092613181-antenna-share.js b/packages/backend/migration/1779092613181-antenna-share.js new file mode 100644 index 00000000000..a0943d3309c --- /dev/null +++ b/packages/backend/migration/1779092613181-antenna-share.js @@ -0,0 +1,32 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class AntennaShare1779092613181 { + name = 'AntennaShare1779092613181' + + /** + * @param {QueryRunner} queryRunner + */ + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "antenna" ADD "isPublic" boolean NOT NULL DEFAULT false`); + await queryRunner.query(`CREATE TABLE "antenna_favorite" ("id" character varying(32) NOT NULL, "userId" character varying(32) NOT NULL, "antennaId" character varying(32) NOT NULL, CONSTRAINT "PK_5b16eb0f3a45f0a64aca81dac90" PRIMARY KEY ("id"))`); + await queryRunner.query(`CREATE INDEX "IDX_7c4b0fce99ae5c5acea9b6c5ab" ON "antenna_favorite" ("userId") `); + await queryRunner.query(`CREATE UNIQUE INDEX "IDX_2eb5e64ed03ad0d35f17e0a0d6" ON "antenna_favorite" ("userId", "antennaId") `); + await queryRunner.query(`ALTER TABLE "antenna_favorite" ADD CONSTRAINT "FK_7c4b0fce99ae5c5acea9b6c5abe" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "antenna_favorite" ADD CONSTRAINT "FK_8a8b14f7e8c5a7e09b1a3c12d5e" FOREIGN KEY ("antennaId") REFERENCES "antenna"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + } + + /** + * @param {QueryRunner} queryRunner + */ + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "antenna_favorite" DROP CONSTRAINT "FK_8a8b14f7e8c5a7e09b1a3c12d5e"`); + await queryRunner.query(`ALTER TABLE "antenna_favorite" DROP CONSTRAINT "FK_7c4b0fce99ae5c5acea9b6c5abe"`); + await queryRunner.query(`DROP INDEX "public"."IDX_2eb5e64ed03ad0d35f17e0a0d6"`); + await queryRunner.query(`DROP INDEX "public"."IDX_7c4b0fce99ae5c5acea9b6c5ab"`); + await queryRunner.query(`DROP TABLE "antenna_favorite"`); + await queryRunner.query(`ALTER TABLE "antenna" DROP COLUMN "isPublic"`); + } +} diff --git a/packages/backend/src/core/entities/AntennaEntityService.ts b/packages/backend/src/core/entities/AntennaEntityService.ts index 1f8c8ae3e8e..fcf85463194 100644 --- a/packages/backend/src/core/entities/AntennaEntityService.ts +++ b/packages/backend/src/core/entities/AntennaEntityService.ts @@ -5,11 +5,13 @@ import { Inject, Injectable } from '@nestjs/common'; import { DI } from '@/di-symbols.js'; -import type { AntennasRepository } from '@/models/_.js'; +import type { AntennaFavoritesRepository, AntennasRepository, MiUser } from '@/models/_.js'; +import { awaitAll } from '@/misc/prelude/await-all.js'; import type { Packed } from '@/misc/json-schema.js'; import type { MiAntenna } from '@/models/Antenna.js'; import { bindThis } from '@/decorators.js'; import { IdService } from '@/core/IdService.js'; +import { UserEntityService } from './UserEntityService.js'; @Injectable() export class AntennaEntityService { @@ -17,6 +19,10 @@ export class AntennaEntityService { @Inject(DI.antennasRepository) private antennasRepository: AntennasRepository, + @Inject(DI.antennaFavoritesRepository) + private antennaFavoritesRepository: AntennaFavoritesRepository, + + private userEntityService: UserEntityService, private idService: IdService, ) { } @@ -24,10 +30,15 @@ export class AntennaEntityService { @bindThis public async pack( src: MiAntenna['id'] | MiAntenna, + me?: { id: MiUser['id'] } | null | undefined, + hint?: { + packedUser?: Packed<'UserLite'>, + }, ): Promise> { + const meId = me ? me.id : null; const antenna = typeof src === 'object' ? src : await this.antennasRepository.findOneByOrFail({ id: src }); - return { + return await awaitAll({ id: antenna.id, createdAt: this.idService.parse(antenna.id).date.toISOString(), name: antenna.name, @@ -45,6 +56,22 @@ export class AntennaEntityService { isActive: antenna.isActive, hasUnreadNote: false, // TODO notify: false, // 後方互換性のため - }; + isPublic: antenna.isPublic, + userId: antenna.userId, + user: hint?.packedUser ?? this.userEntityService.pack(antenna.user ?? antenna.userId), + favoritedCount: await this.antennaFavoritesRepository.countBy({ antennaId: antenna.id }), + isFavorited: meId ? await this.antennaFavoritesRepository.exists({ where: { antennaId: antenna.id, userId: meId } }) : undefined, + }); + } + + @bindThis + public async packMany( + antennas: MiAntenna[], + me?: { id: MiUser['id'] } | null | undefined, + ) { + const _users = antennas.map(({ user, userId }) => user ?? userId); + const _userMap = await this.userEntityService.packMany(_users, me) + .then(users => new Map(users.map(u => [u.id, u]))); + return Promise.all(antennas.map(antenna => this.pack(antenna, me, { packedUser: _userMap.get(antenna.userId) }))); } } diff --git a/packages/backend/src/di-symbols.ts b/packages/backend/src/di-symbols.ts index b9ca76233c1..429faf0c1ce 100644 --- a/packages/backend/src/di-symbols.ts +++ b/packages/backend/src/di-symbols.ts @@ -64,6 +64,7 @@ export const DI = { clipNotesRepository: Symbol('clipNotesRepository'), clipFavoritesRepository: Symbol('clipFavoritesRepository'), antennasRepository: Symbol('antennasRepository'), + antennaFavoritesRepository: Symbol('antennaFavoritesRepository'), promoNotesRepository: Symbol('promoNotesRepository'), promoReadsRepository: Symbol('promoReadsRepository'), relaysRepository: Symbol('relaysRepository'), diff --git a/packages/backend/src/models/Antenna.ts b/packages/backend/src/models/Antenna.ts index 3433cf20af1..0fbc8ffe400 100644 --- a/packages/backend/src/models/Antenna.ts +++ b/packages/backend/src/models/Antenna.ts @@ -105,6 +105,11 @@ export class MiAntenna { default: false, }) public excludeNotesInSensitiveChannel: boolean; + + @Column('boolean', { + default: false, + }) + public isPublic: boolean; } // Note for future developers: When you added a new column, // You should update ExportAntennaProcessorService and ImportAntennaProcessorService diff --git a/packages/backend/src/models/AntennaFavorite.ts b/packages/backend/src/models/AntennaFavorite.ts new file mode 100644 index 00000000000..b62bc3de97e --- /dev/null +++ b/packages/backend/src/models/AntennaFavorite.ts @@ -0,0 +1,35 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm'; +import { id } from './util/id.js'; +import { MiUser } from './User.js'; +import { MiAntenna } from './Antenna.js'; + +@Entity('antenna_favorite') +@Index(['userId', 'antennaId'], { unique: true }) +export class MiAntennaFavorite { + @PrimaryColumn(id()) + public id: string; + + @Index() + @Column(id()) + public userId: MiUser['id']; + + @ManyToOne(() => MiUser, { + onDelete: 'CASCADE', + }) + @JoinColumn() + public user: MiUser | null; + + @Column(id()) + public antennaId: MiAntenna['id']; + + @ManyToOne(() => MiAntenna, { + onDelete: 'CASCADE', + }) + @JoinColumn() + public antenna: MiAntenna | null; +} diff --git a/packages/backend/src/models/RepositoryModule.ts b/packages/backend/src/models/RepositoryModule.ts index e3db6f88385..30436523340 100644 --- a/packages/backend/src/models/RepositoryModule.ts +++ b/packages/backend/src/models/RepositoryModule.ts @@ -13,6 +13,7 @@ import { MiAnnouncement, MiAnnouncementRead, MiAntenna, + MiAntennaFavorite, MiApp, MiAuthSession, MiAvatarDecoration, @@ -394,6 +395,12 @@ const $antennasRepository: Provider = { inject: [DI.db], }; +const $antennaFavoritesRepository: Provider = { + provide: DI.antennaFavoritesRepository, + useFactory: (db: DataSource) => db.getRepository(MiAntennaFavorite).extend(miRepository as MiRepository), + inject: [DI.db], +}; + const $promoNotesRepository: Provider = { provide: DI.promoNotesRepository, useFactory: (db: DataSource) => db.getRepository(MiPromoNote).extend(miRepository as MiRepository), @@ -598,6 +605,7 @@ const $reversiGamesRepository: Provider = { $clipNotesRepository, $clipFavoritesRepository, $antennasRepository, + $antennaFavoritesRepository, $promoNotesRepository, $promoReadsRepository, $relaysRepository, @@ -676,6 +684,7 @@ const $reversiGamesRepository: Provider = { $clipNotesRepository, $clipFavoritesRepository, $antennasRepository, + $antennaFavoritesRepository, $promoNotesRepository, $promoReadsRepository, $relaysRepository, diff --git a/packages/backend/src/models/_.ts b/packages/backend/src/models/_.ts index c4528e3a773..f2003321c24 100644 --- a/packages/backend/src/models/_.ts +++ b/packages/backend/src/models/_.ts @@ -15,6 +15,7 @@ import { MiAd } from '@/models/Ad.js'; import { MiAnnouncement } from '@/models/Announcement.js'; import { MiAnnouncementRead } from '@/models/AnnouncementRead.js'; import { MiAntenna } from '@/models/Antenna.js'; +import { MiAntennaFavorite } from '@/models/AntennaFavorite.js'; import { MiApp } from '@/models/App.js'; import { MiAuthSession } from '@/models/AuthSession.js'; import { MiAvatarDecoration } from '@/models/AvatarDecoration.js'; @@ -104,6 +105,7 @@ export { MiAnnouncement, MiAnnouncementRead, MiAntenna, + MiAntennaFavorite, MiApp, MiAvatarDecoration, MiAuthSession, @@ -184,6 +186,7 @@ export type AdsRepository = Repository & MiRepository; export type AnnouncementsRepository = Repository & MiRepository; export type AnnouncementReadsRepository = Repository & MiRepository; export type AntennasRepository = Repository & MiRepository; +export type AntennaFavoritesRepository = Repository & MiRepository; export type AppsRepository = Repository & MiRepository; export type AvatarDecorationsRepository = Repository & MiRepository; export type AuthSessionsRepository = Repository & MiRepository; diff --git a/packages/backend/src/models/json-schema/antenna.ts b/packages/backend/src/models/json-schema/antenna.ts index eca75630662..c6500ebb85a 100644 --- a/packages/backend/src/models/json-schema/antenna.ts +++ b/packages/backend/src/models/json-schema/antenna.ts @@ -105,5 +105,28 @@ export const packedAntennaSchema = { optional: false, nullable: false, default: false, }, + isPublic: { + type: 'boolean', + optional: false, nullable: false, + default: false, + }, + userId: { + type: 'string', + optional: false, nullable: false, + format: 'id', + }, + user: { + type: 'object', + ref: 'UserLite', + optional: false, nullable: false, + }, + favoritedCount: { + type: 'number', + optional: false, nullable: false, + }, + isFavorited: { + type: 'boolean', + optional: true, nullable: false, + }, }, } as const; diff --git a/packages/backend/src/postgres.ts b/packages/backend/src/postgres.ts index 3dcd3f09656..33c80419af1 100644 --- a/packages/backend/src/postgres.ts +++ b/packages/backend/src/postgres.ts @@ -18,6 +18,7 @@ import { MiAd } from '@/models/Ad.js'; import { MiAnnouncement } from '@/models/Announcement.js'; import { MiAnnouncementRead } from '@/models/AnnouncementRead.js'; import { MiAntenna } from '@/models/Antenna.js'; +import { MiAntennaFavorite } from '@/models/AntennaFavorite.js'; import { MiApp } from '@/models/App.js'; import { MiAvatarDecoration } from '@/models/AvatarDecoration.js'; import { MiAuthSession } from '@/models/AuthSession.js'; @@ -227,6 +228,7 @@ export const entities = [ MiClipNote, MiClipFavorite, MiAntenna, + MiAntennaFavorite, MiPromoNote, MiPromoRead, MiRelay, diff --git a/packages/backend/src/queue/processors/ExportAntennasProcessorService.ts b/packages/backend/src/queue/processors/ExportAntennasProcessorService.ts index 053ba99005a..d66f74bf893 100644 --- a/packages/backend/src/queue/processors/ExportAntennasProcessorService.ts +++ b/packages/backend/src/queue/processors/ExportAntennasProcessorService.ts @@ -88,6 +88,7 @@ export class ExportAntennasProcessorService { withReplies: antenna.withReplies, withFile: antenna.withFile, excludeNotesInSensitiveChannel: antenna.excludeNotesInSensitiveChannel, + isPublic: antenna.isPublic, } satisfies Required)); if (antennas.length - 1 !== index) { write(', '); diff --git a/packages/backend/src/queue/processors/ImportAntennasProcessorService.ts b/packages/backend/src/queue/processors/ImportAntennasProcessorService.ts index 4c7f2d09bbd..111680bd314 100644 --- a/packages/backend/src/queue/processors/ImportAntennasProcessorService.ts +++ b/packages/backend/src/queue/processors/ImportAntennasProcessorService.ts @@ -49,6 +49,7 @@ const exportedAntennaSchema = { withReplies: { type: 'boolean' }, withFile: { type: 'boolean' }, excludeNotesInSensitiveChannel: { type: 'boolean' }, + isPublic: { type: 'boolean' }, }, required: ['name', 'src', 'keywords', 'excludeKeywords', 'users', 'caseSensitive', 'withReplies', 'withFile'], } as const satisfies Schema; @@ -98,6 +99,8 @@ export class ImportAntennasProcessorService { withReplies: antenna.withReplies, withFile: antenna.withFile, excludeNotesInSensitiveChannel: antenna.excludeNotesInSensitiveChannel, + // src=all 以外のアンテナは公開化できない (src/isPublic 整合) + isPublic: antenna.src === 'all' ? (antenna.isPublic ?? false) : false, }); this.logger.succ('Antenna created: ' + result.id); this.globalEventService.publishInternalEvent('antennaCreated', result); diff --git a/packages/backend/src/server/api/endpoint-list.ts b/packages/backend/src/server/api/endpoint-list.ts index feb39e98bb3..104545e77ac 100644 --- a/packages/backend/src/server/api/endpoint-list.ts +++ b/packages/backend/src/server/api/endpoint-list.ts @@ -113,9 +113,12 @@ export * as 'announcements' from './endpoints/announcements.js'; export * as 'announcements/show' from './endpoints/announcements/show.js'; export * as 'antennas/create' from './endpoints/antennas/create.js'; export * as 'antennas/delete' from './endpoints/antennas/delete.js'; +export * as 'antennas/favorite' from './endpoints/antennas/favorite.js'; export * as 'antennas/list' from './endpoints/antennas/list.js'; +export * as 'antennas/my-favorites' from './endpoints/antennas/my-favorites.js'; export * as 'antennas/notes' from './endpoints/antennas/notes.js'; export * as 'antennas/show' from './endpoints/antennas/show.js'; +export * as 'antennas/unfavorite' from './endpoints/antennas/unfavorite.js'; export * as 'antennas/update' from './endpoints/antennas/update.js'; export * as 'ap/get' from './endpoints/ap/get.js'; export * as 'ap/show' from './endpoints/ap/show.js'; @@ -387,6 +390,7 @@ export * as 'test' from './endpoints/test.js'; export * as 'username/available' from './endpoints/username/available.js'; export * as 'users' from './endpoints/users.js'; export * as 'users/achievements' from './endpoints/users/achievements.js'; +export * as 'users/antennas' from './endpoints/users/antennas.js'; export * as 'users/clips' from './endpoints/users/clips.js'; export * as 'users/featured-notes' from './endpoints/users/featured-notes.js'; export * as 'users/flashs' from './endpoints/users/flashs.js'; diff --git a/packages/backend/src/server/api/endpoints/antennas/create.ts b/packages/backend/src/server/api/endpoints/antennas/create.ts index c075608491a..563df878ed1 100644 --- a/packages/backend/src/server/api/endpoints/antennas/create.ts +++ b/packages/backend/src/server/api/endpoints/antennas/create.ts @@ -40,6 +40,12 @@ export const meta = { code: 'EMPTY_KEYWORD', id: '53ee222e-1ddd-4f9a-92e5-9fb82ddb463a', }, + + publicNonAllSrcNotAllowed: { + message: 'Only antennas with src=all can be public.', + code: 'PUBLIC_NON_ALL_SRC_NOT_ALLOWED', + id: 'b4e3f5cd-9f5e-4f17-9d2e-1f5e10c3f3f1', + }, }, res: { @@ -74,6 +80,7 @@ export const paramDef = { withReplies: { type: 'boolean' }, withFile: { type: 'boolean' }, excludeNotesInSensitiveChannel: { type: 'boolean' }, + isPublic: { type: 'boolean' }, }, required: ['name', 'src', 'keywords', 'excludeKeywords', 'users', 'caseSensitive', 'withReplies', 'withFile'], } as const; @@ -97,6 +104,10 @@ export default class extends Endpoint { // eslint- throw new ApiError(meta.errors.emptyKeyword); } + if (ps.isPublic === true && ps.src !== 'all') { + throw new ApiError(meta.errors.publicNonAllSrcNotAllowed); + } + const currentAntennasCount = await this.antennasRepository.countBy({ userId: me.id, }); @@ -135,11 +146,12 @@ export default class extends Endpoint { // eslint- withReplies: ps.withReplies, withFile: ps.withFile, excludeNotesInSensitiveChannel: ps.excludeNotesInSensitiveChannel, + isPublic: ps.isPublic ?? false, }); this.globalEventService.publishInternalEvent('antennaCreated', antenna); - return await this.antennaEntityService.pack(antenna); + return await this.antennaEntityService.pack(antenna, me); }); } } diff --git a/packages/backend/src/server/api/endpoints/antennas/favorite.ts b/packages/backend/src/server/api/endpoints/antennas/favorite.ts new file mode 100644 index 00000000000..e6076db6447 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/antennas/favorite.ts @@ -0,0 +1,83 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Inject, Injectable } from '@nestjs/common'; +import type { AntennasRepository, AntennaFavoritesRepository } from '@/models/_.js'; +import { IdService } from '@/core/IdService.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { DI } from '@/di-symbols.js'; +import { ApiError } from '../../error.js'; + +export const meta = { + tags: ['antennas'], + + requireCredential: true, + + prohibitMoved: true, + + kind: 'write:antenna-favorite', + + errors: { + noSuchAntenna: { + message: 'No such antenna.', + code: 'NO_SUCH_ANTENNA', + id: 'a4d3b7f0-1c1e-4e16-9a8b-3a7e1d2f4b6a', + }, + + alreadyFavorited: { + message: 'The antenna has already been favorited.', + code: 'ALREADY_FAVORITED', + id: 'd2a4e1c6-3b5e-4a8d-9f0c-1e2d3f4a5b6c', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + antennaId: { type: 'string', format: 'misskey:id' }, + }, + required: ['antennaId'], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + @Inject(DI.antennasRepository) + private antennasRepository: AntennasRepository, + + @Inject(DI.antennaFavoritesRepository) + private antennaFavoritesRepository: AntennaFavoritesRepository, + + private idService: IdService, + ) { + super(meta, paramDef, async (ps, me) => { + const antenna = await this.antennasRepository.findOneBy({ id: ps.antennaId }); + if (antenna == null) { + throw new ApiError(meta.errors.noSuchAntenna); + } + if ((antenna.userId !== me.id) && !antenna.isPublic) { + throw new ApiError(meta.errors.noSuchAntenna); + } + + const exist = await this.antennaFavoritesRepository.exists({ + where: { + antennaId: antenna.id, + userId: me.id, + }, + }); + + if (exist) { + throw new ApiError(meta.errors.alreadyFavorited); + } + + await this.antennaFavoritesRepository.insert({ + id: this.idService.gen(), + antennaId: antenna.id, + userId: me.id, + }); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/antennas/list.ts b/packages/backend/src/server/api/endpoints/antennas/list.ts index 83d29f9c8c8..5b1c1fe6b34 100644 --- a/packages/backend/src/server/api/endpoints/antennas/list.ts +++ b/packages/backend/src/server/api/endpoints/antennas/list.ts @@ -46,7 +46,7 @@ export default class extends Endpoint { // eslint- userId: me.id, }); - return await Promise.all(antennas.map(x => this.antennaEntityService.pack(x))); + return await this.antennaEntityService.packMany(antennas, me); }); } } diff --git a/packages/backend/src/server/api/endpoints/antennas/my-favorites.ts b/packages/backend/src/server/api/endpoints/antennas/my-favorites.ts new file mode 100644 index 00000000000..0db30313bfa --- /dev/null +++ b/packages/backend/src/server/api/endpoints/antennas/my-favorites.ts @@ -0,0 +1,54 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import type { AntennaFavoritesRepository } from '@/models/_.js'; +import { DI } from '@/di-symbols.js'; +import { AntennaEntityService } from '@/core/entities/AntennaEntityService.js'; + +export const meta = { + tags: ['account', 'antennas'], + + requireCredential: true, + + kind: 'read:antenna-favorite', + + res: { + type: 'array', + optional: false, nullable: false, + items: { + type: 'object', + optional: false, nullable: false, + ref: 'Antenna', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + }, + required: [], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + @Inject(DI.antennaFavoritesRepository) + private antennaFavoritesRepository: AntennaFavoritesRepository, + + private antennaEntityService: AntennaEntityService, + ) { + super(meta, paramDef, async (ps, me) => { + const favorites = await this.antennaFavoritesRepository.createQueryBuilder('favorite') + .andWhere('favorite.userId = :meId', { meId: me.id }) + .leftJoinAndSelect('favorite.antenna', 'antenna') + .getMany(); + + return this.antennaEntityService.packMany(favorites.map(x => x.antenna!), me); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/antennas/notes.ts b/packages/backend/src/server/api/endpoints/antennas/notes.ts index c59479d3701..ad4801a19cf 100644 --- a/packages/backend/src/server/api/endpoints/antennas/notes.ts +++ b/packages/backend/src/server/api/endpoints/antennas/notes.ts @@ -4,7 +4,6 @@ */ import { Inject, Injectable } from '@nestjs/common'; -import * as Redis from 'ioredis'; import { Brackets } from 'typeorm'; import { Endpoint } from '@/server/api/endpoint-base.js'; import type { NotesRepository, AntennasRepository } from '@/models/_.js'; @@ -21,7 +20,7 @@ import { ApiError } from '../../error.js'; export const meta = { tags: ['antennas', 'account', 'notes'], - requireCredential: true, + requireCredential: false, kind: 'read:account', @@ -79,13 +78,16 @@ export default class extends Endpoint { // eslint- const antenna = await this.antennasRepository.findOneBy({ id: ps.antennaId, - userId: me.id, }); if (antenna == null) { throw new ApiError(meta.errors.noSuchAntenna); } + if (!antenna.isPublic && (me == null || antenna.userId !== me.id)) { + throw new ApiError(meta.errors.noSuchAntenna); + } + // falseだった場合はアンテナの配信先が増えたことを通知したい const needPublishEvent = !antenna.isActive; @@ -111,19 +113,21 @@ export default class extends Endpoint { // eslint- .leftJoinAndSelect('reply.user', 'replyUser') .leftJoinAndSelect('renote.user', 'renoteUser'); - // -- ミュートされたチャンネル対策 - const mutingChannelIds = await this.channelMutingService - .list({ requestUserId: me.id }, { idOnly: true }) - .then(x => x.map(x => x.id)); - if (mutingChannelIds.length > 0) { - query.andWhere(new Brackets(qb => { - qb.orWhere('note.channelId IS NULL'); - qb.orWhere('note.channelId NOT IN (:...mutingChannelIds)', { mutingChannelIds }); - })); - query.andWhere(new Brackets(qb => { - qb.orWhere('note.renoteChannelId IS NULL'); - qb.orWhere('note.renoteChannelId NOT IN (:...mutingChannelIds)', { mutingChannelIds }); - })); + // -- ミュートされたチャンネル対策 (ログインユーザのみ) + if (me) { + const mutingChannelIds = await this.channelMutingService + .list({ requestUserId: me.id }, { idOnly: true }) + .then(x => x.map(x => x.id)); + if (mutingChannelIds.length > 0) { + query.andWhere(new Brackets(qb => { + qb.orWhere('note.channelId IS NULL'); + qb.orWhere('note.channelId NOT IN (:...mutingChannelIds)', { mutingChannelIds }); + })); + query.andWhere(new Brackets(qb => { + qb.orWhere('note.renoteChannelId IS NULL'); + qb.orWhere('note.renoteChannelId NOT IN (:...mutingChannelIds)', { mutingChannelIds }); + })); + } } // NOTE: センシティブ除外の設定はこのエンドポイントでは無視する。 diff --git a/packages/backend/src/server/api/endpoints/antennas/show.ts b/packages/backend/src/server/api/endpoints/antennas/show.ts index a40f187d0bc..bbaf63b8c42 100644 --- a/packages/backend/src/server/api/endpoints/antennas/show.ts +++ b/packages/backend/src/server/api/endpoints/antennas/show.ts @@ -13,7 +13,7 @@ import { ApiError } from '../../error.js'; export const meta = { tags: ['antennas', 'account'], - requireCredential: true, + requireCredential: false, kind: 'read:account', @@ -49,17 +49,19 @@ export default class extends Endpoint { // eslint- private antennaEntityService: AntennaEntityService, ) { super(meta, paramDef, async (ps, me) => { - // Fetch the antenna const antenna = await this.antennasRepository.findOneBy({ id: ps.antennaId, - userId: me.id, }); if (antenna == null) { throw new ApiError(meta.errors.noSuchAntenna); } - return await this.antennaEntityService.pack(antenna); + if (!antenna.isPublic && (me == null || antenna.userId !== me.id)) { + throw new ApiError(meta.errors.noSuchAntenna); + } + + return await this.antennaEntityService.pack(antenna, me); }); } } diff --git a/packages/backend/src/server/api/endpoints/antennas/unfavorite.ts b/packages/backend/src/server/api/endpoints/antennas/unfavorite.ts new file mode 100644 index 00000000000..4c9d09985db --- /dev/null +++ b/packages/backend/src/server/api/endpoints/antennas/unfavorite.ts @@ -0,0 +1,71 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Inject, Injectable } from '@nestjs/common'; +import type { AntennasRepository, AntennaFavoritesRepository } from '@/models/_.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { DI } from '@/di-symbols.js'; +import { ApiError } from '../../error.js'; + +export const meta = { + tags: ['antennas'], + + requireCredential: true, + + prohibitMoved: true, + + kind: 'write:antenna-favorite', + + errors: { + noSuchAntenna: { + message: 'No such antenna.', + code: 'NO_SUCH_ANTENNA', + id: 'e1f2a3b4-c5d6-4e7f-8a9b-0c1d2e3f4a5b', + }, + + notFavorited: { + message: 'You have not favorited the antenna.', + code: 'NOT_FAVORITED', + id: 'b6a7c8d9-e0f1-4a2b-9c3d-4e5f6a7b8c9d', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + antennaId: { type: 'string', format: 'misskey:id' }, + }, + required: ['antennaId'], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + @Inject(DI.antennasRepository) + private antennasRepository: AntennasRepository, + + @Inject(DI.antennaFavoritesRepository) + private antennaFavoritesRepository: AntennaFavoritesRepository, + ) { + super(meta, paramDef, async (ps, me) => { + const antenna = await this.antennasRepository.findOneBy({ id: ps.antennaId }); + if (antenna == null) { + throw new ApiError(meta.errors.noSuchAntenna); + } + + const exist = await this.antennaFavoritesRepository.findOneBy({ + antennaId: antenna.id, + userId: me.id, + }); + + if (exist == null) { + throw new ApiError(meta.errors.notFavorited); + } + + await this.antennaFavoritesRepository.delete(exist.id); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/antennas/update.ts b/packages/backend/src/server/api/endpoints/antennas/update.ts index 53fc4db1b77..99f7f79b666 100644 --- a/packages/backend/src/server/api/endpoints/antennas/update.ts +++ b/packages/backend/src/server/api/endpoints/antennas/update.ts @@ -38,6 +38,12 @@ export const meta = { code: 'EMPTY_KEYWORD', id: '721aaff6-4e1b-4d88-8de6-877fae9f68c4', }, + + publicNonAllSrcNotAllowed: { + message: 'Only antennas with src=all can be public.', + code: 'PUBLIC_NON_ALL_SRC_NOT_ALLOWED', + id: 'c5f3a7b9-2d3e-4c1a-8b5f-7e9a1b2c3d4e', + }, }, res: { @@ -73,6 +79,7 @@ export const paramDef = { withReplies: { type: 'boolean' }, withFile: { type: 'boolean' }, excludeNotesInSensitiveChannel: { type: 'boolean' }, + isPublic: { type: 'boolean' }, }, required: ['antennaId'], } as const; @@ -105,6 +112,12 @@ export default class extends Endpoint { // eslint- throw new ApiError(meta.errors.noSuchAntenna); } + const nextSrc = ps.src ?? antenna.src; + const nextIsPublic = ps.isPublic ?? antenna.isPublic; + if (nextIsPublic === true && nextSrc !== 'all') { + throw new ApiError(meta.errors.publicNonAllSrcNotAllowed); + } + let userList; if ((ps.src === 'list' || antenna.src === 'list') && ps.userListId) { @@ -131,13 +144,14 @@ export default class extends Endpoint { // eslint- withReplies: ps.withReplies, withFile: ps.withFile, excludeNotesInSensitiveChannel: ps.excludeNotesInSensitiveChannel, + isPublic: ps.isPublic, isActive: true, lastUsedAt: new Date(), }); this.globalEventService.publishInternalEvent('antennaUpdated', await this.antennasRepository.findOneByOrFail({ id: antenna.id })); - return await this.antennaEntityService.pack(antenna.id); + return await this.antennaEntityService.pack(antenna.id, me); }); } } diff --git a/packages/backend/src/server/api/endpoints/users/antennas.ts b/packages/backend/src/server/api/endpoints/users/antennas.ts new file mode 100644 index 00000000000..d36fc5375ba --- /dev/null +++ b/packages/backend/src/server/api/endpoints/users/antennas.ts @@ -0,0 +1,63 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Inject, Injectable } from '@nestjs/common'; +import type { AntennasRepository } from '@/models/_.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { QueryService } from '@/core/QueryService.js'; +import { AntennaEntityService } from '@/core/entities/AntennaEntityService.js'; +import { DI } from '@/di-symbols.js'; + +export const meta = { + tags: ['users', 'antennas'], + + description: 'Show all public antennas this user owns.', + + res: { + type: 'array', + optional: false, nullable: false, + items: { + type: 'object', + optional: false, nullable: false, + ref: 'Antenna', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + userId: { type: 'string', format: 'misskey:id' }, + limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, + sinceId: { type: 'string', format: 'misskey:id' }, + untilId: { type: 'string', format: 'misskey:id' }, + sinceDate: { type: 'integer' }, + untilDate: { type: 'integer' }, + }, + required: ['userId'], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + @Inject(DI.antennasRepository) + private antennasRepository: AntennasRepository, + + private antennaEntityService: AntennaEntityService, + private queryService: QueryService, + ) { + super(meta, paramDef, async (ps, me) => { + const query = this.queryService.makePaginationQuery(this.antennasRepository.createQueryBuilder('antenna'), ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate) + .andWhere('antenna.userId = :userId', { userId: ps.userId }) + .andWhere('antenna.isPublic = true'); + + const antennas = await query + .limit(ps.limit) + .getMany(); + + return await this.antennaEntityService.packMany(antennas, me); + }); + } +} diff --git a/packages/backend/src/server/api/stream/channels/antenna.ts b/packages/backend/src/server/api/stream/channels/antenna.ts index b7f863b3551..3eef21a38bb 100644 --- a/packages/backend/src/server/api/stream/channels/antenna.ts +++ b/packages/backend/src/server/api/stream/channels/antenna.ts @@ -44,14 +44,12 @@ export class AntennaChannel extends Channel { this.antennaId = params.antennaId; - const antennaExists = await this.antennasReposiotry.exists({ - where: { - id: this.antennaId, - userId: this.user.id, - }, + const antenna = await this.antennasReposiotry.findOneBy({ + id: this.antennaId, }); - if (!antennaExists) return false; + if (antenna == null) return false; + if (!antenna.isPublic && antenna.userId !== this.user.id) return false; // Subscribe stream this.subscriber.on(`antennaStream:${this.antennaId}`, this.onEvent); diff --git a/packages/backend/test/e2e/antennas.ts b/packages/backend/test/e2e/antennas.ts index ea7cd77d665..e14202c1f7b 100644 --- a/packages/backend/test/e2e/antennas.ts +++ b/packages/backend/test/e2e/antennas.ts @@ -167,6 +167,11 @@ describe('アンテナ', () => { excludeBots: false, localOnly: false, notify: false, + isPublic: false, + userId: response.userId, + user: response.user, + favoritedCount: response.favoritedCount, + isFavorited: response.isFavorited, }; assert.deepStrictEqual(response, expected); }); @@ -728,4 +733,411 @@ describe('アンテナ', () => { //#endregion }); + + //#region 共有アンテナ (isPublic, favorite) + + describe('の共有 (isPublic)', () => { + const sharedKeyword = 'sharedAntennaKeyword'; + + describe('作成・更新の制約', () => { + test.each([ + { src: 'home' as const }, + { src: 'users' as const }, + { src: 'users_blacklist' as const }, + { src: 'list' as const, userListId: () => aliceList.id }, + ])('src=$src と isPublic=true は同時に指定できない (create)', async ({ src, userListId }) => { + await failedApiCall({ + endpoint: 'antennas/create', + parameters: { ...defaultParam, src, userListId: userListId ? userListId() : null, isPublic: true }, + user: alice, + }, { + status: 400, + code: 'PUBLIC_NON_ALL_SRC_NOT_ALLOWED', + id: 'b4e3f5cd-9f5e-4f17-9d2e-1f5e10c3f3f1', + }); + }); + + test('既存の公開アンテナ (src=all) を src=users に変更しようとするとエラー', async () => { + const antenna = await successfulApiCall({ + endpoint: 'antennas/create', + parameters: { ...defaultParam, isPublic: true }, + user: alice, + }); + await failedApiCall({ + endpoint: 'antennas/update', + parameters: { antennaId: antenna.id, ...defaultParam, src: 'users', isPublic: true }, + user: alice, + }, { + status: 400, + code: 'PUBLIC_NON_ALL_SRC_NOT_ALLOWED', + id: 'c5f3a7b9-2d3e-4c1a-8b5f-7e9a1b2c3d4e', + }); + }); + + test('既存の src=list アンテナを isPublic=true に変更しようとするとエラー', async () => { + const antenna = await successfulApiCall({ + endpoint: 'antennas/create', + parameters: { ...defaultParam, src: 'list', userListId: aliceList.id }, + user: alice, + }); + await failedApiCall({ + endpoint: 'antennas/update', + parameters: { antennaId: antenna.id, ...defaultParam, src: 'list', userListId: aliceList.id, isPublic: true }, + user: alice, + }, { + status: 400, + code: 'PUBLIC_NON_ALL_SRC_NOT_ALLOWED', + id: 'c5f3a7b9-2d3e-4c1a-8b5f-7e9a1b2c3d4e', + }); + }); + }); + + describe('アクセス制御', () => { + test('公開アンテナは他ユーザでも show できる', async () => { + const antenna = await successfulApiCall({ + endpoint: 'antennas/create', + parameters: { ...defaultParam, isPublic: true }, + user: alice, + }); + const response = await successfulApiCall({ + endpoint: 'antennas/show', + parameters: { antennaId: antenna.id }, + user: carol, + }); + assert.strictEqual(response.id, antenna.id); + assert.strictEqual(response.isPublic, true); + assert.strictEqual(response.userId, alice.id); + }); + + test('公開アンテナは未ログインでも show できる', async () => { + const antenna = await successfulApiCall({ + endpoint: 'antennas/create', + parameters: { ...defaultParam, isPublic: true }, + user: alice, + }); + const response = await successfulApiCall({ + endpoint: 'antennas/show', + parameters: { antennaId: antenna.id }, + user: undefined, + }); + assert.strictEqual(response.id, antenna.id); + }); + + test('非公開アンテナは他ユーザだと NO_SUCH_ANTENNA', async () => { + const antenna = await successfulApiCall({ + endpoint: 'antennas/create', + parameters: { ...defaultParam }, + user: alice, + }); + await failedApiCall({ + endpoint: 'antennas/show', + parameters: { antennaId: antenna.id }, + user: carol, + }, { + status: 400, + code: 'NO_SUCH_ANTENNA', + id: 'c06569fb-b025-4f23-b22d-1fcd20d2816b', + }); + }); + + test('他人の公開アンテナは update/delete できない', async () => { + const antenna = await successfulApiCall({ + endpoint: 'antennas/create', + parameters: { ...defaultParam, isPublic: true }, + user: alice, + }); + await failedApiCall({ + endpoint: 'antennas/update', + parameters: { antennaId: antenna.id, ...defaultParam, isPublic: true, name: 'hijack' }, + user: bob, + }, { + status: 400, + code: 'NO_SUCH_ANTENNA', + id: '10c673ac-8852-48eb-aa1f-f5b67f069290', + }); + await failedApiCall({ + endpoint: 'antennas/delete', + parameters: { antennaId: antenna.id }, + user: bob, + }, { + status: 400, + code: 'NO_SUCH_ANTENNA', + id: 'b34dcf9d-348f-44bb-99d0-6c9314cfe2df', + }); + }); + + test('users/antennas は対象ユーザの公開アンテナだけを返す', async () => { + const publicAntenna = await successfulApiCall({ + endpoint: 'antennas/create', + parameters: { ...defaultParam, isPublic: true, name: 'public-1' }, + user: alice, + }); + await successfulApiCall({ + endpoint: 'antennas/create', + parameters: { ...defaultParam, isPublic: false, name: 'private-1' }, + user: alice, + }); + const response = await successfulApiCall({ + endpoint: 'users/antennas', + parameters: { userId: alice.id }, + user: carol, + }); + assert.strictEqual(response.length, 1); + assert.strictEqual(response[0].id, publicAntenna.id); + }); + }); + + describe('ノートの可視性 (漏洩検証)', () => { + test('所有者宛の followers 限定ノートは、投稿者をフォローしていない購読者には漏れない', async () => { + // alice は userFollowedByAlice をフォローしている (beforeAll で設定済) + // carol は userFollowedByAlice をフォローしていない + const antenna = await successfulApiCall({ + endpoint: 'antennas/create', + parameters: { ...defaultParam, isPublic: true, keywords: [[sharedKeyword]] }, + user: alice, + }); + const note = await post(userFollowedByAlice, { text: `${sharedKeyword} followers-only`, visibility: 'followers' }); + + // alice (所有者) 視点では含まれる + const aliceView = await successfulApiCall({ + endpoint: 'antennas/notes', + parameters: { antennaId: antenna.id }, + user: alice, + }); + assert.ok(aliceView.some(n => n.id === note.id), 'owner should see the followers-only note'); + + // carol (購読者・未フォロー) 視点では漏れない + const carolView = await successfulApiCall({ + endpoint: 'antennas/notes', + parameters: { antennaId: antenna.id }, + user: carol, + }); + assert.ok(!carolView.some(n => n.id === note.id), 'non-follower subscriber must not see the followers-only note'); + }); + + test('所有者宛の specified ノートは、宛先でない購読者には漏れない', async () => { + const antenna = await successfulApiCall({ + endpoint: 'antennas/create', + parameters: { ...defaultParam, isPublic: true, keywords: [[sharedKeyword]] }, + user: alice, + }); + const note = await post(bob, { + text: `${sharedKeyword} dm`, + visibility: 'specified', + visibleUserIds: [alice.id], + }); + + const aliceView = await successfulApiCall({ + endpoint: 'antennas/notes', + parameters: { antennaId: antenna.id }, + user: alice, + }); + assert.ok(aliceView.some(n => n.id === note.id), 'owner should see specified note'); + + const carolView = await successfulApiCall({ + endpoint: 'antennas/notes', + parameters: { antennaId: antenna.id }, + user: carol, + }); + assert.ok(!carolView.some(n => n.id === note.id), 'unrelated subscriber must not see specified note'); + }); + + test('投稿者が購読者をブロックしている場合、そのノートは含まれない', async () => { + const antenna = await successfulApiCall({ + endpoint: 'antennas/create', + parameters: { ...defaultParam, isPublic: true, keywords: [[sharedKeyword]] }, + user: alice, + }); + // bob が carol をブロックする + await api('blocking/create', { userId: carol.id }, bob); + const note = await post(bob, { text: `${sharedKeyword} bob-blocks-carol` }); + + const carolView = await successfulApiCall({ + endpoint: 'antennas/notes', + parameters: { antennaId: antenna.id }, + user: carol, + }); + assert.ok(!carolView.some(n => n.id === note.id), 'note from a user who blocked the subscriber must not be visible'); + + // 後片付け + await api('blocking/delete', { userId: carol.id }, bob); + }); + + test('公開アンテナのノートは未ログインでも公開ノートのみ取得できる', async () => { + const antenna = await successfulApiCall({ + endpoint: 'antennas/create', + parameters: { ...defaultParam, isPublic: true, keywords: [[sharedKeyword]] }, + user: alice, + }); + const publicNote = await post(bob, { text: `${sharedKeyword} public` }); + const followersNote = await post(userFollowedByAlice, { text: `${sharedKeyword} followers`, visibility: 'followers' }); + + const anonView = await successfulApiCall({ + endpoint: 'antennas/notes', + parameters: { antennaId: antenna.id }, + user: undefined, + }); + assert.ok(anonView.some(n => n.id === publicNote.id), 'anon should see public note'); + assert.ok(!anonView.some(n => n.id === followersNote.id), 'anon must not see followers-only note'); + }); + + test('非公開アンテナのノートは他ユーザだと取得できない', async () => { + const antenna = await successfulApiCall({ + endpoint: 'antennas/create', + parameters: { ...defaultParam, keywords: [[sharedKeyword]] }, + user: alice, + }); + await failedApiCall({ + endpoint: 'antennas/notes', + parameters: { antennaId: antenna.id }, + user: carol, + }, { + status: 400, + code: 'NO_SUCH_ANTENNA', + id: '850926e0-fd3b-49b6-b69a-b28a5dbd82fe', + }); + }); + }); + + describe('お気に入り (favorite)', () => { + test('公開アンテナを他ユーザがお気に入りできる', async () => { + const antenna = await successfulApiCall({ + endpoint: 'antennas/create', + parameters: { ...defaultParam, isPublic: true }, + user: alice, + }); + await successfulApiCall({ + endpoint: 'antennas/favorite', + parameters: { antennaId: antenna.id }, + user: carol, + }); + const shown = await successfulApiCall({ + endpoint: 'antennas/show', + parameters: { antennaId: antenna.id }, + user: carol, + }); + assert.strictEqual(shown.isFavorited, true); + assert.strictEqual(shown.favoritedCount, 1); + }); + + test('非公開アンテナを他ユーザはお気に入りできない', async () => { + const antenna = await successfulApiCall({ + endpoint: 'antennas/create', + parameters: { ...defaultParam }, + user: alice, + }); + await failedApiCall({ + endpoint: 'antennas/favorite', + parameters: { antennaId: antenna.id }, + user: carol, + }, { + status: 400, + code: 'NO_SUCH_ANTENNA', + id: 'a4d3b7f0-1c1e-4e16-9a8b-3a7e1d2f4b6a', + }); + }); + + test('自分のアンテナはお気に入り可 (公開非公開問わず)', async () => { + const antenna = await successfulApiCall({ + endpoint: 'antennas/create', + parameters: { ...defaultParam }, + user: alice, + }); + await successfulApiCall({ + endpoint: 'antennas/favorite', + parameters: { antennaId: antenna.id }, + user: alice, + }); + }); + + test('重複 favorite はエラー', async () => { + const antenna = await successfulApiCall({ + endpoint: 'antennas/create', + parameters: { ...defaultParam, isPublic: true }, + user: alice, + }); + await successfulApiCall({ + endpoint: 'antennas/favorite', + parameters: { antennaId: antenna.id }, + user: carol, + }); + await failedApiCall({ + endpoint: 'antennas/favorite', + parameters: { antennaId: antenna.id }, + user: carol, + }, { + status: 400, + code: 'ALREADY_FAVORITED', + id: 'd2a4e1c6-3b5e-4a8d-9f0c-1e2d3f4a5b6c', + }); + }); + + test('unfavorite で取り消せる / 未 favorite で NOT_FAVORITED', async () => { + const antenna = await successfulApiCall({ + endpoint: 'antennas/create', + parameters: { ...defaultParam, isPublic: true }, + user: alice, + }); + await failedApiCall({ + endpoint: 'antennas/unfavorite', + parameters: { antennaId: antenna.id }, + user: carol, + }, { + status: 400, + code: 'NOT_FAVORITED', + id: 'b6a7c8d9-e0f1-4a2b-9c3d-4e5f6a7b8c9d', + }); + await successfulApiCall({ + endpoint: 'antennas/favorite', + parameters: { antennaId: antenna.id }, + user: carol, + }); + await successfulApiCall({ + endpoint: 'antennas/unfavorite', + parameters: { antennaId: antenna.id }, + user: carol, + }); + const shown = await successfulApiCall({ + endpoint: 'antennas/show', + parameters: { antennaId: antenna.id }, + user: carol, + }); + assert.strictEqual(shown.isFavorited, false); + assert.strictEqual(shown.favoritedCount, 0); + }); + + test('my-favorites でお気に入りした公開アンテナの一覧が取れる', async () => { + const a1 = await successfulApiCall({ + endpoint: 'antennas/create', + parameters: { ...defaultParam, isPublic: true, name: 'fav-1' }, + user: alice, + }); + const a2 = await successfulApiCall({ + endpoint: 'antennas/create', + parameters: { ...defaultParam, isPublic: true, name: 'fav-2' }, + user: bob, + }); + await successfulApiCall({ + endpoint: 'antennas/favorite', + parameters: { antennaId: a1.id }, + user: carol, + }); + await successfulApiCall({ + endpoint: 'antennas/favorite', + parameters: { antennaId: a2.id }, + user: carol, + }); + const favs = await successfulApiCall({ + endpoint: 'antennas/my-favorites', + parameters: {}, + user: carol, + }); + const ids = new Set(favs.map(x => x.id)); + assert.ok(ids.has(a1.id)); + assert.ok(ids.has(a2.id)); + }); + }); + }); + + //#endregion }); diff --git a/packages/frontend/src/cache.ts b/packages/frontend/src/cache.ts index 39cf73feb85..d465d6c8e2c 100644 --- a/packages/frontend/src/cache.ts +++ b/packages/frontend/src/cache.ts @@ -11,4 +11,5 @@ export const clipsCache = new Cache(1000 * 60 * 30, () export const rolesCache = new Cache(1000 * 60 * 30, () => misskeyApi('admin/roles/list', { limit: 30 })); export const userListsCache = new Cache(1000 * 60 * 30, () => misskeyApi('users/lists/list')); export const antennasCache = new Cache(1000 * 60 * 30, () => misskeyApi('antennas/list', { limit: 30 })); +export const favoritedAntennasCache = new Cache(1000 * 60 * 30, () => misskeyApi('antennas/my-favorites')); export const favoritedChannelsCache = new Cache(1000 * 60 * 30, () => misskeyApi('channels/my-favorites', { limit: 100 })); diff --git a/packages/frontend/src/components/MkAntennaEditor.vue b/packages/frontend/src/components/MkAntennaEditor.vue index a41fdbc45d6..a19376fb66d 100644 --- a/packages/frontend/src/components/MkAntennaEditor.vue +++ b/packages/frontend/src/components/MkAntennaEditor.vue @@ -34,6 +34,13 @@ SPDX-License-Identifier: AGPL-3.0-only {{ i18n.ts.caseSensitive }} {{ i18n.ts.withFileAntenna }} {{ i18n.ts.excludeNotesInSensitiveChannel }} + + {{ i18n.ts._antenna.public }} + +
@@ -60,7 +67,7 @@ import { i18n } from '@/i18n.js'; import { deepMerge } from '@/utility/merge.js'; import { useMkSelect } from '@/composables/use-mkselect.js'; -type PartialAllowedAntenna = Omit & { +type PartialAllowedAntenna = Omit & { id?: string; createdAt?: string; updatedAt?: string; @@ -86,6 +93,7 @@ const initialAntenna = deepMerge(props.antenna ?? {}, { isActive: true, hasUnreadNote: false, notify: false, + isPublic: false, }); const emit = defineEmits<{ @@ -132,12 +140,17 @@ const excludeBots = ref(initialAntenna.excludeBots); const withReplies = ref(initialAntenna.withReplies); const withFile = ref(initialAntenna.withFile); const excludeNotesInSensitiveChannel = ref(initialAntenna.excludeNotesInSensitiveChannel); +const isPublic = ref(initialAntenna.isPublic); const userLists = ref(null); watch(() => src.value, async () => { if (src.value === 'list' && userLists.value === null) { userLists.value = await misskeyApi('users/lists/list'); } + // 公開できるのは src=all のときだけ + if (src.value !== 'all' && isPublic.value) { + isPublic.value = false; + } }); async function saveAntenna() { @@ -151,6 +164,7 @@ async function saveAntenna() { excludeNotesInSensitiveChannel: excludeNotesInSensitiveChannel.value, caseSensitive: caseSensitive.value, localOnly: localOnly.value, + isPublic: isPublic.value, users: users.value.trim().split('\n').map(x => x.trim()), keywords: keywords.value.trim().split('\n').map(x => x.trim().split(' ')), excludeKeywords: excludeKeywords.value.trim().split('\n').map(x => x.trim().split(' ')), @@ -197,4 +211,10 @@ function addUser() { padding: 24px 0; border-top: solid 0.5px var(--MI_THEME-divider); } + +.publicNonAllWarn { + display: block; + margin-top: 4px; + color: var(--MI_THEME-warn); +} diff --git a/packages/frontend/src/pages/antenna-timeline.vue b/packages/frontend/src/pages/antenna-timeline.vue index 9030fa0e294..e39e8574642 100644 --- a/packages/frontend/src/pages/antenna-timeline.vue +++ b/packages/frontend/src/pages/antenna-timeline.vue @@ -6,6 +6,10 @@ SPDX-License-Identifier: AGPL-3.0-only