From 39748ea0c38c5bfbdadf6cd2772fae636337c470 Mon Sep 17 00:00:00 2001
From: Chocolate Pie <106949016+chocolate-pie@users.noreply.github.com>
Date: Sat, 6 May 2023 08:17:55 +0900
Subject: [PATCH] =?UTF-8?q?feat:=20=E3=82=A2=E3=83=B3=E3=83=86=E3=83=8A?=
 =?UTF-8?q?=E3=81=AE=E3=82=A8=E3=82=AF=E3=82=B9=E3=83=9D=E3=83=BC=E3=83=88?=
 =?UTF-8?q?=E3=83=BB=E3=82=A4=E3=83=B3=E3=83=9D=E3=83=BC=E3=83=88=20(#1075?=
 =?UTF-8?q?4)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

* feat: アンテナのエクスポートに対応 (misskey-dev/misskey#10690)

* feat: アンテナのインポートに対応 (misskey-dev/misskey#10690)

* fix: タイポを修正

* feat: ユーザーリストをサポート

* fix: バグを直した

* fix: バグを直した

* fix: 適当に決めた変数名を変更

* fix

* fix: 変数の変更、リファクタリング
---
 packages/backend/src/core/QueueService.ts     | 24 ++++-
 .../src/queue/DbQueueProcessorsService.ts     |  6 ++
 .../backend/src/queue/QueueProcessorModule.ts |  4 +
 .../ExportAntennasProcessorService.ts         | 99 +++++++++++++++++++
 .../ImportAntennasProcessorService.ts         | 93 +++++++++++++++++
 packages/backend/src/queue/types.ts           | 12 +++
 .../backend/src/server/api/EndpointsModule.ts |  8 ++
 packages/backend/src/server/api/endpoints.ts  |  4 +
 .../server/api/endpoints/i/export-antennas.ts | 30 ++++++
 .../server/api/endpoints/i/import-antennas.ts | 81 +++++++++++++++
 .../src/pages/settings/import-export.vue      | 24 +++++
 11 files changed, 384 insertions(+), 1 deletion(-)
 create mode 100644 packages/backend/src/queue/processors/ExportAntennasProcessorService.ts
 create mode 100644 packages/backend/src/queue/processors/ImportAntennasProcessorService.ts
 create mode 100644 packages/backend/src/server/api/endpoints/i/export-antennas.ts
 create mode 100644 packages/backend/src/server/api/endpoints/i/import-antennas.ts

diff --git a/packages/backend/src/core/QueueService.ts b/packages/backend/src/core/QueueService.ts
index 3590846d75..b4ffffecc0 100644
--- a/packages/backend/src/core/QueueService.ts
+++ b/packages/backend/src/core/QueueService.ts
@@ -1,15 +1,16 @@
 import { Inject, Injectable } from '@nestjs/common';
 import { v4 as uuid } from 'uuid';
+import Bull from 'bull';
 import type { IActivity } from '@/core/activitypub/type.js';
 import type { DriveFile } from '@/models/entities/DriveFile.js';
 import type { Webhook, webhookEventTypes } from '@/models/entities/Webhook.js';
 import type { Config } from '@/config.js';
 import { DI } from '@/di-symbols.js';
 import { bindThis } from '@/decorators.js';
+import type { Antenna } from '@/server/api/endpoints/i/import-antennas.js';
 import type { DbQueue, DeliverQueue, EndedPollNotificationQueue, InboxQueue, ObjectStorageQueue, RelationshipQueue, SystemQueue, WebhookDeliverQueue } from './QueueModule.js';
 import type { DbJobData, RelationshipJobData, ThinUser } from '../queue/types.js';
 import type httpSignature from '@peertube/http-signature';
-import Bull from 'bull';
 
 @Injectable()
 export class QueueService {
@@ -152,6 +153,16 @@ export class QueueService {
 		});
 	}
 
+	@bindThis
+	public createExportAntennasJob(user: ThinUser) {
+		return this.dbQueue.add('exportAntennas', {
+			user: { id: user.id },
+		}, {
+			removeOnComplete: true,
+			removeOnFail: true,
+		});
+	}
+
 	@bindThis
 	public createImportFollowingJob(user: ThinUser, fileId: DriveFile['id']) {
 		return this.dbQueue.add('importFollowing', {
@@ -235,6 +246,17 @@ export class QueueService {
 		});
 	}
 
+	@bindThis
+	public createImportAntennasJob(user: ThinUser, antenna: Antenna) {
+		return this.dbQueue.add('importAntennas', {
+			user: { id: user.id },
+			antenna,
+		}, {
+			removeOnComplete: true,
+			removeOnFail: true,
+		});
+	}
+
 	@bindThis
 	public createDeleteAccountJob(user: ThinUser, opts: { soft?: boolean; } = {}) {
 		return this.dbQueue.add('deleteAccount', {
diff --git a/packages/backend/src/queue/DbQueueProcessorsService.ts b/packages/backend/src/queue/DbQueueProcessorsService.ts
index 233a36dd04..df8ac3a301 100644
--- a/packages/backend/src/queue/DbQueueProcessorsService.ts
+++ b/packages/backend/src/queue/DbQueueProcessorsService.ts
@@ -9,11 +9,13 @@ import { ExportFollowingProcessorService } from './processors/ExportFollowingPro
 import { ExportMutingProcessorService } from './processors/ExportMutingProcessorService.js';
 import { ExportBlockingProcessorService } from './processors/ExportBlockingProcessorService.js';
 import { ExportUserListsProcessorService } from './processors/ExportUserListsProcessorService.js';
+import { ExportAntennasProcessorService } from './processors/ExportAntennasProcessorService.js';
 import { ImportFollowingProcessorService } from './processors/ImportFollowingProcessorService.js';
 import { ImportMutingProcessorService } from './processors/ImportMutingProcessorService.js';
 import { ImportBlockingProcessorService } from './processors/ImportBlockingProcessorService.js';
 import { ImportUserListsProcessorService } from './processors/ImportUserListsProcessorService.js';
 import { ImportCustomEmojisProcessorService } from './processors/ImportCustomEmojisProcessorService.js';
+import { ImportAntennasProcessorService } from './processors/ImportAntennasProcessorService.js';
 import { DeleteAccountProcessorService } from './processors/DeleteAccountProcessorService.js';
 import { ExportFavoritesProcessorService } from './processors/ExportFavoritesProcessorService.js';
 import type Bull from 'bull';
@@ -32,11 +34,13 @@ export class DbQueueProcessorsService {
 		private exportMutingProcessorService: ExportMutingProcessorService,
 		private exportBlockingProcessorService: ExportBlockingProcessorService,
 		private exportUserListsProcessorService: ExportUserListsProcessorService,
+		private exportAntennasProcessorService: ExportAntennasProcessorService,
 		private importFollowingProcessorService: ImportFollowingProcessorService,
 		private importMutingProcessorService: ImportMutingProcessorService,
 		private importBlockingProcessorService: ImportBlockingProcessorService,
 		private importUserListsProcessorService: ImportUserListsProcessorService,
 		private importCustomEmojisProcessorService: ImportCustomEmojisProcessorService,
+		private importAntennasProcessorService: ImportAntennasProcessorService,
 		private deleteAccountProcessorService: DeleteAccountProcessorService,
 	) {
 	}
@@ -51,6 +55,7 @@ export class DbQueueProcessorsService {
 		q.process('exportMuting', (job, done) => this.exportMutingProcessorService.process(job, done));
 		q.process('exportBlocking', (job, done) => this.exportBlockingProcessorService.process(job, done));
 		q.process('exportUserLists', (job, done) => this.exportUserListsProcessorService.process(job, done));
+		q.process('exportAntennas', (job, done) => this.exportAntennasProcessorService.process(job, done));
 		q.process('importFollowing', (job, done) => this.importFollowingProcessorService.process(job, done));
 		q.process('importFollowingToDb', (job) => this.importFollowingProcessorService.processDb(job));
 		q.process('importMuting', (job, done) => this.importMutingProcessorService.process(job, done));
@@ -58,6 +63,7 @@ export class DbQueueProcessorsService {
 		q.process('importBlockingToDb', (job) => this.importBlockingProcessorService.processDb(job));
 		q.process('importUserLists', (job, done) => this.importUserListsProcessorService.process(job, done));
 		q.process('importCustomEmojis', (job, done) => this.importCustomEmojisProcessorService.process(job, done));
+		q.process('importAntennas', (job, done) => this.importAntennasProcessorService.process(job, done));
 		q.process('deleteAccount', (job) => this.deleteAccountProcessorService.process(job));
 	}
 }
diff --git a/packages/backend/src/queue/QueueProcessorModule.ts b/packages/backend/src/queue/QueueProcessorModule.ts
index 4db9b38547..3d4cc77321 100644
--- a/packages/backend/src/queue/QueueProcessorModule.ts
+++ b/packages/backend/src/queue/QueueProcessorModule.ts
@@ -24,11 +24,13 @@ import { ExportFollowingProcessorService } from './processors/ExportFollowingPro
 import { ExportMutingProcessorService } from './processors/ExportMutingProcessorService.js';
 import { ExportNotesProcessorService } from './processors/ExportNotesProcessorService.js';
 import { ExportUserListsProcessorService } from './processors/ExportUserListsProcessorService.js';
+import { ExportAntennasProcessorService } from './processors/ExportAntennasProcessorService.js';
 import { ImportBlockingProcessorService } from './processors/ImportBlockingProcessorService.js';
 import { ImportCustomEmojisProcessorService } from './processors/ImportCustomEmojisProcessorService.js';
 import { ImportFollowingProcessorService } from './processors/ImportFollowingProcessorService.js';
 import { ImportMutingProcessorService } from './processors/ImportMutingProcessorService.js';
 import { ImportUserListsProcessorService } from './processors/ImportUserListsProcessorService.js';
+import { ImportAntennasProcessorService } from './processors/ImportAntennasProcessorService.js';
 import { ResyncChartsProcessorService } from './processors/ResyncChartsProcessorService.js';
 import { TickChartsProcessorService } from './processors/TickChartsProcessorService.js';
 import { AggregateRetentionProcessorService } from './processors/AggregateRetentionProcessorService.js';
@@ -55,11 +57,13 @@ import { RelationshipProcessorService } from './processors/RelationshipProcessor
 		ExportMutingProcessorService,
 		ExportBlockingProcessorService,
 		ExportUserListsProcessorService,
+		ExportAntennasProcessorService,
 		ImportFollowingProcessorService,
 		ImportMutingProcessorService,
 		ImportBlockingProcessorService,
 		ImportUserListsProcessorService,
 		ImportCustomEmojisProcessorService,
+		ImportAntennasProcessorService,
 		DeleteAccountProcessorService,
 		DeleteFileProcessorService,
 		CleanRemoteFilesProcessorService,
diff --git a/packages/backend/src/queue/processors/ExportAntennasProcessorService.ts b/packages/backend/src/queue/processors/ExportAntennasProcessorService.ts
new file mode 100644
index 0000000000..dba764dc35
--- /dev/null
+++ b/packages/backend/src/queue/processors/ExportAntennasProcessorService.ts
@@ -0,0 +1,99 @@
+import fs from 'node:fs';
+import { Inject, Injectable } from '@nestjs/common';
+import { format as DateFormat } from 'date-fns';
+import { In } from 'typeorm';
+import { DI } from '@/di-symbols.js';
+import type { AntennasRepository, UsersRepository, UserListJoiningsRepository, User } from '@/models/index.js';
+import type { Config } from '@/config.js';
+import Logger from '@/logger.js';
+import { DriveService } from '@/core/DriveService.js';
+import { bindThis } from '@/decorators.js';
+import { createTemp } from '@/misc/create-temp.js';
+import { QueueLoggerService } from '../QueueLoggerService.js';
+import type { DBExportAntennasData } from '../types.js';
+import type Bull from 'bull';
+import { UtilityService } from '@/core/UtilityService.js';
+
+@Injectable()
+export class ExportAntennasProcessorService {
+	private logger: Logger;
+
+	constructor (
+		@Inject(DI.config)
+		private config: Config,
+		@Inject(DI.usersRepository)
+		private usersRepository: UsersRepository,
+		@Inject(DI.antennasRepository)
+		private antennsRepository: AntennasRepository,
+		@Inject(DI.userListJoiningsRepository)
+		private userListJoiningsRepository: UserListJoiningsRepository,
+		private driveService: DriveService,
+		private utilityService: UtilityService,
+		private queueLoggerService: QueueLoggerService,
+	) {
+		this.logger = this.queueLoggerService.logger.createSubLogger('export-antennas');
+	}
+
+	@bindThis
+	public async process(job: Bull.Job<DBExportAntennasData>, done: () => void): Promise<void> {
+		const user = await this.usersRepository.findOneBy({ id: job.data.user.id });
+		if (user == null) {
+			done();
+			return;
+		}
+		const [path, cleanup] = await createTemp();
+		const stream = fs.createWriteStream(path, { flags: 'a' });
+		const write = (input: string): Promise<void> => {
+			return new Promise((resolve, reject) => {
+				stream.write(input, err => {
+					if (err) {
+						this.logger.error(err);
+						reject();
+					} else {
+						resolve();
+					}
+				});
+			});
+		};
+		try {
+			const antennas = await this.antennsRepository.findBy({ userId: job.data.user.id });
+			write('[');
+			for (const [index, antenna] of antennas.entries()) {
+				let users: User[] | undefined;
+				if (antenna.userListId !== null) {
+					const joinings = await this.userListJoiningsRepository.findBy({ userListId: antenna.userListId });
+					users = await this.usersRepository.findBy({
+						id: In(joinings.map(j => j.userId)),
+					});
+				}
+				write(JSON.stringify({
+					name: antenna.name,
+					src: antenna.src,
+					keywords: antenna.keywords,
+					excludeKeywords: antenna.excludeKeywords,
+					users: antenna.users,
+					userListAcct: typeof users !== 'undefined' ? users.map((u) => {
+						return this.utilityService.getFullApAccount(u.username, u.host); // acct
+					}) : null,
+					caseSensitive: antenna.caseSensitive,
+					withReplies: antenna.withReplies,
+					withFile: antenna.withFile,
+					notify: antenna.notify,
+				}));
+				if (antennas.length - 1 !== index) {
+					write(', ');
+				}
+			}
+			write(']');
+			stream.end();
+
+			const fileName = 'antennas-' + DateFormat(new Date(), 'yyyy-MM-dd-HH-mm-ss') + '.json';
+			const driveFile = await this.driveService.addFile({ user, path, name: fileName, force: true, ext: 'json' });
+			this.logger.succ('Exported to: ' + driveFile.id);
+		} finally {
+			cleanup();
+			done();
+		}
+	}
+}
+
diff --git a/packages/backend/src/queue/processors/ImportAntennasProcessorService.ts b/packages/backend/src/queue/processors/ImportAntennasProcessorService.ts
new file mode 100644
index 0000000000..67d255bdd3
--- /dev/null
+++ b/packages/backend/src/queue/processors/ImportAntennasProcessorService.ts
@@ -0,0 +1,93 @@
+import { Injectable, Inject } from '@nestjs/common';
+import Ajv from 'ajv';
+import { IdService } from '@/core/IdService.js';
+import { GlobalEventService } from '@/core/GlobalEventService.js';
+import Logger from '@/logger.js';
+import type { AntennasRepository } from '@/models/index.js';
+import { DI } from '@/di-symbols.js';
+import { bindThis } from '@/decorators.js';
+import { QueueLoggerService } from '../QueueLoggerService.js';
+import { DBAntennaImportJobData } from '../types.js';
+import type Bull from 'bull';
+
+@Injectable()
+export class ImportAntennasProcessorService {
+	private logger: Logger;
+	constructor (
+		@Inject(DI.antennasRepository)
+		private antennasRepository: AntennasRepository,
+		private queueLoggerService: QueueLoggerService,
+		private idService: IdService,
+		private globalEventService: GlobalEventService,
+	) {
+		this.logger = this.queueLoggerService.logger.createSubLogger('import-antennas');
+	}
+
+	@bindThis
+	public async process(job: Bull.Job<DBAntennaImportJobData>, done: () => void): Promise<void> {
+		const now = new Date();
+		try {
+			const validate = new Ajv().compile({
+				type: 'object',
+				properties: {
+					name: { type: 'string', minLength: 1, maxLength: 100 },
+					src: { type: 'string', enum: ['home', 'all', 'users', 'list'] },
+					userListAcct: { 
+						type: 'array', 
+						items: {
+							type: 'string',
+						}, 
+						nullable: true,
+					},
+					keywords: { type: 'array', items: {
+						type: 'array', items: {
+							type: 'string',
+						},
+					} },
+					excludeKeywords: { type: 'array', items: {
+						type: 'array', items: {
+							type: 'string',
+						},
+					} },
+					users: { type: 'array', items: {
+						type: 'string',
+					} },
+					caseSensitive: { type: 'boolean' },
+					withReplies: { type: 'boolean' },
+					withFile: { type: 'boolean' },
+					notify: { type: 'boolean' },
+				},
+				required: ['name', 'src', 'keywords', 'excludeKeywords', 'users', 'caseSensitive', 'withReplies', 'withFile', 'notify'],
+			});
+			for (const antenna of job.data.antenna) {
+				if (antenna.keywords.length === 0 || antenna.keywords[0].every(x => x === '')) continue;
+				if (!validate(antenna)) {
+					this.logger.warn('Validation Failed');
+					continue;
+				}
+				const result = await this.antennasRepository.insert({
+					id: this.idService.genId(),
+					createdAt: now,
+					lastUsedAt: now,
+					userId: job.data.user.id,
+					name: antenna.name,
+					src: antenna.src === 'list' && antenna.userListAcct ? 'users' : antenna.src,
+					userListId: null,
+					keywords: antenna.keywords,
+					excludeKeywords: antenna.excludeKeywords,
+					users: (antenna.src === 'list' && antenna.userListAcct !== null ? antenna.userListAcct : antenna.users).filter(Boolean),
+					caseSensitive: antenna.caseSensitive,
+					withReplies: antenna.withReplies,
+					withFile: antenna.withFile,
+					notify: antenna.notify,
+				}).then(x => this.antennasRepository.findOneByOrFail(x.identifiers[0]));
+				this.logger.succ('Antenna created: ' + result.id);
+				this.globalEventService.publishInternalEvent('antennaCreated', result);
+			}
+		} catch (err: any) {
+			this.logger.error(err);
+		} finally {
+			done();
+		}
+	}
+}
diff --git a/packages/backend/src/queue/types.ts b/packages/backend/src/queue/types.ts
index 23c973d449..776dd3aa12 100644
--- a/packages/backend/src/queue/types.ts
+++ b/packages/backend/src/queue/types.ts
@@ -1,3 +1,4 @@
+import type { Antenna } from '@/server/api/endpoints/i/import-antennas.js';
 import type { DriveFile } from '@/models/entities/DriveFile.js';
 import type { Note } from '@/models/entities/Note.js';
 import type { User } from '@/models/entities/User.js';
@@ -33,12 +34,14 @@ export type DbJobData<T extends keyof DbJobMap> = DbJobMap[T];
 export type DbJobMap = {
 	deleteDriveFiles: DbJobDataWithUser;
 	exportCustomEmojis: DbJobDataWithUser;
+	exportAntennas: DBExportAntennasData;
 	exportNotes: DbJobDataWithUser;
 	exportFavorites: DbJobDataWithUser;
 	exportFollowing: DbExportFollowingData;
 	exportMuting: DbJobDataWithUser;
 	exportBlocking: DbJobDataWithUser;
 	exportUserLists: DbJobDataWithUser;
+	importAntennas: DBAntennaImportJobData;
 	importFollowing: DbUserImportJobData;
 	importFollowingToDb: DbUserImportToDbJobData;
 	importMuting: DbUserImportJobData;
@@ -59,6 +62,10 @@ export type DbExportFollowingData = {
 	excludeInactive: boolean;
 };
 
+export type DBExportAntennasData = {
+	user: ThinUser
+}
+
 export type DbUserDeleteJobData = {
 	user: ThinUser;
 	soft?: boolean;
@@ -69,6 +76,11 @@ export type DbUserImportJobData = {
 	fileId: DriveFile['id'];
 };
 
+export type DBAntennaImportJobData = {
+	user: ThinUser,
+	antenna: Antenna
+}
+
 export type DbUserImportToDbJobData = {
 	user: ThinUser;
 	target: string;
diff --git a/packages/backend/src/server/api/EndpointsModule.ts b/packages/backend/src/server/api/EndpointsModule.ts
index 6dc1313e59..ee1aae5b6c 100644
--- a/packages/backend/src/server/api/EndpointsModule.ts
+++ b/packages/backend/src/server/api/EndpointsModule.ts
@@ -194,6 +194,7 @@ import * as ep___i_exportMute from './endpoints/i/export-mute.js';
 import * as ep___i_exportNotes from './endpoints/i/export-notes.js';
 import * as ep___i_exportFavorites from './endpoints/i/export-favorites.js';
 import * as ep___i_exportUserLists from './endpoints/i/export-user-lists.js';
+import * as ep___i_exportAntennas from './endpoints/i/export-antennas.js';
 import * as ep___i_favorites from './endpoints/i/favorites.js';
 import * as ep___i_gallery_likes from './endpoints/i/gallery/likes.js';
 import * as ep___i_gallery_posts from './endpoints/i/gallery/posts.js';
@@ -202,6 +203,7 @@ import * as ep___i_importBlocking from './endpoints/i/import-blocking.js';
 import * as ep___i_importFollowing from './endpoints/i/import-following.js';
 import * as ep___i_importMuting from './endpoints/i/import-muting.js';
 import * as ep___i_importUserLists from './endpoints/i/import-user-lists.js';
+import * as ep___i_importAntennas from './endpoints/i/import-antennas.js';
 import * as ep___i_notifications from './endpoints/i/notifications.js';
 import * as ep___i_pageLikes from './endpoints/i/page-likes.js';
 import * as ep___i_pages from './endpoints/i/pages.js';
@@ -530,6 +532,7 @@ const $i_exportMute: Provider = { provide: 'ep:i/export-mute', useClass: ep___i_
 const $i_exportNotes: Provider = { provide: 'ep:i/export-notes', useClass: ep___i_exportNotes.default };
 const $i_exportFavorites: Provider = { provide: 'ep:i/export-favorites', useClass: ep___i_exportFavorites.default };
 const $i_exportUserLists: Provider = { provide: 'ep:i/export-user-lists', useClass: ep___i_exportUserLists.default };
+const $i_exportAntennas: Provider = { provide: 'ep:i/export-antennas', useClass: ep___i_exportAntennas.default };
 const $i_favorites: Provider = { provide: 'ep:i/favorites', useClass: ep___i_favorites.default };
 const $i_gallery_likes: Provider = { provide: 'ep:i/gallery/likes', useClass: ep___i_gallery_likes.default };
 const $i_gallery_posts: Provider = { provide: 'ep:i/gallery/posts', useClass: ep___i_gallery_posts.default };
@@ -538,6 +541,7 @@ const $i_importBlocking: Provider = { provide: 'ep:i/import-blocking', useClass:
 const $i_importFollowing: Provider = { provide: 'ep:i/import-following', useClass: ep___i_importFollowing.default };
 const $i_importMuting: Provider = { provide: 'ep:i/import-muting', useClass: ep___i_importMuting.default };
 const $i_importUserLists: Provider = { provide: 'ep:i/import-user-lists', useClass: ep___i_importUserLists.default };
+const $i_importAntennas: Provider = { provide: 'ep:i/import-antennas', useClass: ep___i_importAntennas.default };
 const $i_notifications: Provider = { provide: 'ep:i/notifications', useClass: ep___i_notifications.default };
 const $i_pageLikes: Provider = { provide: 'ep:i/page-likes', useClass: ep___i_pageLikes.default };
 const $i_pages: Provider = { provide: 'ep:i/pages', useClass: ep___i_pages.default };
@@ -870,6 +874,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention
 		$i_exportNotes,
 		$i_exportFavorites,
 		$i_exportUserLists,
+		$i_exportAntennas,
 		$i_favorites,
 		$i_gallery_likes,
 		$i_gallery_posts,
@@ -878,6 +883,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention
 		$i_importFollowing,
 		$i_importMuting,
 		$i_importUserLists,
+		$i_importAntennas,
 		$i_notifications,
 		$i_pageLikes,
 		$i_pages,
@@ -1204,6 +1210,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention
 		$i_exportNotes,
 		$i_exportFavorites,
 		$i_exportUserLists,
+		$i_exportAntennas,
 		$i_favorites,
 		$i_gallery_likes,
 		$i_gallery_posts,
@@ -1212,6 +1219,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention
 		$i_importFollowing,
 		$i_importMuting,
 		$i_importUserLists,
+		$i_importAntennas,
 		$i_notifications,
 		$i_pageLikes,
 		$i_pages,
diff --git a/packages/backend/src/server/api/endpoints.ts b/packages/backend/src/server/api/endpoints.ts
index acd7f7ec3e..09bd7cbff4 100644
--- a/packages/backend/src/server/api/endpoints.ts
+++ b/packages/backend/src/server/api/endpoints.ts
@@ -194,6 +194,7 @@ import * as ep___i_exportMute from './endpoints/i/export-mute.js';
 import * as ep___i_exportNotes from './endpoints/i/export-notes.js';
 import * as ep___i_exportFavorites from './endpoints/i/export-favorites.js';
 import * as ep___i_exportUserLists from './endpoints/i/export-user-lists.js';
+import * as ep___i_exportAntennas from './endpoints/i/export-antennas.js';
 import * as ep___i_favorites from './endpoints/i/favorites.js';
 import * as ep___i_gallery_likes from './endpoints/i/gallery/likes.js';
 import * as ep___i_gallery_posts from './endpoints/i/gallery/posts.js';
@@ -202,6 +203,7 @@ import * as ep___i_importBlocking from './endpoints/i/import-blocking.js';
 import * as ep___i_importFollowing from './endpoints/i/import-following.js';
 import * as ep___i_importMuting from './endpoints/i/import-muting.js';
 import * as ep___i_importUserLists from './endpoints/i/import-user-lists.js';
+import * as ep___i_importAntennas from './endpoints/i/import-antennas.js';
 import * as ep___i_notifications from './endpoints/i/notifications.js';
 import * as ep___i_pageLikes from './endpoints/i/page-likes.js';
 import * as ep___i_pages from './endpoints/i/pages.js';
@@ -528,6 +530,7 @@ const eps = [
 	['i/export-notes', ep___i_exportNotes],
 	['i/export-favorites', ep___i_exportFavorites],
 	['i/export-user-lists', ep___i_exportUserLists],
+	['i/export-antennas', ep___i_exportAntennas],
 	['i/favorites', ep___i_favorites],
 	['i/gallery/likes', ep___i_gallery_likes],
 	['i/gallery/posts', ep___i_gallery_posts],
@@ -536,6 +539,7 @@ const eps = [
 	['i/import-following', ep___i_importFollowing],
 	['i/import-muting', ep___i_importMuting],
 	['i/import-user-lists', ep___i_importUserLists],
+	['i/import-antennas', ep___i_importAntennas],
 	['i/notifications', ep___i_notifications],
 	['i/page-likes', ep___i_pageLikes],
 	['i/pages', ep___i_pages],
diff --git a/packages/backend/src/server/api/endpoints/i/export-antennas.ts b/packages/backend/src/server/api/endpoints/i/export-antennas.ts
new file mode 100644
index 0000000000..4182c1b247
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/i/export-antennas.ts
@@ -0,0 +1,30 @@
+import { Injectable } from '@nestjs/common';
+import ms from 'ms';
+import { Endpoint } from '@/server/api/endpoint-base.js';
+import { QueueService } from '@/core/QueueService.js';
+
+export const meta = {
+	secure: true,
+	requireCredential: true,
+	limit: {
+		duration: ms('1hour'),
+		max: 1,
+	},
+} as const;
+
+export const paramDef = {
+	type: 'object',
+	properties: {},
+	required: [],
+} as const;
+
+@Injectable()
+export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
+	constructor (
+		private queueService: QueueService,
+	) {
+		super(meta, paramDef, async (ps, me) => {
+			this.queueService.createExportAntennasJob(me);
+		});
+	}
+}
diff --git a/packages/backend/src/server/api/endpoints/i/import-antennas.ts b/packages/backend/src/server/api/endpoints/i/import-antennas.ts
new file mode 100644
index 0000000000..676648bde8
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/i/import-antennas.ts
@@ -0,0 +1,81 @@
+import { Inject, Injectable } from '@nestjs/common';
+import ms from 'ms';
+import { Endpoint } from '@/server/api/endpoint-base.js';
+import { QueueService } from '@/core/QueueService.js';
+import type { AntennasRepository, DriveFilesRepository, UsersRepository, Antenna as _Antenna } from '@/models/index.js';
+import { DI } from '@/di-symbols.js';
+import { ApiError } from '../../error.js';
+import { RoleService } from '@/core/RoleService.js';
+import { DownloadService } from '@/core/DownloadService.js';
+
+export const meta = {
+	secure: true,
+	requireCredential: true,
+	prohibitMoved: true,
+
+	limit: {
+		duration: ms('1hour'),
+		max: 1,
+	},
+	errors: {
+		noSuchFile: {
+			message: 'No such file.',
+			code: 'NO_SUCH_FILE',
+			id: '3b71d086-c3fa-431c-b01d-ded65a777172',
+		},
+		noSuchUser: {
+			message: 'No such user.',
+			code: 'NO_SUCH_USER',
+			id: 'e842c379-8ac7-4cf7-b07a-4d4de7e4671c',
+		},
+		emptyFile: {
+			message: 'That file is empty.',
+			code: 'EMPTY_FILE',
+			id: '7f60115d-8d93-4b0f-bd0e-3815dcbb389f',
+		},
+		tooManyAntennas: {
+			message: 'You cannot create antenna any more.',
+			code: 'TOO_MANY_ANTENNAS',
+			id: '600917d4-a4cb-4cc5-8ba8-7ac8ea3c7779',
+		},
+	},
+} as const;
+
+export const paramDef = {
+	type: 'object',
+	properties: {
+		fileId: { type: 'string', format: 'misskey:id' },
+	},
+	required: ['fileId'],
+} as const;
+
+@Injectable() // eslint-disable-next-line import/no-default-export
+export default class extends Endpoint<typeof meta, typeof paramDef> {
+	constructor (
+		@Inject(DI.driveFilesRepository)
+		private driveFilesRepository: DriveFilesRepository,
+		@Inject(DI.antennasRepository)
+		private antennasRepository: AntennasRepository,
+		@Inject(DI.usersRepository)
+		private usersRepository: UsersRepository,
+		private roleService: RoleService,
+		private queueService: QueueService,
+		private downloadService: DownloadService
+	) {
+		super(meta, paramDef, async (ps, me) => {
+			const users = await this.usersRepository.findOneBy({ id: me.id });
+			if (users === null) throw new ApiError(meta.errors.noSuchUser);
+			const file = await this.driveFilesRepository.findOneBy({ id: ps.fileId });
+			if (file === null) throw new ApiError(meta.errors.noSuchFile);
+			if (file.size === 0) throw new ApiError(meta.errors.emptyFile);
+			const antennas: (_Antenna & { userListAcct: string[] | null })[] = JSON.parse(await this.downloadService.downloadTextFile(file.url));
+			const currentAntennasCount = await this.antennasRepository.countBy({ userId: me.id });
+			if (currentAntennasCount + antennas.length > (await this.roleService.getUserPolicies(me.id)).antennaLimit) {
+				throw new ApiError(meta.errors.tooManyAntennas);
+			}
+			this.queueService.createImportAntennasJob(me, antennas);
+		});
+	}
+} 
+
+export type Antenna = (_Antenna & { userListAcct: string[] | null })[];
diff --git a/packages/frontend/src/pages/settings/import-export.vue b/packages/frontend/src/pages/settings/import-export.vue
index c883efe691..89b4104020 100644
--- a/packages/frontend/src/pages/settings/import-export.vue
+++ b/packages/frontend/src/pages/settings/import-export.vue
@@ -84,6 +84,21 @@
 			</MkFolder>
 		</div>
 	</FormSection>
+	<FormSection>
+		<template #label><i class="ti ti-antenna"></i> {{ i18n.ts.antennas }}</template>
+		<div class="_gaps_s">
+			<MkFolder>
+				<template #label>{{ i18n.ts.export }}</template>
+				<template #icon><i class="ti ti-download"></i></template>
+				<MkButton primary :class="$style.button" inline @click="exportAntennas()"><i class="ti ti-download"></i> {{ i18n.ts.export }}</MkButton>
+			</MkFolder>
+			<MkFolder v-if="$i && !$i.movedTo">
+				<template #label>{{ i18n.ts.import }}</template>
+				<template #icon><i class="ti ti-upload"></i></template>
+				<MkButton primary :class="$style.button" inline @click="importAntennas($event)"><i class="ti ti-upload"></i> {{ i18n.ts.import }}</MkButton>
+			</MkFolder>
+		</div>
+	</FormSection>
 </div>
 </template>
 
@@ -151,6 +166,10 @@ const exportMuting = () => {
 	os.api('i/export-mute', {}).then(onExportSuccess).catch(onError);
 };
 
+const exportAntennas = () => {
+	os.api('i/export-antennas', {}).then(onExportSuccess).catch(onError);
+};
+
 const importFollowing = async (ev) => {
 	const file = await selectFile(ev.currentTarget ?? ev.target);
 	os.api('i/import-following', { fileId: file.id }).then(onImportSuccess).catch(onError);
@@ -171,6 +190,11 @@ const importBlocking = async (ev) => {
 	os.api('i/import-blocking', { fileId: file.id }).then(onImportSuccess).catch(onError);
 };
 
+const importAntennas = async (ev) => {
+	const file = await selectFile(ev.currentTarget ?? ev.target);
+	os.api('i/import-antennas', { fileId: file.id }).then(onImportSuccess).catch(onError);
+};
+
 const headerActions = $computed(() => []);
 
 const headerTabs = $computed(() => []);