Compare commits

...

29 Commits

Author SHA1 Message Date
tamaina
a926e84b14 Merge branch 'develop' into fetch-outbox 2023-09-16 08:33:23 +00:00
syuilo
814e28459e enhance(backend): improve server icon setting
Resolve #11481
Resolve #10901
2023-09-16 17:05:17 +09:00
syuilo
d2831c612f Update vite.config.ts 2023-09-15 19:49:52 +09:00
tamaina
df636659e2 Merge branch 'develop' into fetch-outbox 2023-09-11 06:32:04 +00:00
tamaina
888cd2eb9f Merge branch 'develop' into fetch-outbox 2023-08-24 06:23:05 +00:00
Kagami Sascha Rosylight
ff4b3d2d9e Merge branch 'develop' into fetch-outbox 2023-08-06 00:04:26 +02:00
tamaina
60fd848182 configable 2023-07-15 14:24:05 +00:00
tamaina
76def0032e ? 2023-07-15 14:06:36 +00:00
tamaina
da0804eb17 fix type error 2023-07-15 14:05:05 +00:00
tamaina
6b26ce3768 Merge branch 'develop' into fetch-outbox 2023-07-15 14:04:54 +00:00
tamaina
5a0d7d41e6 Merge branch 'develop' into fetch-outbox 2023-07-14 13:36:04 +09:00
Kagami Sascha Rosylight
08e2b6ee32 perform only create activities 2023-07-09 23:34:07 +02:00
Kagami Sascha Rosylight
ca0c673b44 Update activitypub.ts 2023-07-09 22:39:02 +02:00
Kagami Sascha Rosylight
70bb9a4d1f Update activitypub.ts 2023-07-09 22:36:41 +02:00
Kagami Sascha Rosylight
b93046c071 Update activitypub.ts 2023-07-09 22:36:27 +02:00
Kagami Sascha Rosylight
f34f0dfcb6 Merge remote-tracking branch 'origin/develop' into fetch-outbox 2023-07-09 22:36:14 +02:00
Kagami Sascha Rosylight
26040c2bb0 Update activitypub.ts 2023-07-09 22:36:07 +02:00
Kagami Sascha Rosylight
bdbad4605b Merge branch 'develop' into fetch-outbox 2023-07-09 15:04:12 +02:00
Kagami Sascha Rosylight
ec62fe02b1 Merge branch 'develop' into fetch-outbox 2023-07-09 00:08:54 +02:00
Kagami Sascha Rosylight
a74af07992 reformat 2023-07-08 15:48:10 +02:00
Kagami Sascha Rosylight
aa78c29e8c Merge remote-tracking branch 'origin/develop' into fetch-outbox 2023-07-08 15:47:00 +02:00
Kagami Sascha Rosylight
45d0b46e7a typo 2023-07-08 15:44:27 +02:00
Kagami Sascha Rosylight
6087d02047 adjust tests 2023-07-08 15:40:48 +02:00
Kagami Sascha Rosylight
7bf318ae98 remove extra resolving in tests 2023-07-08 15:13:23 +02:00
tamaina
71d74676f0 Merge branch 'develop' into fetch-outbox 2023-07-07 14:54:28 +09:00
tamaina
5077df2973 100 -> 15 2023-07-07 05:02:28 +00:00
Kagami Sascha Rosylight
a1388a8444 Update CHANGELOG.md 2023-07-06 03:39:16 +02:00
Kagami Sascha Rosylight
630e97bd06 Merge remote-tracking branch 'origin/develop' into fetch-outbox 2023-07-06 03:38:13 +02:00
Kagami Sascha Rosylight
2e1de4fca9 feat(backend): fetch the first page of outbox when resolving Person 2023-07-06 02:56:17 +02:00
20 changed files with 428 additions and 73 deletions

View File

@@ -8,7 +8,7 @@
- -
### Server ### Server
- - 最初照会したユーザーの最新ノートを受け取るように
--> -->
@@ -26,6 +26,8 @@
- 二要素認証のバックアップコードが生成されるようになりました ref. https://github.com/MisskeyIO/misskey/pull/121 - 二要素認証のバックアップコードが生成されるようになりました ref. https://github.com/MisskeyIO/misskey/pull/121
- 二要素認証でパスキーをサポートするようになりました - 二要素認証でパスキーをサポートするようになりました
- 通知をテストできるようになりました - 通知をテストできるようになりました
- PWAのアイコンが設定できるようになりました
- manifest.jsonをオーバーライド可能に
### Client ### Client
- プロフィールにその人が作ったPlayの一覧出せるように - プロフィールにその人が作ったPlayの一覧出せるように

View File

@@ -187,6 +187,10 @@ id: "aidx"
# Sign to ActivityPub GET request (default: true) # Sign to ActivityPub GET request (default: true)
signToActivityPubGet: true signToActivityPubGet: true
# Limit of notes to fetch from outbox with remote user first fetched (default: 5)
# https://github.com/misskey-dev/misskey/pull/11130
outboxNotesFetchLimit: 5
#allowedPrivateNetworks: [ #allowedPrivateNetworks: [
# '127.0.0.1/32' # '127.0.0.1/32'
#] #]

9
locales/index.d.ts vendored
View File

@@ -359,7 +359,6 @@ export interface Locale {
"driveCapacityPerLocalAccount": string; "driveCapacityPerLocalAccount": string;
"driveCapacityPerRemoteAccount": string; "driveCapacityPerRemoteAccount": string;
"inMb": string; "inMb": string;
"iconUrl": string;
"bannerUrl": string; "bannerUrl": string;
"backgroundImageUrl": string; "backgroundImageUrl": string;
"basicInfo": string; "basicInfo": string;
@@ -1140,6 +1139,14 @@ export interface Locale {
"_serverRules": { "_serverRules": {
"description": string; "description": string;
}; };
"_serverSettings": {
"iconUrl": string;
"appIconDescription": string;
"appIconUsageExample": string;
"appIconStyleRecommendation": string;
"appIconResolutionMustBe": string;
"manifestJsonOverride": string;
};
"_accountMigration": { "_accountMigration": {
"moveFrom": string; "moveFrom": string;
"moveFromSub": string; "moveFromSub": string;

View File

@@ -356,7 +356,6 @@ invite: "招待"
driveCapacityPerLocalAccount: "ローカルユーザーひとりあたりのドライブ容量" driveCapacityPerLocalAccount: "ローカルユーザーひとりあたりのドライブ容量"
driveCapacityPerRemoteAccount: "リモートユーザーひとりあたりのドライブ容量" driveCapacityPerRemoteAccount: "リモートユーザーひとりあたりのドライブ容量"
inMb: "メガバイト単位" inMb: "メガバイト単位"
iconUrl: "アイコン画像のURL (faviconなど)"
bannerUrl: "バナー画像のURL" bannerUrl: "バナー画像のURL"
backgroundImageUrl: "背景画像のURL" backgroundImageUrl: "背景画像のURL"
basicInfo: "基本情報" basicInfo: "基本情報"
@@ -1138,6 +1137,14 @@ _initialAccountSetting:
_serverRules: _serverRules:
description: "新規登録前に表示する、サーバーの簡潔なルールを設定します。内容は利用規約の要約とすることを推奨します。" description: "新規登録前に表示する、サーバーの簡潔なルールを設定します。内容は利用規約の要約とすることを推奨します。"
_serverSettings:
iconUrl: "アイコン画像のURL"
appIconDescription: "{host}がアプリとして表示される際のアイコンを指定します。"
appIconUsageExample: "例: PWAや、スマートフォンのホーム画面にブックマークとして追加された時など"
appIconStyleRecommendation: "画像は透過部分が無く、塗りつぶされた余白がある背景を持つことが推奨されます。"
appIconResolutionMustBe: "解像度は必ず{resolution}である必要があります。"
manifestJsonOverride: "manifest.jsonのオーバーライド"
_accountMigration: _accountMigration:
moveFrom: "別のアカウントからこのアカウントに移行" moveFrom: "別のアカウントからこのアカウントに移行"
moveFromSub: "別のアカウントへエイリアスを作成" moveFromSub: "別のアカウントへエイリアスを作成"

View File

@@ -0,0 +1,15 @@
export class ServerIconsAndManifest1694850832075 {
name = 'ServerIconsAndManifest1694850832075'
async up(queryRunner) {
await queryRunner.query(`ALTER TABLE "meta" ADD "app192IconUrl" character varying(1024)`);
await queryRunner.query(`ALTER TABLE "meta" ADD "app512IconUrl" character varying(1024)`);
await queryRunner.query(`ALTER TABLE "meta" ADD "manifestJsonOverride" character varying(8192) NOT NULL DEFAULT '{}'`);
}
async down(queryRunner) {
await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "manifestJsonOverride"`);
await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "app512IconUrl"`);
await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "app192IconUrl"`);
}
}

View File

@@ -85,6 +85,7 @@ type Source = {
videoThumbnailGenerator?: string; videoThumbnailGenerator?: string;
signToActivityPubGet?: boolean; signToActivityPubGet?: boolean;
outboxNotesFetchLimit?: number;
perChannelMaxNoteCacheCount?: number; perChannelMaxNoteCacheCount?: number;
perUserNotificationsMaxCount?: number; perUserNotificationsMaxCount?: number;

View File

@@ -27,7 +27,7 @@ import { QueueService } from '@/core/QueueService.js';
import type { UsersRepository, NotesRepository, FollowingsRepository, AbuseUserReportsRepository, FollowRequestsRepository } from '@/models/_.js'; import type { UsersRepository, NotesRepository, FollowingsRepository, AbuseUserReportsRepository, FollowRequestsRepository } from '@/models/_.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
import type { MiRemoteUser } from '@/models/entities/User.js'; import type { MiRemoteUser } from '@/models/entities/User.js';
import { getApHrefNullable, getApId, getApIds, getApType, isAccept, isActor, isAdd, isAnnounce, isBlock, isCollection, isCollectionOrOrderedCollection, isCreate, isDelete, isFlag, isFollow, isLike, isMove, isPost, isReject, isRemove, isTombstone, isUndo, isUpdate, validActor, validPost } from './type.js'; import { getApHrefNullable, getApId, getApIds, getApType, isAccept, isActor, isAdd, isAnnounce, isBlock, isCollection, isCollectionOrOrderedCollection, isCreate, isDelete, isFlag, isFollow, isLike, isMove, isOrderedCollectionPage, isPost, isReject, isRemove, isTombstone, isUndo, isUpdate, validActor, validPost } from './type.js';
import { ApNoteService } from './models/ApNoteService.js'; import { ApNoteService } from './models/ApNoteService.js';
import { ApLoggerService } from './ApLoggerService.js'; import { ApLoggerService } from './ApLoggerService.js';
import { ApDbResolverService } from './ApDbResolverService.js'; import { ApDbResolverService } from './ApDbResolverService.js';
@@ -87,11 +87,19 @@ export class ApInboxService {
} }
@bindThis @bindThis
public async performActivity(actor: MiRemoteUser, activity: IObject): Promise<void> { public async performActivity(actor: MiRemoteUser, activity: IObject, {
if (isCollectionOrOrderedCollection(activity)) { limit = Infinity,
allow = null as (string[] | null) } = {},
): Promise<void> {
if (isCollectionOrOrderedCollection(activity) || isOrderedCollectionPage(activity)) {
const resolver = this.apResolverService.createResolver(); const resolver = this.apResolverService.createResolver();
for (const item of toArray(isCollection(activity) ? activity.items : activity.orderedItems)) { for (const item of toArray(isCollection(activity) ? activity.items : activity.orderedItems).slice(0, limit)) {
const act = await resolver.resolve(item); const act = await resolver.resolve(item);
const type = getApType(act);
if (allow && !allow.includes(type)) {
this.logger.info(`skipping activity type: ${type}`);
continue;
}
try { try {
await this.performOneActivity(actor, act); await this.performOneActivity(actor, act);
} catch (err) { } catch (err) {
@@ -367,7 +375,7 @@ export class ApInboxService {
}); });
if (isPost(object)) { if (isPost(object)) {
this.createNote(resolver, actor, object, false, activity); await this.createNote(resolver, actor, object, false, activity);
} else { } else {
this.logger.warn(`Unknown type: ${getApType(object)}`); this.logger.warn(`Unknown type: ${getApType(object)}`);
} }

View File

@@ -15,11 +15,11 @@ import { UtilityService } from '@/core/UtilityService.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
import { LoggerService } from '@/core/LoggerService.js'; import { LoggerService } from '@/core/LoggerService.js';
import type Logger from '@/logger.js'; import type Logger from '@/logger.js';
import { isCollectionOrOrderedCollection } from './type.js'; import { isCollectionOrOrderedCollection, isOrderedCollectionPage } from './type.js';
import { ApDbResolverService } from './ApDbResolverService.js'; import { ApDbResolverService } from './ApDbResolverService.js';
import { ApRendererService } from './ApRendererService.js'; import { ApRendererService } from './ApRendererService.js';
import { ApRequestService } from './ApRequestService.js'; import { ApRequestService } from './ApRequestService.js';
import type { IObject, ICollection, IOrderedCollection } from './type.js'; import type { IObject, ICollection, IOrderedCollection, IOrderedCollectionPage } from './type.js';
export class Resolver { export class Resolver {
private history: Set<string>; private history: Set<string>;
@@ -64,6 +64,18 @@ export class Resolver {
} }
} }
public async resolveOrderedCollectionPage(value: string | IObject): Promise<IOrderedCollectionPage> {
const collection = typeof value === 'string'
? await this.resolve(value)
: value;
if (isOrderedCollectionPage(collection)) {
return collection;
} else {
throw new Error(`unrecognized collection type: ${collection.type}`);
}
}
@bindThis @bindThis
public async resolve(value: string | IObject): Promise<IObject> { public async resolve(value: string | IObject): Promise<IObject> {
if (typeof value !== 'string') { if (typeof value !== 'string') {

View File

@@ -38,7 +38,8 @@ import { MetaService } from '@/core/MetaService.js';
import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js'; import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js';
import type { AccountMoveService } from '@/core/AccountMoveService.js'; import type { AccountMoveService } from '@/core/AccountMoveService.js';
import { checkHttps } from '@/misc/check-https.js'; import { checkHttps } from '@/misc/check-https.js';
import { getApId, getApType, getOneApHrefNullable, isActor, isCollection, isCollectionOrOrderedCollection, isPropertyValue } from '../type.js'; import { getApId, getApType, getOneApHrefNullable, isActor, isCollection, isCollectionOrOrderedCollection, isOrderedCollection, isOrderedCollectionPage, isPropertyValue } from '../type.js';
import { ApInboxService } from '../ApInboxService.js';
import { extractApHashtags } from './tag.js'; import { extractApHashtags } from './tag.js';
import type { OnModuleInit } from '@nestjs/common'; import type { OnModuleInit } from '@nestjs/common';
import type { ApNoteService } from './ApNoteService.js'; import type { ApNoteService } from './ApNoteService.js';
@@ -68,6 +69,7 @@ export class ApPersonService implements OnModuleInit {
private apResolverService: ApResolverService; private apResolverService: ApResolverService;
private apNoteService: ApNoteService; private apNoteService: ApNoteService;
private apImageService: ApImageService; private apImageService: ApImageService;
private apInboxService: ApInboxService;
private apMfmService: ApMfmService; private apMfmService: ApMfmService;
private mfmService: MfmService; private mfmService: MfmService;
private hashtagService: HashtagService; private hashtagService: HashtagService;
@@ -116,6 +118,7 @@ export class ApPersonService implements OnModuleInit {
this.apResolverService = this.moduleRef.get('ApResolverService'); this.apResolverService = this.moduleRef.get('ApResolverService');
this.apNoteService = this.moduleRef.get('ApNoteService'); this.apNoteService = this.moduleRef.get('ApNoteService');
this.apImageService = this.moduleRef.get('ApImageService'); this.apImageService = this.moduleRef.get('ApImageService');
this.apInboxService = this.moduleRef.get('ApInboxService');
this.apMfmService = this.moduleRef.get('ApMfmService'); this.apMfmService = this.moduleRef.get('ApMfmService');
this.mfmService = this.moduleRef.get('MfmService'); this.mfmService = this.moduleRef.get('MfmService');
this.hashtagService = this.moduleRef.get('HashtagService'); this.hashtagService = this.moduleRef.get('HashtagService');
@@ -384,7 +387,10 @@ export class ApPersonService implements OnModuleInit {
} }
//#endregion //#endregion
await this.updateFeatured(user.id, resolver).catch(err => this.logger.error(err)); await Promise.allSettled([
this.updateFeatured(user.id, resolver).catch(err => this.logger.error(err)),
this.updateOutboxFirstPage(user, person.outbox, resolver).catch(err => this.logger.error(err)),
]);
return user; return user;
} }
@@ -589,7 +595,7 @@ export class ApPersonService implements OnModuleInit {
const unresolvedItems = isCollection(collection) ? collection.items : collection.orderedItems; const unresolvedItems = isCollection(collection) ? collection.items : collection.orderedItems;
const items = await Promise.all(toArray(unresolvedItems).map(x => _resolver.resolve(x))); const items = await Promise.all(toArray(unresolvedItems).map(x => _resolver.resolve(x)));
// Resolve and regist Notes // Resolve and register Notes
const limit = promiseLimit<MiNote | null>(2); const limit = promiseLimit<MiNote | null>(2);
const featuredNotes = await Promise.all(items const featuredNotes = await Promise.all(items
.filter(item => getApType(item) === 'Note') // TODO: Noteでなくてもいいかも .filter(item => getApType(item) === 'Note') // TODO: Noteでなくてもいいかも
@@ -616,6 +622,35 @@ export class ApPersonService implements OnModuleInit {
}); });
} }
/**
* Retrieve outbox from an actor object.
*
* This only retrieves the first page for now.
*/
public async updateOutboxFirstPage(user: RemoteUser, outbox: IActor['outbox'], resolver: Resolver): Promise<void> {
if (!this.config.outboxNotesFetchLimit) return;
// https://www.w3.org/TR/activitypub/#actor-objects
// Outbox is a required property for all actors
if (!outbox) {
throw new Error('No outbox property');
}
this.logger.info(`Fetching the outbox for ${user.uri}: ${outbox}`);
const collection = await resolver.resolveCollection(outbox);
if (!isOrderedCollection(collection)) {
throw new Error('Outbox must be an ordered collection');
}
const firstPage = collection.first ?
await resolver.resolveOrderedCollectionPage(collection.first) :
collection;
// Perform activity but only the first outboxNotesFetchLimit ones with `type: Create`
await this.apInboxService.performActivity(user, firstPage, { limit: this.config.outboxNotesFetchLimit, allow: ['Create'] });
}
/** /**
* リモート由来のアカウント移行処理を行います * リモート由来のアカウント移行処理を行います
* @param src 移行元アカウントリモートかつupdatePerson後である必要がある、というかこれ自体がupdatePersonで呼ばれる前提 * @param src 移行元アカウントリモートかつupdatePerson後である必要がある、というかこれ自体がupdatePersonで呼ばれる前提

View File

@@ -92,16 +92,37 @@ export interface IActivity extends IObject {
}; };
} }
// https://www.w3.org/TR/activitystreams-vocabulary/#dfn-collection
export interface ICollection extends IObject { export interface ICollection extends IObject {
type: 'Collection'; type: 'Collection';
totalItems: number; totalItems: number;
current?: ICollectionPage | string;
first?: ICollectionPage | string;
last?: ICollectionPage | string;
items: ApObject; items: ApObject;
} }
export interface IOrderedCollection extends IObject { // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-orderedcollection
export interface IOrderedCollection extends Omit<ICollection, 'type' | 'items'> {
type: 'OrderedCollection'; type: 'OrderedCollection';
totalItems: number;
orderedItems: ApObject; // orderedItems is not defined well
// https://github.com/w3c/activitystreams/issues/494
orderedItems?: ApObject;
}
// https://www.w3.org/TR/activitystreams-vocabulary/#dfn-collectionpage
export interface ICollectionPage extends Omit<ICollection, 'type'> {
type: 'CollectionPage';
partOf?: ICollection | string;
next?: ICollectionPage | string;
prev?: ICollectionPage | string;
}
// https://www.w3.org/TR/activitystreams-vocabulary/#dfn-orderedcollectionpage
export interface IOrderedCollectionPage extends Omit<IOrderedCollection, 'type'>, Omit<ICollectionPage, 'type' | 'items'> {
type: 'OrderedCollectionPage';
startIndex?: number,
} }
export const validPost = ['Note', 'Question', 'Article', 'Audio', 'Document', 'Image', 'Page', 'Video', 'Event']; export const validPost = ['Note', 'Question', 'Article', 'Audio', 'Document', 'Image', 'Page', 'Video', 'Event'];
@@ -188,6 +209,9 @@ export const isCollection = (object: IObject): object is ICollection =>
export const isOrderedCollection = (object: IObject): object is IOrderedCollection => export const isOrderedCollection = (object: IObject): object is IOrderedCollection =>
getApType(object) === 'OrderedCollection'; getApType(object) === 'OrderedCollection';
export const isOrderedCollectionPage = (object: IObject): object is IOrderedCollectionPage =>
getApType(object) === 'OrderedCollectionPage';
export const isCollectionOrOrderedCollection = (object: IObject): object is ICollection | IOrderedCollection => export const isCollectionOrOrderedCollection = (object: IObject): object is ICollection | IOrderedCollection =>
isCollection(object) || isOrderedCollection(object); isCollection(object) || isOrderedCollection(object);

View File

@@ -107,6 +107,18 @@ export class MiMeta {
}) })
public iconUrl: string | null; public iconUrl: string | null;
@Column('varchar', {
length: 1024,
nullable: true,
})
public app192IconUrl: string | null;
@Column('varchar', {
length: 1024,
nullable: true,
})
public app512IconUrl: string | null;
@Column('varchar', { @Column('varchar', {
length: 1024, length: 1024,
nullable: true, nullable: true,
@@ -444,6 +456,12 @@ export class MiMeta {
}) })
public serverRules: string[]; public serverRules: string[];
@Column('varchar', {
length: 8192,
default: '{}',
})
public manifestJsonOverride: string;
@Column('varchar', { @Column('varchar', {
length: 1024, array: true, default: '{ "admin", "administrator", "root", "system", "maintainer", "host", "mod", "moderator", "owner", "superuser", "staff", "auth", "i", "me", "everyone", "all", "mention", "mentions", "example", "user", "users", "account", "accounts", "official", "help", "helps", "support", "supports", "info", "information", "informations", "announce", "announces", "announcement", "announcements", "notice", "notification", "notifications", "dev", "developer", "developers", "tech", "misskey" }', length: 1024, array: true, default: '{ "admin", "administrator", "root", "system", "maintainer", "host", "mod", "moderator", "owner", "superuser", "staff", "auth", "i", "me", "everyone", "all", "mention", "mentions", "example", "user", "users", "account", "accounts", "official", "help", "helps", "support", "supports", "info", "information", "informations", "announce", "announces", "announcement", "announcements", "notice", "notification", "notifications", "dev", "developer", "developers", "tech", "misskey" }',
}) })

View File

@@ -85,6 +85,14 @@ export const meta = {
type: 'string', type: 'string',
optional: false, nullable: true, optional: false, nullable: true,
}, },
app192IconUrl: {
type: 'string',
optional: false, nullable: true,
},
app512IconUrl: {
type: 'string',
optional: false, nullable: true,
},
enableEmail: { enableEmail: {
type: 'boolean', type: 'boolean',
optional: false, nullable: false, optional: false, nullable: false,
@@ -278,6 +286,10 @@ export const meta = {
type: 'boolean', type: 'boolean',
optional: false, nullable: false, optional: false, nullable: false,
}, },
manifestJsonOverride: {
type: 'string',
optional: true, nullable: false,
},
policies: { policies: {
type: 'object', type: 'object',
optional: false, nullable: false, optional: false, nullable: false,
@@ -331,6 +343,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
notFoundImageUrl: instance.notFoundImageUrl, notFoundImageUrl: instance.notFoundImageUrl,
infoImageUrl: instance.infoImageUrl, infoImageUrl: instance.infoImageUrl,
iconUrl: instance.iconUrl, iconUrl: instance.iconUrl,
app192IconUrl: instance.app192IconUrl,
app512IconUrl: instance.app512IconUrl,
backgroundImageUrl: instance.backgroundImageUrl, backgroundImageUrl: instance.backgroundImageUrl,
logoImageUrl: instance.logoImageUrl, logoImageUrl: instance.logoImageUrl,
defaultLightTheme: instance.defaultLightTheme, defaultLightTheme: instance.defaultLightTheme,
@@ -383,6 +397,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
enableServerMachineStats: instance.enableServerMachineStats, enableServerMachineStats: instance.enableServerMachineStats,
enableIdenticonGeneration: instance.enableIdenticonGeneration, enableIdenticonGeneration: instance.enableIdenticonGeneration,
policies: { ...DEFAULT_POLICIES, ...instance.policies }, policies: { ...DEFAULT_POLICIES, ...instance.policies },
manifestJsonOverride: instance.manifestJsonOverride,
}; };
}); });
} }

View File

@@ -39,6 +39,8 @@ export const paramDef = {
infoImageUrl: { type: 'string', nullable: true }, infoImageUrl: { type: 'string', nullable: true },
notFoundImageUrl: { type: 'string', nullable: true }, notFoundImageUrl: { type: 'string', nullable: true },
iconUrl: { type: 'string', nullable: true }, iconUrl: { type: 'string', nullable: true },
app192IconUrl: { type: 'string', nullable: true },
app512IconUrl: { type: 'string', nullable: true },
backgroundImageUrl: { type: 'string', nullable: true }, backgroundImageUrl: { type: 'string', nullable: true },
logoImageUrl: { type: 'string', nullable: true }, logoImageUrl: { type: 'string', nullable: true },
name: { type: 'string', nullable: true }, name: { type: 'string', nullable: true },
@@ -104,6 +106,7 @@ export const paramDef = {
enableIdenticonGeneration: { type: 'boolean' }, enableIdenticonGeneration: { type: 'boolean' },
serverRules: { type: 'array', items: { type: 'string' } }, serverRules: { type: 'array', items: { type: 'string' } },
preservedUsernames: { type: 'array', items: { type: 'string' } }, preservedUsernames: { type: 'array', items: { type: 'string' } },
manifestJsonOverride: { type: 'string' },
}, },
required: [], required: [],
} as const; } as const;
@@ -153,6 +156,14 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
set.iconUrl = ps.iconUrl; set.iconUrl = ps.iconUrl;
} }
if (ps.app192IconUrl !== undefined) {
set.app192IconUrl = ps.app192IconUrl;
}
if (ps.app512IconUrl !== undefined) {
set.app512IconUrl = ps.app512IconUrl;
}
if (ps.serverErrorImageUrl !== undefined) { if (ps.serverErrorImageUrl !== undefined) {
set.serverErrorImageUrl = ps.serverErrorImageUrl; set.serverErrorImageUrl = ps.serverErrorImageUrl;
} }
@@ -421,6 +432,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
set.preservedUsernames = ps.preservedUsernames; set.preservedUsernames = ps.preservedUsernames;
} }
if (ps.manifestJsonOverride !== undefined) {
set.manifestJsonOverride = ps.manifestJsonOverride;
}
await this.metaService.update(set); await this.metaService.update(set);
this.moderationLogService.insertModerationLog(me, 'updateMeta'); this.moderationLogService.insertModerationLog(me, 'updateMeta');
}); });

View File

@@ -51,45 +51,6 @@ const assets = `${_dirname}/../../../../../built/_frontend_dist_/`;
const swAssets = `${_dirname}/../../../../../built/_sw_dist_/`; const swAssets = `${_dirname}/../../../../../built/_sw_dist_/`;
const viteOut = `${_dirname}/../../../../../built/_vite_/`; const viteOut = `${_dirname}/../../../../../built/_vite_/`;
const manifest = {
'short_name': 'Misskey',
'name': 'Misskey',
'start_url': '/',
'display': 'standalone',
'background_color': '#313a42',
'theme_color': '#86b300',
'icons': [
{
'src': '/static-assets/icons/192.png',
'sizes': '192x192',
'type': 'image/png',
'purpose': 'maskable',
},
{
'src': '/static-assets/icons/512.png',
'sizes': '512x512',
'type': 'image/png',
'purpose': 'maskable',
},
{
'src': '/static-assets/splash.png',
'sizes': '300x300',
'type': 'image/png',
'purpose': 'any',
},
],
'share_target': {
'action': '/share/',
'method': 'GET',
'enctype': 'application/x-www-form-urlencoded',
'params': {
'title': 'title',
'text': 'text',
'url': 'url',
},
},
};
@Injectable() @Injectable()
export class ClientServerService { export class ClientServerService {
private logger: Logger; private logger: Logger;
@@ -148,16 +109,60 @@ export class ClientServerService {
@bindThis @bindThis
private async manifestHandler(reply: FastifyReply) { private async manifestHandler(reply: FastifyReply) {
const res = deepClone(manifest);
const instance = await this.metaService.fetch(true); const instance = await this.metaService.fetch(true);
res.short_name = instance.name ?? 'Misskey'; let manifest = {
res.name = instance.name ?? 'Misskey'; // 空文字列の場合右辺を使いたいため
if (instance.themeColor) res.theme_color = instance.themeColor; // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
'short_name': instance.name || 'Misskey',
// 空文字列の場合右辺を使いたいため
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
'name': instance.name || 'Misskey',
'start_url': '/',
'display': 'standalone',
'background_color': '#313a42',
// 空文字列の場合右辺を使いたいため
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
'theme_color': instance.themeColor || '#86b300',
'icons': [{
// 空文字列の場合右辺を使いたいため
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
'src': instance.app192IconUrl || '/static-assets/icons/192.png',
'sizes': '192x192',
'type': 'image/png',
'purpose': 'maskable',
}, {
// 空文字列の場合右辺を使いたいため
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
'src': instance.app512IconUrl || '/static-assets/icons/512.png',
'sizes': '512x512',
'type': 'image/png',
'purpose': 'maskable',
}, {
'src': '/static-assets/splash.png',
'sizes': '300x300',
'type': 'image/png',
'purpose': 'any',
}],
'share_target': {
'action': '/share/',
'method': 'GET',
'enctype': 'application/x-www-form-urlencoded',
'params': {
'title': 'title',
'text': 'text',
'url': 'url',
},
},
};
manifest = {
...manifest,
...JSON.parse(instance.manifestJsonOverride === '' ? '{}' : instance.manifestJsonOverride),
};
reply.header('Cache-Control', 'max-age=300'); reply.header('Cache-Control', 'max-age=300');
return (res); return (manifest);
} }
@bindThis @bindThis
@@ -165,6 +170,7 @@ export class ClientServerService {
return { return {
instanceName: meta.name ?? 'Misskey', instanceName: meta.name ?? 'Misskey',
icon: meta.iconUrl, icon: meta.iconUrl,
appleTouchIcon: meta.app512IconUrl,
themeColor: meta.themeColor, themeColor: meta.themeColor,
serverErrorImageUrl: meta.serverErrorImageUrl ?? 'https://xn--931a.moe/assets/error.jpg', serverErrorImageUrl: meta.serverErrorImageUrl ?? 'https://xn--931a.moe/assets/error.jpg',
infoImageUrl: meta.infoImageUrl ?? 'https://xn--931a.moe/assets/info.jpg', infoImageUrl: meta.infoImageUrl ?? 'https://xn--931a.moe/assets/info.jpg',

View File

@@ -28,7 +28,7 @@ html
meta(property='og:site_name' content= instanceName || 'Misskey') meta(property='og:site_name' content= instanceName || 'Misskey')
meta(name='viewport' content='width=device-width, initial-scale=1') meta(name='viewport' content='width=device-width, initial-scale=1')
link(rel='icon' href= icon || '/favicon.ico') link(rel='icon' href= icon || '/favicon.ico')
link(rel='apple-touch-icon' href= icon || '/apple-touch-icon.png') link(rel='apple-touch-icon' href= appleTouchIcon || '/apple-touch-icon.png')
link(rel='manifest' href='/manifest.json') link(rel='manifest' href='/manifest.json')
link(rel='search' type='application/opensearchdescription+xml' title=(title || "Misskey") href=`${url}/opensearch.xml`) link(rel='search' type='application/opensearchdescription+xml' title=(title || "Misskey") href=`${url}/opensearch.xml`)
link(rel='prefetch' href=serverErrorImageUrl) link(rel='prefetch' href=serverErrorImageUrl)

View File

@@ -68,7 +68,7 @@ export class MockResolver extends Resolver {
const r = this.#responseMap.get(value); const r = this.#responseMap.get(value);
if (!r) { if (!r) {
throw new Error('Not registed for mock'); throw new Error('Not registered for mock');
} }
const object = JSON.parse(r.content); const object = JSON.parse(r.content);

View File

@@ -17,7 +17,7 @@ import { GlobalModule } from '@/GlobalModule.js';
import { CoreModule } from '@/core/CoreModule.js'; import { CoreModule } from '@/core/CoreModule.js';
import { FederatedInstanceService } from '@/core/FederatedInstanceService.js'; import { FederatedInstanceService } from '@/core/FederatedInstanceService.js';
import { LoggerService } from '@/core/LoggerService.js'; import { LoggerService } from '@/core/LoggerService.js';
import type { IActor, IApDocument, ICollection, IPost } from '@/core/activitypub/type.js'; import type { IActivity, IApDocument, IActor, ICollection, IObject, IOrderedCollection, IOrderedCollectionPage, IPost } from '@/core/activitypub/type.js';
import { MiMeta, MiNote } from '@/models/_.js'; import { MiMeta, MiNote } from '@/models/_.js';
import { secureRndstr } from '@/misc/secure-rndstr.js'; import { secureRndstr } from '@/misc/secure-rndstr.js';
import { DownloadService } from '@/core/DownloadService.js'; import { DownloadService } from '@/core/DownloadService.js';
@@ -29,6 +29,16 @@ const host = 'https://host1.test';
type NonTransientIActor = IActor & { id: string }; type NonTransientIActor = IActor & { id: string };
type NonTransientIPost = IPost & { id: string }; type NonTransientIPost = IPost & { id: string };
type NonTransientICollection = ICollection & { id: string };
type NonTransientIOrderedCollection = IOrderedCollection & { id: string };
type NonTransientIOrderedCollectionPage = IOrderedCollectionPage & { id: string };
/**
* Use when the order of the array is not definitive
*/
function deepSortedEqual<T extends unknown[]>(array1: unknown[], array2: T): asserts array1 is T {
return assert.deepStrictEqual(array1.sort(), array2.sort());
}
function createRandomActor({ actorHost = host } = {}): NonTransientIActor { function createRandomActor({ actorHost = host } = {}): NonTransientIActor {
const preferredUsername = secureRndstr(8); const preferredUsername = secureRndstr(8);
@@ -60,7 +70,7 @@ function createRandomNotes(actor: NonTransientIActor, length: number): NonTransi
return new Array(length).fill(null).map(() => createRandomNote(actor)); return new Array(length).fill(null).map(() => createRandomNote(actor));
} }
function createRandomFeaturedCollection(actor: NonTransientIActor, length: number): ICollection { function createRandomFeaturedCollection(actor: NonTransientIActor, length: number): NonTransientICollection {
const items = createRandomNotes(actor, length); const items = createRandomNotes(actor, length);
return { return {
@@ -72,6 +82,53 @@ function createRandomFeaturedCollection(actor: NonTransientIActor, length: numbe
}; };
} }
function createRandomActivities(actor: NonTransientIActor, type: string, length: number): IActivity[] {
return new Array(length).fill(null).map((): IActivity => {
const note = createRandomNote(actor);
return {
type,
id: `${note.id}/activity`,
actor,
object: note,
};
});
}
function createRandomNonPagedOutbox(actor: NonTransientIActor, length: number): NonTransientIOrderedCollection {
const orderedItems = createRandomActivities(actor, 'Create', length);
return {
'@context': 'https://www.w3.org/ns/activitystreams',
type: 'OrderedCollection',
id: actor.outbox as string,
totalItems: orderedItems.length,
orderedItems,
};
}
function createRandomOutboxPage(actor: NonTransientIActor, id: string, length: number): NonTransientIOrderedCollectionPage {
const orderedItems = createRandomActivities(actor, 'Create', length);
return {
'@context': 'https://www.w3.org/ns/activitystreams',
type: 'OrderedCollectionPage',
id,
totalItems: orderedItems.length,
orderedItems,
};
}
function createRandomPagedOutbox(actor: NonTransientIActor): NonTransientIOrderedCollection {
return {
'@context': 'https://www.w3.org/ns/activitystreams',
type: 'OrderedCollection',
id: actor.outbox as string,
totalItems: 10,
first: `${actor.outbox}?first`,
};
}
async function createRandomRemoteUser( async function createRandomRemoteUser(
resolver: MockResolver, resolver: MockResolver,
personService: ApPersonService, personService: ApPersonService,
@@ -196,7 +253,7 @@ describe('ActivityPub', () => {
describe('Renderer', () => { describe('Renderer', () => {
test('Render an announce with visibility: followers', () => { test('Render an announce with visibility: followers', () => {
rendererService.renderAnnounce(null, { rendererService.renderAnnounce('hoge', {
createdAt: new Date(0), createdAt: new Date(0),
visibility: 'followers', visibility: 'followers',
} as MiNote); } as MiNote);
@@ -216,7 +273,7 @@ describe('ActivityPub', () => {
await personService.createPerson(actor.id, resolver); await personService.createPerson(actor.id, resolver);
// All notes in `featured` are same-origin, no need to fetch notes again // All notes in `featured` are same-origin, no need to fetch notes again
assert.deepStrictEqual(resolver.remoteGetTrials(), [actor.id, actor.featured]); deepSortedEqual(resolver.remoteGetTrials(), [actor.id, actor.featured, actor.outbox]);
// Created notes without resolving anything // Created notes without resolving anything
for (const item of featured.items as IPost[]) { for (const item of featured.items as IPost[]) {
@@ -247,9 +304,9 @@ describe('ActivityPub', () => {
await personService.createPerson(actor1.id, resolver); await personService.createPerson(actor1.id, resolver);
// actor2Note is from a different server and needs to be fetched again // actor2Note is from a different server and needs to be fetched again
assert.deepStrictEqual( deepSortedEqual(
resolver.remoteGetTrials(), resolver.remoteGetTrials(),
[actor1.id, actor1.featured, actor2Note.id, actor2.id], [actor1.id, actor1.featured, actor1.outbox, actor2Note.id, actor2.id, actor2.outbox],
); );
const note = await noteService.fetchNote(actor2Note.id); const note = await noteService.fetchNote(actor2Note.id);
@@ -276,6 +333,95 @@ describe('ActivityPub', () => {
}); });
}); });
describe('Outbox', () => {
test('Fetch non-paged outbox from IActor', async () => {
const actor = createRandomActor();
const outbox = createRandomNonPagedOutbox(actor, 10);
resolver.register(actor.id, actor);
resolver.register(actor.outbox as string, outbox);
await personService.createPerson(actor.id, resolver);
deepSortedEqual(
resolver.remoteGetTrials(),
[actor.id, actor.outbox],
);
for (const item of outbox.orderedItems as IActivity[]) {
const note = await noteService.fetchNote(item.object);
assert.ok(note);
assert.strictEqual(note.text, 'test test foo');
assert.strictEqual(note.uri, (item.object as IObject).id);
}
});
test('Fetch paged outbox from IActor', async () => {
const actor = createRandomActor();
const outbox = createRandomPagedOutbox(actor);
const page = createRandomOutboxPage(actor, outbox.id, 10);
resolver.register(actor.id, actor);
resolver.register(actor.outbox as string, outbox);
resolver.register(outbox.first as string, page);
await personService.createPerson(actor.id, resolver);
deepSortedEqual(
resolver.remoteGetTrials(),
[actor.id, actor.outbox, outbox.first],
);
for (const item of page.orderedItems as IActivity[]) {
const note = await noteService.fetchNote(item.object);
assert.ok(note);
assert.strictEqual(note.text, 'test test foo');
assert.strictEqual(note.uri, (item.object as IObject).id);
}
});
test('Fetch only the first 20 items', async () => {
const actor = createRandomActor();
const outbox = createRandomNonPagedOutbox(actor, 200);
resolver.register(actor.id, actor);
resolver.register(actor.outbox as string, outbox);
await personService.createPerson(actor.id, resolver);
const items = outbox.orderedItems as IActivity[];
deepSortedEqual(
resolver.remoteGetTrials(),
[actor.id, actor.outbox],
);
assert.ok(await noteService.fetchNote(items[19].object));
assert.ok(!await noteService.fetchNote(items[20].object));
});
test('Perform only Create activities', async () => {
const actor = createRandomActor();
const outbox = createRandomNonPagedOutbox(actor, 0);
outbox.orderedItems = createRandomActivities(actor, 'Announce', 10);
resolver.register(actor.id, actor);
resolver.register(actor.outbox as string, outbox);
await personService.createPerson(actor.id, resolver);
deepSortedEqual(
resolver.remoteGetTrials(),
[actor.id, actor.outbox],
);
for (const item of outbox.orderedItems as IActivity[]) {
const note = await noteService.fetchNote(item.object);
assert.ok(!note);
}
});
});
describe('Images', () => { describe('Images', () => {
test('Create images', async () => { test('Create images', async () => {
const imageObject: IApDocument = { const imageObject: IApDocument = {

View File

@@ -12,7 +12,29 @@ SPDX-License-Identifier: AGPL-3.0-only
<div class="_gaps_m"> <div class="_gaps_m">
<MkInput v-model="iconUrl"> <MkInput v-model="iconUrl">
<template #prefix><i class="ti ti-link"></i></template> <template #prefix><i class="ti ti-link"></i></template>
<template #label>{{ i18n.ts.iconUrl }}</template> <template #label>{{ i18n.ts._serverSettings.iconUrl }}</template>
</MkInput>
<MkInput v-model="app192IconUrl">
<template #prefix><i class="ti ti-link"></i></template>
<template #label>{{ i18n.ts._serverSettings.iconUrl }} (App/192px)</template>
<template #caption>
<div>{{ i18n.t('_serverSettings.appIconDescription', { host: instance.name ?? host }) }}</div>
<div>({{ i18n.ts._serverSettings.appIconUsageExample }})</div>
<div>{{ i18n.ts._serverSettings.appIconStyleRecommendation }}</div>
<div><strong>{{ i18n.t('_serverSettings.appIconResolutionMustBe', { resolution: '192x192px' }) }}</strong></div>
</template>
</MkInput>
<MkInput v-model="app512IconUrl">
<template #prefix><i class="ti ti-link"></i></template>
<template #label>{{ i18n.ts._serverSettings.iconUrl }} (App/512px)</template>
<template #caption>
<div>{{ i18n.t('_serverSettings.appIconDescription', { host: instance.name ?? host }) }}</div>
<div>({{ i18n.ts._serverSettings.appIconUsageExample }})</div>
<div>{{ i18n.ts._serverSettings.appIconStyleRecommendation }}</div>
<div><strong>{{ i18n.t('_serverSettings.appIconResolutionMustBe', { resolution: '512x512px' }) }}</strong></div>
</template>
</MkInput> </MkInput>
<MkInput v-model="bannerUrl"> <MkInput v-model="bannerUrl">
@@ -53,6 +75,10 @@ SPDX-License-Identifier: AGPL-3.0-only
<template #label>{{ i18n.ts.instanceDefaultDarkTheme }}</template> <template #label>{{ i18n.ts.instanceDefaultDarkTheme }}</template>
<template #caption>{{ i18n.ts.instanceDefaultThemeDescription }}</template> <template #caption>{{ i18n.ts.instanceDefaultThemeDescription }}</template>
</MkTextarea> </MkTextarea>
<MkTextarea v-model="manifestJsonOverride">
<template #label>{{ i18n.ts._serverSettings.manifestJsonOverride }}</template>
</MkTextarea>
</div> </div>
</FormSuspense> </FormSuspense>
</MkSpacer> </MkSpacer>
@@ -69,6 +95,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup> <script lang="ts" setup>
import { } from 'vue'; import { } from 'vue';
import JSON5 from 'json5';
import XHeader from './_header_.vue'; import XHeader from './_header_.vue';
import MkSwitch from '@/components/MkSwitch.vue'; import MkSwitch from '@/components/MkSwitch.vue';
import MkInput from '@/components/MkInput.vue'; import MkInput from '@/components/MkInput.vue';
@@ -77,13 +104,16 @@ import FormSection from '@/components/form/section.vue';
import FormSplit from '@/components/form/split.vue'; import FormSplit from '@/components/form/split.vue';
import FormSuspense from '@/components/form/suspense.vue'; import FormSuspense from '@/components/form/suspense.vue';
import * as os from '@/os'; import * as os from '@/os';
import { fetchInstance } from '@/instance'; import { instance, fetchInstance } from '@/instance';
import { i18n } from '@/i18n'; import { i18n } from '@/i18n';
import { definePageMetadata } from '@/scripts/page-metadata'; import { definePageMetadata } from '@/scripts/page-metadata';
import MkButton from '@/components/MkButton.vue'; import MkButton from '@/components/MkButton.vue';
import MkColorInput from '@/components/MkColorInput.vue'; import MkColorInput from '@/components/MkColorInput.vue';
import { host } from '@/config';
let iconUrl: string | null = $ref(null); let iconUrl: string | null = $ref(null);
let app192IconUrl: string | null = $ref(null);
let app512IconUrl: string | null = $ref(null);
let bannerUrl: string | null = $ref(null); let bannerUrl: string | null = $ref(null);
let backgroundImageUrl: string | null = $ref(null); let backgroundImageUrl: string | null = $ref(null);
let themeColor: any = $ref(null); let themeColor: any = $ref(null);
@@ -92,10 +122,13 @@ let defaultDarkTheme: any = $ref(null);
let serverErrorImageUrl: string | null = $ref(null); let serverErrorImageUrl: string | null = $ref(null);
let infoImageUrl: string | null = $ref(null); let infoImageUrl: string | null = $ref(null);
let notFoundImageUrl: string | null = $ref(null); let notFoundImageUrl: string | null = $ref(null);
let manifestJsonOverride: string = $ref('{}');
async function init() { async function init() {
const meta = await os.api('admin/meta'); const meta = await os.api('admin/meta');
iconUrl = meta.iconUrl; iconUrl = meta.iconUrl;
app192IconUrl = meta.app192IconUrl;
app512IconUrl = meta.app512IconUrl;
bannerUrl = meta.bannerUrl; bannerUrl = meta.bannerUrl;
backgroundImageUrl = meta.backgroundImageUrl; backgroundImageUrl = meta.backgroundImageUrl;
themeColor = meta.themeColor; themeColor = meta.themeColor;
@@ -104,11 +137,14 @@ async function init() {
serverErrorImageUrl = meta.serverErrorImageUrl; serverErrorImageUrl = meta.serverErrorImageUrl;
infoImageUrl = meta.infoImageUrl; infoImageUrl = meta.infoImageUrl;
notFoundImageUrl = meta.notFoundImageUrl; notFoundImageUrl = meta.notFoundImageUrl;
manifestJsonOverride = meta.manifestJsonOverride === '' ? '{}' : JSON.stringify(JSON.parse(meta.manifestJsonOverride), null, '\t');
} }
function save() { function save() {
os.apiWithDialog('admin/update-meta', { os.apiWithDialog('admin/update-meta', {
iconUrl, iconUrl,
app192IconUrl,
app512IconUrl,
bannerUrl, bannerUrl,
backgroundImageUrl, backgroundImageUrl,
themeColor: themeColor === '' ? null : themeColor, themeColor: themeColor === '' ? null : themeColor,
@@ -117,6 +153,7 @@ function save() {
infoImageUrl, infoImageUrl,
notFoundImageUrl, notFoundImageUrl,
serverErrorImageUrl, serverErrorImageUrl,
manifestJsonOverride: manifestJsonOverride === '' ? '{}' : JSON.stringify(JSON5.parse(manifestJsonOverride)),
}).then(() => { }).then(() => {
fetchInstance(); fetchInstance();
}); });

View File

@@ -112,8 +112,8 @@ export function getConfig(): UserConfig {
build: { build: {
target: [ target: [
'chrome108', 'chrome116',
'firefox109', 'firefox116',
'safari16', 'safari16',
], ],
manifest: 'manifest.json', manifest: 'manifest.json',

View File

@@ -353,6 +353,9 @@ export type InstanceMetadata = LiteInstanceMetadata | DetailedInstanceMetadata;
export type AdminInstanceMetadata = DetailedInstanceMetadata & { export type AdminInstanceMetadata = DetailedInstanceMetadata & {
// TODO: There are more fields. // TODO: There are more fields.
blockedHosts: string[]; blockedHosts: string[];
app192IconUrl: string | null;
app512IconUrl: string | null;
manifestJsonOverride: string;
}; };
export type ServerInfo = { export type ServerInfo = {