Merge branch 'develop' into system-accounts
This commit is contained in:
16
packages/backend/migration/1739006797620-GoogleAnalytics.js
Normal file
16
packages/backend/migration/1739006797620-GoogleAnalytics.js
Normal file
@@ -0,0 +1,16 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
export class GoogleAnalytics1739006797620 {
|
||||
name = 'GoogleAnalytics1739006797620'
|
||||
|
||||
async up(queryRunner) {
|
||||
await queryRunner.query(`ALTER TABLE "meta" ADD "googleAnalyticsMeasurementId" character varying(64)`);
|
||||
}
|
||||
|
||||
async down(queryRunner) {
|
||||
await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "googleAnalyticsMeasurementId"`);
|
||||
}
|
||||
}
|
@@ -73,6 +73,7 @@ type Source = {
|
||||
proxyBypassHosts?: string[];
|
||||
|
||||
allowedPrivateNetworks?: string[];
|
||||
disallowExternalApRedirect?: boolean;
|
||||
|
||||
maxFileSize?: number;
|
||||
|
||||
@@ -149,6 +150,7 @@ export type Config = {
|
||||
proxySmtp: string | undefined;
|
||||
proxyBypassHosts: string[] | undefined;
|
||||
allowedPrivateNetworks: string[] | undefined;
|
||||
disallowExternalApRedirect: boolean;
|
||||
maxFileSize: number;
|
||||
clusterLimit: number | undefined;
|
||||
id: string;
|
||||
@@ -287,6 +289,7 @@ export function loadConfig(): Config {
|
||||
proxySmtp: config.proxySmtp,
|
||||
proxyBypassHosts: config.proxyBypassHosts,
|
||||
allowedPrivateNetworks: config.allowedPrivateNetworks,
|
||||
disallowExternalApRedirect: config.disallowExternalApRedirect ?? false,
|
||||
maxFileSize: config.maxFileSize ?? 262144000,
|
||||
clusterLimit: config.clusterLimit,
|
||||
outgoingAddress: config.outgoingAddress,
|
||||
|
@@ -60,8 +60,8 @@ export class DownloadService {
|
||||
request: operationTimeout, // whole operation timeout
|
||||
},
|
||||
agent: {
|
||||
http: this.httpRequestService.httpAgent,
|
||||
https: this.httpRequestService.httpsAgent,
|
||||
http: this.httpRequestService.getAgentForHttp(urlObj, true),
|
||||
https: this.httpRequestService.getAgentForHttps(urlObj, true),
|
||||
},
|
||||
http2: false, // default
|
||||
retry: {
|
||||
|
@@ -16,7 +16,7 @@ import type { Config } from '@/config.js';
|
||||
import { StatusError } from '@/misc/status-error.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { validateContentTypeSetAsActivityPub } from '@/core/activitypub/misc/validator.js';
|
||||
import { assertActivityMatchesUrls } from '@/core/activitypub/misc/check-against-url.js';
|
||||
import { assertActivityMatchesUrls, FetchAllowSoftFailMask } from '@/core/activitypub/misc/check-against-url.js';
|
||||
import type { IObject } from '@/core/activitypub/type.js';
|
||||
import type { Response } from 'node-fetch';
|
||||
import type { URL } from 'node:url';
|
||||
@@ -115,32 +115,32 @@ export class HttpRequestService {
|
||||
/**
|
||||
* Get http non-proxy agent (without local address filtering)
|
||||
*/
|
||||
private httpNative: http.Agent;
|
||||
private readonly httpNative: http.Agent;
|
||||
|
||||
/**
|
||||
* Get https non-proxy agent (without local address filtering)
|
||||
*/
|
||||
private httpsNative: https.Agent;
|
||||
private readonly httpsNative: https.Agent;
|
||||
|
||||
/**
|
||||
* Get http non-proxy agent
|
||||
*/
|
||||
private http: http.Agent;
|
||||
private readonly http: http.Agent;
|
||||
|
||||
/**
|
||||
* Get https non-proxy agent
|
||||
*/
|
||||
private https: https.Agent;
|
||||
private readonly https: https.Agent;
|
||||
|
||||
/**
|
||||
* Get http proxy or non-proxy agent
|
||||
*/
|
||||
public httpAgent: http.Agent;
|
||||
public readonly httpAgent: http.Agent;
|
||||
|
||||
/**
|
||||
* Get https proxy or non-proxy agent
|
||||
*/
|
||||
public httpsAgent: https.Agent;
|
||||
public readonly httpsAgent: https.Agent;
|
||||
|
||||
constructor(
|
||||
@Inject(DI.config)
|
||||
@@ -197,7 +197,8 @@ export class HttpRequestService {
|
||||
/**
|
||||
* Get agent by URL
|
||||
* @param url URL
|
||||
* @param bypassProxy Allways bypass proxy
|
||||
* @param bypassProxy Always bypass proxy
|
||||
* @param isLocalAddressAllowed
|
||||
*/
|
||||
@bindThis
|
||||
public getAgentByUrl(url: URL, bypassProxy = false, isLocalAddressAllowed = false): http.Agent | https.Agent {
|
||||
@@ -214,8 +215,40 @@ export class HttpRequestService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get agent for http by URL
|
||||
* @param url URL
|
||||
* @param isLocalAddressAllowed
|
||||
*/
|
||||
@bindThis
|
||||
public async getActivityJson(url: string, isLocalAddressAllowed = false): Promise<IObject> {
|
||||
public getAgentForHttp(url: URL, isLocalAddressAllowed = false): http.Agent {
|
||||
if ((this.config.proxyBypassHosts ?? []).includes(url.hostname)) {
|
||||
return isLocalAddressAllowed
|
||||
? this.httpNative
|
||||
: this.http;
|
||||
} else {
|
||||
return this.httpAgent;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get agent for https by URL
|
||||
* @param url URL
|
||||
* @param isLocalAddressAllowed
|
||||
*/
|
||||
@bindThis
|
||||
public getAgentForHttps(url: URL, isLocalAddressAllowed = false): https.Agent {
|
||||
if ((this.config.proxyBypassHosts ?? []).includes(url.hostname)) {
|
||||
return isLocalAddressAllowed
|
||||
? this.httpsNative
|
||||
: this.https;
|
||||
} else {
|
||||
return this.httpsAgent;
|
||||
}
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async getActivityJson(url: string, isLocalAddressAllowed = false, allowSoftfail: FetchAllowSoftFailMask = FetchAllowSoftFailMask.Strict): Promise<IObject> {
|
||||
const res = await this.send(url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
@@ -232,7 +265,7 @@ export class HttpRequestService {
|
||||
const finalUrl = res.url; // redirects may have been involved
|
||||
const activity = await res.json() as IObject;
|
||||
|
||||
assertActivityMatchesUrls(activity, [finalUrl]);
|
||||
assertActivityMatchesUrls(url, activity, [finalUrl], allowSoftfail);
|
||||
|
||||
return activity;
|
||||
}
|
||||
|
@@ -3,7 +3,7 @@
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { Brackets, In } from 'typeorm';
|
||||
import { Brackets, In, IsNull, Not } from 'typeorm';
|
||||
import { Injectable, Inject } from '@nestjs/common';
|
||||
import type { MiUser, MiLocalUser, MiRemoteUser } from '@/models/User.js';
|
||||
import type { MiNote, IMentionedRemoteUsers } from '@/models/Note.js';
|
||||
@@ -189,13 +189,27 @@ export class NoteDeleteService {
|
||||
}) as MiRemoteUser[];
|
||||
}
|
||||
|
||||
@bindThis
|
||||
private async getRenotedOrRepliedRemoteUsers(note: MiNote) {
|
||||
const query = this.notesRepository.createQueryBuilder('note')
|
||||
.leftJoinAndSelect('note.user', 'user')
|
||||
.where(new Brackets(qb => {
|
||||
qb.orWhere('note.renoteId = :renoteId', { renoteId: note.id });
|
||||
qb.orWhere('note.replyId = :replyId', { replyId: note.id });
|
||||
}))
|
||||
.andWhere({ userHost: Not(IsNull()) });
|
||||
const notes = await query.getMany() as (MiNote & { user: MiRemoteUser })[];
|
||||
const remoteUsers = notes.map(({ user }) => user);
|
||||
return remoteUsers;
|
||||
}
|
||||
|
||||
@bindThis
|
||||
private async deliverToConcerned(user: { id: MiLocalUser['id']; host: null; }, note: MiNote, content: any) {
|
||||
this.apDeliverManagerService.deliverToFollowers(user, content);
|
||||
this.relayService.deliverToRelays(user, content);
|
||||
const remoteUsers = await this.getMentionedRemoteUsers(note);
|
||||
for (const remoteUser of remoteUsers) {
|
||||
this.apDeliverManagerService.deliverToUser(user, content, remoteUser);
|
||||
}
|
||||
this.apDeliverManagerService.deliverToUsers(user, content, [
|
||||
...await this.getMentionedRemoteUsers(note),
|
||||
...await this.getRenotedOrRepliedRemoteUsers(note),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
@@ -74,7 +74,7 @@ export class RemoteUserResolveService {
|
||||
if (user == null) {
|
||||
const self = await this.resolveSelf(acctLower);
|
||||
|
||||
if (self.href.startsWith(this.config.url)) {
|
||||
if (this.utilityService.isUriLocal(self.href)) {
|
||||
const local = this.apDbResolverService.parseUri(self.href);
|
||||
if (local.local && local.type === 'users') {
|
||||
// the LR points to local
|
||||
|
@@ -196,6 +196,25 @@ export class ApDeliverManagerService {
|
||||
await manager.execute();
|
||||
}
|
||||
|
||||
/**
|
||||
* Deliver activity to users
|
||||
* @param actor
|
||||
* @param activity Activity
|
||||
* @param targets Target users
|
||||
*/
|
||||
@bindThis
|
||||
public async deliverToUsers(actor: { id: MiLocalUser['id']; host: null; }, activity: IActivity, targets: MiRemoteUser[]): Promise<void> {
|
||||
const manager = new DeliverManager(
|
||||
this.userEntityService,
|
||||
this.followingsRepository,
|
||||
this.queueService,
|
||||
actor,
|
||||
activity,
|
||||
);
|
||||
for (const to of targets) manager.addDirectRecipe(to);
|
||||
await manager.execute();
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public createDeliverManager(actor: { id: MiUser['id']; host: null; }, activity: IActivity | null): DeliverManager {
|
||||
return new DeliverManager(
|
||||
|
@@ -27,6 +27,7 @@ import type { UsersRepository, UserProfilesRepository, NotesRepository, DriveFil
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { CustomEmojiService } from '@/core/CustomEmojiService.js';
|
||||
import { IdService } from '@/core/IdService.js';
|
||||
import { UtilityService } from '@/core/UtilityService.js';
|
||||
import { JsonLdService } from './JsonLdService.js';
|
||||
import { ApMfmService } from './ApMfmService.js';
|
||||
import { CONTEXT } from './misc/contexts.js';
|
||||
@@ -64,6 +65,7 @@ export class ApRendererService {
|
||||
private apMfmService: ApMfmService,
|
||||
private mfmService: MfmService,
|
||||
private idService: IdService,
|
||||
private utilityService: UtilityService,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -612,7 +614,7 @@ export class ApRendererService {
|
||||
|
||||
@bindThis
|
||||
public renderUndo(object: string | IObject, user: { id: MiUser['id'] }): IUndo {
|
||||
const id = typeof object !== 'string' && typeof object.id === 'string' && object.id.startsWith(this.config.url) ? `${object.id}/undo` : undefined;
|
||||
const id = typeof object !== 'string' && typeof object.id === 'string' && this.utilityService.isUriLocal(object.id) ? `${object.id}/undo` : undefined;
|
||||
|
||||
return {
|
||||
type: 'Undo',
|
||||
|
@@ -17,7 +17,7 @@ import { LoggerService } from '@/core/LoggerService.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import type Logger from '@/logger.js';
|
||||
import { validateContentTypeSetAsActivityPub } from '@/core/activitypub/misc/validator.js';
|
||||
import { assertActivityMatchesUrls } from '@/core/activitypub/misc/check-against-url.js';
|
||||
import { assertActivityMatchesUrls, FetchAllowSoftFailMask as FetchAllowSoftFailMask } from '@/core/activitypub/misc/check-against-url.js';
|
||||
import type { IObject } from './type.js';
|
||||
|
||||
type Request = {
|
||||
@@ -185,7 +185,7 @@ export class ApRequestService {
|
||||
* @param url URL to fetch
|
||||
*/
|
||||
@bindThis
|
||||
public async signedGet(url: string, user: { id: MiUser['id'] }, followAlternate?: boolean): Promise<unknown> {
|
||||
public async signedGet(url: string, user: { id: MiUser['id'] }, allowSoftfail: FetchAllowSoftFailMask = FetchAllowSoftFailMask.Strict, followAlternate?: boolean): Promise<unknown> {
|
||||
const _followAlternate = followAlternate ?? true;
|
||||
const keypair = await this.userKeypairService.getUserKeypair(user.id);
|
||||
|
||||
@@ -243,7 +243,7 @@ export class ApRequestService {
|
||||
if (alternate) {
|
||||
const href = alternate.getAttribute('href');
|
||||
if (href && this.utilityService.punyHost(url) === this.utilityService.punyHost(href)) {
|
||||
return await this.signedGet(href, user, false);
|
||||
return await this.signedGet(href, user, allowSoftfail, false);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -258,7 +258,7 @@ export class ApRequestService {
|
||||
const finalUrl = res.url; // redirects may have been involved
|
||||
const activity = await res.json() as IObject;
|
||||
|
||||
assertActivityMatchesUrls(activity, [finalUrl]);
|
||||
assertActivityMatchesUrls(url, activity, [finalUrl], allowSoftfail);
|
||||
|
||||
return activity;
|
||||
}
|
||||
|
@@ -14,12 +14,13 @@ import { UtilityService } from '@/core/UtilityService.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { LoggerService } from '@/core/LoggerService.js';
|
||||
import type Logger from '@/logger.js';
|
||||
import { IdentifiableError } from '@/misc/identifiable-error.js';
|
||||
import { SystemAccountService } from '@/core/SystemAccountService.js';
|
||||
import { IdentifiableError } from '@/misc/identifiable-error.js';
|
||||
import { isCollectionOrOrderedCollection } from './type.js';
|
||||
import { ApDbResolverService } from './ApDbResolverService.js';
|
||||
import { ApRendererService } from './ApRendererService.js';
|
||||
import { ApRequestService } from './ApRequestService.js';
|
||||
import { FetchAllowSoftFailMask } from './misc/check-against-url.js';
|
||||
import type { IObject, ICollection, IOrderedCollection } from './type.js';
|
||||
|
||||
export class Resolver {
|
||||
@@ -72,7 +73,7 @@ export class Resolver {
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async resolve(value: string | IObject): Promise<IObject> {
|
||||
public async resolve(value: string | IObject, allowSoftfail: FetchAllowSoftFailMask = FetchAllowSoftFailMask.Strict): Promise<IObject> {
|
||||
if (typeof value !== 'string') {
|
||||
return value;
|
||||
}
|
||||
@@ -108,8 +109,8 @@ export class Resolver {
|
||||
}
|
||||
|
||||
const object = (this.user
|
||||
? await this.apRequestService.signedGet(value, this.user) as IObject
|
||||
: await this.httpRequestService.getActivityJson(value)) as IObject;
|
||||
? await this.apRequestService.signedGet(value, this.user, allowSoftfail) as IObject
|
||||
: await this.httpRequestService.getActivityJson(value, undefined, allowSoftfail)) as IObject;
|
||||
|
||||
if (
|
||||
Array.isArray(object['@context']) ?
|
||||
@@ -119,18 +120,6 @@ export class Resolver {
|
||||
throw new IdentifiableError('72180409-793c-4973-868e-5a118eb5519b', 'invalid response');
|
||||
}
|
||||
|
||||
// HttpRequestService / ApRequestService have already checked that
|
||||
// `object.id` or `object.url` matches the URL used to fetch the
|
||||
// object after redirects; here we double-check that no redirects
|
||||
// bounced between hosts
|
||||
if (object.id == null) {
|
||||
throw new IdentifiableError('ad2dc287-75c1-44c4-839d-3d2e64576675', 'invalid AP object: missing id');
|
||||
}
|
||||
|
||||
if (this.utilityService.punyHost(object.id) !== this.utilityService.punyHost(value)) {
|
||||
throw new IdentifiableError('fd93c2fa-69a8-440f-880b-bf178e0ec877', `invalid AP object ${value}: id ${object.id} has different host`);
|
||||
}
|
||||
|
||||
return object;
|
||||
}
|
||||
|
||||
|
@@ -4,18 +4,124 @@
|
||||
*/
|
||||
import type { IObject } from '../type.js';
|
||||
|
||||
export function assertActivityMatchesUrls(activity: IObject, urls: string[]) {
|
||||
const hosts = urls.map(it => new URL(it).host);
|
||||
|
||||
const idOk = activity.id !== undefined && hosts.includes(new URL(activity.id).host);
|
||||
|
||||
// technically `activity.url` could be an `ApObject = IObject |
|
||||
// string | (IObject | string)[]`, but if it's a complicated thing
|
||||
// and the `activity.id` doesn't match, I think we're fine
|
||||
// rejecting the activity
|
||||
const urlOk = typeof(activity.url) === 'string' && hosts.includes(new URL(activity.url).host);
|
||||
|
||||
if (!idOk && !urlOk) {
|
||||
throw new Error(`bad Activity: neither id(${activity?.id}) nor url(${activity?.url}) match location(${urls})`);
|
||||
}
|
||||
export enum FetchAllowSoftFailMask {
|
||||
// Allow no softfail flags
|
||||
Strict = 0,
|
||||
// The values in tuple (requestUrl, finalUrl, objectId) are not all identical
|
||||
//
|
||||
// This condition is common for user-initiated lookups but should not be allowed in federation loop
|
||||
//
|
||||
// Allow variations:
|
||||
// good example: https://alice.example.com/@user -> https://alice.example.com/user/:userId
|
||||
// problematic example: https://alice.example.com/redirect?url=https://bad.example.com/ -> https://bad.example.com/ -> https://alice.example.com/somethingElse
|
||||
NonCanonicalId = 1 << 0,
|
||||
// Allow the final object to be at most one subdomain deeper than the request URL, similar to SPF relaxed alignment
|
||||
//
|
||||
// Currently no code path allows this flag to be set, but is kept in case of future use as some niche deployments do this, and we provide a pre-reviewed mechanism to opt-in.
|
||||
//
|
||||
// Allow variations:
|
||||
// good example: https://example.com/@user -> https://activitypub.example.com/@user { id: 'https://activitypub.example.com/@user' }
|
||||
// problematic example: https://example.com/@user -> https://untrusted.example.com/@user { id: 'https://untrusted.example.com/@user' }
|
||||
MisalignedOrigin = 1 << 1,
|
||||
// The requested URL has a different host than the returned object ID, although the final URL is still consistent with the object ID
|
||||
//
|
||||
// This condition is common for user-initiated lookups using an intermediate host but should not be allowed in federation loops
|
||||
//
|
||||
// Allow variations:
|
||||
// good example: https://alice.example.com/@user@bob.example.com -> https://bob.example.com/@user { id: 'https://bob.example.com/@user' }
|
||||
// problematic example: https://alice.example.com/definitelyAlice -> https://bob.example.com/@somebodyElse { id: 'https://bob.example.com/@somebodyElse' }
|
||||
CrossOrigin = 1 << 2 | MisalignedOrigin,
|
||||
// Allow all softfail flags
|
||||
//
|
||||
// do not use this flag on released code
|
||||
Any = ~0,
|
||||
}
|
||||
|
||||
/**
|
||||
* Fuzz match on whether the candidate host has authority over the request host
|
||||
*
|
||||
* @param requestHost The host of the requested resources
|
||||
* @param candidateHost The host of final response
|
||||
* @returns Whether the candidate host has authority over the request host, or if a soft fail is required for a match
|
||||
*/
|
||||
function hostFuzzyMatch(requestHost: string, candidateHost: string): FetchAllowSoftFailMask {
|
||||
const requestFqdn = requestHost.endsWith('.') ? requestHost : `${requestHost}.`;
|
||||
const candidateFqdn = candidateHost.endsWith('.') ? candidateHost : `${candidateHost}.`;
|
||||
|
||||
if (requestFqdn === candidateFqdn) {
|
||||
return FetchAllowSoftFailMask.Strict;
|
||||
}
|
||||
|
||||
// allow only one case where candidateHost is a first-level subdomain of requestHost
|
||||
const requestDnsDepth = requestFqdn.split('.').length;
|
||||
const candidateDnsDepth = candidateFqdn.split('.').length;
|
||||
|
||||
if ((candidateDnsDepth - requestDnsDepth) !== 1) {
|
||||
return FetchAllowSoftFailMask.CrossOrigin;
|
||||
}
|
||||
|
||||
if (`.${candidateHost}`.endsWith(`.${requestHost}`)) {
|
||||
return FetchAllowSoftFailMask.MisalignedOrigin;
|
||||
}
|
||||
|
||||
return FetchAllowSoftFailMask.CrossOrigin;
|
||||
}
|
||||
|
||||
// normalize host names by removing www. prefix
|
||||
function normalizeSynonymousSubdomain(url: URL | string): URL {
|
||||
const urlParsed = url instanceof URL ? url : new URL(url);
|
||||
const host = urlParsed.host;
|
||||
const normalizedHost = host.replace(/^www\./, '');
|
||||
return new URL(urlParsed.toString().replace(host, normalizedHost));
|
||||
}
|
||||
|
||||
export function assertActivityMatchesUrls(requestUrl: string | URL, activity: IObject, candidateUrls: (string | URL)[], allowSoftfail: FetchAllowSoftFailMask): FetchAllowSoftFailMask {
|
||||
// must have a unique identifier to verify authority
|
||||
if (!activity.id) {
|
||||
throw new Error('bad Activity: missing id field');
|
||||
}
|
||||
|
||||
let softfail = 0;
|
||||
|
||||
// if the flag is allowed, set the flag on return otherwise throw
|
||||
const requireSoftfail = (needed: FetchAllowSoftFailMask, message: string) => {
|
||||
if ((allowSoftfail & needed) !== needed) {
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
softfail |= needed;
|
||||
};
|
||||
|
||||
const requestUrlParsed = normalizeSynonymousSubdomain(requestUrl);
|
||||
const idParsed = normalizeSynonymousSubdomain(activity.id);
|
||||
|
||||
const candidateUrlsParsed = candidateUrls.map(it => normalizeSynonymousSubdomain(it));
|
||||
|
||||
const requestUrlSecure = requestUrlParsed.protocol === 'https:';
|
||||
const finalUrlSecure = candidateUrlsParsed.every(it => it.protocol === 'https:');
|
||||
if (requestUrlSecure && !finalUrlSecure) {
|
||||
throw new Error(`bad Activity: id(${activity.id}) is not allowed to have http:// in the url`);
|
||||
}
|
||||
|
||||
// Compare final URL to the ID
|
||||
if (!candidateUrlsParsed.some(it => it.href === idParsed.href)) {
|
||||
requireSoftfail(FetchAllowSoftFailMask.NonCanonicalId, `bad Activity: id(${activity.id}) does not match response url(${candidateUrlsParsed.map(it => it.toString())})`);
|
||||
|
||||
// at lease host need to match exactly (ActivityPub requirement)
|
||||
if (!candidateUrlsParsed.some(it => idParsed.host === it.host)) {
|
||||
throw new Error(`bad Activity: id(${activity.id}) does not match response host(${candidateUrlsParsed.map(it => it.host)})`);
|
||||
}
|
||||
}
|
||||
|
||||
// Compare request URL to the ID
|
||||
if (!requestUrlParsed.href.includes(idParsed.href)) {
|
||||
requireSoftfail(FetchAllowSoftFailMask.NonCanonicalId, `bad Activity: id(${activity.id}) does not match request url(${requestUrlParsed.toString()})`);
|
||||
|
||||
// if cross-origin lookup is allowed, we can accept some variation between the original request URL to the final object ID (but not between the final URL and the object ID)
|
||||
const hostResult = hostFuzzyMatch(requestUrlParsed.host, idParsed.host);
|
||||
|
||||
requireSoftfail(hostResult, `bad Activity: id(${activity.id}) is valid but is not the same origin as request url(${requestUrlParsed.toString()})`);
|
||||
}
|
||||
|
||||
return softfail;
|
||||
}
|
||||
|
@@ -95,6 +95,7 @@ export class MetaEntityService {
|
||||
enableTurnstile: instance.enableTurnstile,
|
||||
turnstileSiteKey: instance.turnstileSiteKey,
|
||||
enableTestcaptcha: instance.enableTestcaptcha,
|
||||
googleAnalyticsMeasurementId: instance.googleAnalyticsMeasurementId,
|
||||
swPublickey: instance.swPublicKey,
|
||||
themeColor: instance.themeColor,
|
||||
mascotImageUrl: instance.mascotImageUrl ?? '/assets/ai.png',
|
||||
|
@@ -658,4 +658,10 @@ export class MiMeta {
|
||||
default: '{}',
|
||||
})
|
||||
public federationHosts: string[];
|
||||
|
||||
@Column('varchar', {
|
||||
length: 64,
|
||||
nullable: true,
|
||||
})
|
||||
public googleAnalyticsMeasurementId: string | null;
|
||||
}
|
||||
|
@@ -119,6 +119,10 @@ export const packedMetaLiteSchema = {
|
||||
type: 'boolean',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
googleAnalyticsMeasurementId: {
|
||||
type: 'string',
|
||||
optional: false, nullable: true,
|
||||
},
|
||||
swPublickey: {
|
||||
type: 'string',
|
||||
optional: false, nullable: true,
|
||||
|
@@ -103,6 +103,43 @@ export class ServerService implements OnApplicationShutdown {
|
||||
serve: false,
|
||||
});
|
||||
|
||||
// if the requester looks like to be performing an ActivityPub object lookup, reject all external redirects
|
||||
//
|
||||
// this will break lookup that involve copying a URL from a third-party server, like trying to lookup http://charlie.example.com/@alice@alice.com
|
||||
//
|
||||
// this is not required by standard but protect us from peers that did not validate final URL.
|
||||
if (this.config.disallowExternalApRedirect) {
|
||||
const maybeApLookupRegex = /application\/activity\+json|application\/ld\+json.+activitystreams/i;
|
||||
fastify.addHook('onSend', (request, reply, _, done) => {
|
||||
const location = reply.getHeader('location');
|
||||
if (reply.statusCode < 300 || reply.statusCode >= 400 || typeof location !== 'string') {
|
||||
done();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!maybeApLookupRegex.test(request.headers.accept ?? '')) {
|
||||
done();
|
||||
return;
|
||||
}
|
||||
|
||||
const effectiveLocation = process.env.NODE_ENV === 'production' ? location : location.replace(/^http:\/\//, 'https://');
|
||||
if (effectiveLocation.startsWith(`https://${this.config.host}/`)) {
|
||||
done();
|
||||
return;
|
||||
}
|
||||
|
||||
reply.status(406);
|
||||
reply.removeHeader('location');
|
||||
reply.header('content-type', 'text/plain; charset=utf-8');
|
||||
reply.header('link', `<${encodeURI(location)}>; rel="canonical"`);
|
||||
done(null, [
|
||||
"Refusing to relay remote ActivityPub object lookup.",
|
||||
"",
|
||||
`Please remove 'application/activity+json' and 'application/ld+json' from the Accept header or fetch using the authoritative URL at ${location}.`,
|
||||
].join('\n'));
|
||||
});
|
||||
}
|
||||
|
||||
fastify.register(this.apiServerService.createServer, { prefix: '/api' });
|
||||
fastify.register(this.openApiServerService.createServer);
|
||||
fastify.register(this.fileServerService.createServer);
|
||||
|
@@ -74,6 +74,10 @@ export const meta = {
|
||||
type: 'boolean',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
googleAnalyticsMeasurementId: {
|
||||
type: 'string',
|
||||
optional: false, nullable: true,
|
||||
},
|
||||
swPublickey: {
|
||||
type: 'string',
|
||||
optional: false, nullable: true,
|
||||
@@ -576,6 +580,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||
enableTurnstile: instance.enableTurnstile,
|
||||
turnstileSiteKey: instance.turnstileSiteKey,
|
||||
enableTestcaptcha: instance.enableTestcaptcha,
|
||||
googleAnalyticsMeasurementId: instance.googleAnalyticsMeasurementId,
|
||||
swPublickey: instance.swPublicKey,
|
||||
themeColor: instance.themeColor,
|
||||
mascotImageUrl: instance.mascotImageUrl,
|
||||
|
@@ -84,6 +84,7 @@ export const paramDef = {
|
||||
turnstileSiteKey: { type: 'string', nullable: true },
|
||||
turnstileSecretKey: { type: 'string', nullable: true },
|
||||
enableTestcaptcha: { type: 'boolean' },
|
||||
googleAnalyticsMeasurementId: { type: 'string', nullable: true },
|
||||
sensitiveMediaDetection: { type: 'string', enum: ['none', 'all', 'local', 'remote'] },
|
||||
sensitiveMediaDetectionSensitivity: { type: 'string', enum: ['medium', 'low', 'high', 'veryLow', 'veryHigh'] },
|
||||
setSensitiveFlagAutomatically: { type: 'boolean' },
|
||||
@@ -116,7 +117,7 @@ export const paramDef = {
|
||||
useObjectStorage: { type: 'boolean' },
|
||||
objectStorageBaseUrl: { type: 'string', nullable: true },
|
||||
objectStorageBucket: { type: 'string', nullable: true },
|
||||
objectStoragePrefix: { type: 'string', nullable: true },
|
||||
objectStoragePrefix: { type: 'string', pattern: /^[a-zA-Z0-9-._]*$/.source, nullable: true },
|
||||
objectStorageEndpoint: { type: 'string', nullable: true },
|
||||
objectStorageRegion: { type: 'string', nullable: true },
|
||||
objectStoragePort: { type: 'integer', nullable: true },
|
||||
@@ -370,6 +371,12 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||
set.enableTestcaptcha = ps.enableTestcaptcha;
|
||||
}
|
||||
|
||||
if (ps.googleAnalyticsMeasurementId !== undefined) {
|
||||
// 空文字列をnullにしたいので??は使わない
|
||||
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
|
||||
set.googleAnalyticsMeasurementId = ps.googleAnalyticsMeasurementId || null;
|
||||
}
|
||||
|
||||
if (ps.sensitiveMediaDetection !== undefined) {
|
||||
set.sensitiveMediaDetection = ps.sensitiveMediaDetection;
|
||||
}
|
||||
|
@@ -20,6 +20,7 @@ import { UtilityService } from '@/core/UtilityService.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { ApiError } from '../../error.js';
|
||||
import { IdentifiableError } from '@/misc/identifiable-error.js';
|
||||
import { FetchAllowSoftFailMask } from '@/core/activitypub/misc/check-against-url.js';
|
||||
|
||||
export const meta = {
|
||||
tags: ['federation'],
|
||||
@@ -53,11 +54,6 @@ export const meta = {
|
||||
code: 'RESPONSE_INVALID',
|
||||
id: '70193c39-54f3-4813-82f0-70a680f7495b',
|
||||
},
|
||||
responseInvalidIdHostNotMatch: {
|
||||
message: 'Requested URI and response URI host does not match.',
|
||||
code: 'RESPONSE_INVALID_ID_HOST_NOT_MATCH',
|
||||
id: 'a2c9c61a-cb72-43ab-a964-3ca5fddb410a',
|
||||
},
|
||||
noSuchObject: {
|
||||
message: 'No such object.',
|
||||
code: 'NO_SUCH_OBJECT',
|
||||
@@ -153,7 +149,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||
|
||||
// リモートから一旦オブジェクトフェッチ
|
||||
const resolver = this.apResolverService.createResolver();
|
||||
const object = await resolver.resolve(uri).catch((err) => {
|
||||
// allow ap/show exclusively to lookup URLs that are cross-origin or non-canonical (like https://alice.example.com/@bob@bob.example.com -> https://bob.example.com/@bob)
|
||||
const object = await resolver.resolve(uri, FetchAllowSoftFailMask.CrossOrigin | FetchAllowSoftFailMask.NonCanonicalId).catch((err) => {
|
||||
if (err instanceof IdentifiableError) {
|
||||
switch (err.id) {
|
||||
// resolve
|
||||
@@ -165,10 +162,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||
case '09d79f9e-64f1-4316-9cfa-e75c4d091574':
|
||||
throw new ApiError(meta.errors.federationNotAllowed);
|
||||
case '72180409-793c-4973-868e-5a118eb5519b':
|
||||
case 'ad2dc287-75c1-44c4-839d-3d2e64576675':
|
||||
throw new ApiError(meta.errors.responseInvalid);
|
||||
case 'fd93c2fa-69a8-440f-880b-bf178e0ec877':
|
||||
throw new ApiError(meta.errors.responseInvalidIdHostNotMatch);
|
||||
|
||||
// resolveLocal
|
||||
case '02b40cd0-fa92-4b0c-acc9-fb2ada952ab8':
|
||||
|
@@ -39,7 +39,7 @@ export const paramDef = {
|
||||
properties: {
|
||||
name: { type: 'string', minLength: 1, maxLength: 100 },
|
||||
isPublic: { type: 'boolean', default: false },
|
||||
description: { type: 'string', nullable: true, minLength: 1, maxLength: 2048 },
|
||||
description: { type: 'string', nullable: true, maxLength: 2048 },
|
||||
},
|
||||
required: ['name'],
|
||||
} as const;
|
||||
@@ -53,7 +53,9 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
let clip: MiClip;
|
||||
try {
|
||||
clip = await this.clipService.create(me, ps.name, ps.isPublic, ps.description ?? null);
|
||||
// 空文字列をnullにしたいので??は使わない
|
||||
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
|
||||
clip = await this.clipService.create(me, ps.name, ps.isPublic, ps.description || null);
|
||||
} catch (e) {
|
||||
if (e instanceof ClipService.TooManyClipsError) {
|
||||
throw new ApiError(meta.errors.tooManyClips);
|
||||
|
@@ -39,7 +39,7 @@ export const paramDef = {
|
||||
clipId: { type: 'string', format: 'misskey:id' },
|
||||
name: { type: 'string', minLength: 1, maxLength: 100 },
|
||||
isPublic: { type: 'boolean' },
|
||||
description: { type: 'string', nullable: true, minLength: 1, maxLength: 2048 },
|
||||
description: { type: 'string', nullable: true, maxLength: 2048 },
|
||||
},
|
||||
required: ['clipId'],
|
||||
} as const;
|
||||
@@ -53,7 +53,9 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
try {
|
||||
await this.clipService.update(me, ps.clipId, ps.name, ps.isPublic, ps.description);
|
||||
// 空文字列をnullにしたいので??は使わない
|
||||
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
|
||||
await this.clipService.update(me, ps.clipId, ps.name, ps.isPublic, ps.description || null);
|
||||
} catch (e) {
|
||||
if (e instanceof ClipService.NoSuchClipError) {
|
||||
throw new ApiError(meta.errors.noSuchClip);
|
||||
|
@@ -5,112 +5,107 @@
|
||||
*/
|
||||
|
||||
* {
|
||||
font-family: BIZ UDGothic, Roboto, HelveticaNeue, Arial, sans-serif;
|
||||
font-family: BIZ UDGothic, Roboto, HelveticaNeue, Arial, sans-serif;
|
||||
}
|
||||
|
||||
#misskey_app,
|
||||
#splash {
|
||||
display: none !important;
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
body,
|
||||
html {
|
||||
background-color: #222;
|
||||
color: #dfddcc;
|
||||
justify-content: center;
|
||||
margin: auto;
|
||||
padding: 10px;
|
||||
text-align: center;
|
||||
background-color: #222;
|
||||
color: #dfddcc;
|
||||
justify-content: center;
|
||||
margin: auto;
|
||||
padding: 10px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
button {
|
||||
border-radius: 999px;
|
||||
padding: 0px 12px 0px 12px;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
margin-bottom: 12px;
|
||||
border-radius: 999px;
|
||||
padding: 0px 12px 0px 12px;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.button-big {
|
||||
background: linear-gradient(90deg, rgb(134, 179, 0), rgb(74, 179, 0));
|
||||
line-height: 50px;
|
||||
background: linear-gradient(90deg, rgb(134, 179, 0), rgb(74, 179, 0));
|
||||
line-height: 50px;
|
||||
}
|
||||
|
||||
.button-big:hover {
|
||||
background: rgb(153, 204, 0);
|
||||
background: rgb(153, 204, 0);
|
||||
}
|
||||
|
||||
.button-small {
|
||||
background: #444;
|
||||
line-height: 40px;
|
||||
background: #444;
|
||||
line-height: 40px;
|
||||
}
|
||||
|
||||
.button-small:hover {
|
||||
background: #555;
|
||||
background: #555;
|
||||
}
|
||||
|
||||
.button-label-big {
|
||||
color: #222;
|
||||
font-weight: bold;
|
||||
font-size: 20px;
|
||||
padding: 12px;
|
||||
color: #222;
|
||||
font-weight: bold;
|
||||
font-size: 1.2em;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.button-label-small {
|
||||
color: rgb(153, 204, 0);
|
||||
font-size: 16px;
|
||||
padding: 12px;
|
||||
color: rgb(153, 204, 0);
|
||||
font-size: 16px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
a {
|
||||
color: rgb(134, 179, 0);
|
||||
text-decoration: none;
|
||||
color: rgb(134, 179, 0);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
p,
|
||||
li {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.dont-worry,
|
||||
#msg {
|
||||
font-size: 18px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.icon-warning {
|
||||
color: #dec340;
|
||||
height: 4rem;
|
||||
padding-top: 2rem;
|
||||
color: #dec340;
|
||||
height: 4rem;
|
||||
padding-top: 2rem;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 32px;
|
||||
font-size: 1.5em;
|
||||
margin: 1em;
|
||||
}
|
||||
|
||||
code {
|
||||
display: block;
|
||||
font-family: Fira, FiraCode, monospace;
|
||||
background: #333;
|
||||
padding: 0.5rem 1rem;
|
||||
max-width: 40rem;
|
||||
border-radius: 10px;
|
||||
justify-content: center;
|
||||
margin: auto;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
display: block;
|
||||
font-family: Fira, FiraCode, monospace;
|
||||
background: #333;
|
||||
padding: 0.5rem 1rem;
|
||||
max-width: 40rem;
|
||||
border-radius: 10px;
|
||||
justify-content: center;
|
||||
margin: auto;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
summary {
|
||||
cursor: pointer;
|
||||
#errorInfo summary {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
summary > * {
|
||||
display: inline;
|
||||
white-space: pre-wrap;
|
||||
#errorInfo summary>* {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 500px) {
|
||||
details {
|
||||
width: 50%;
|
||||
}
|
||||
#errorInfo {
|
||||
width: 50%;
|
||||
}
|
||||
}
|
||||
|
40
packages/backend/src/server/web/error.js
Normal file
40
packages/backend/src/server/web/error.js
Normal file
@@ -0,0 +1,40 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
(() => {
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const locale = JSON.parse(localStorage.getItem('locale') || '{}');
|
||||
|
||||
const messages = Object.assign({
|
||||
title: 'Failed to initialize Misskey',
|
||||
serverError: 'If reloading after a period of time does not resolve the problem, contact the server administrator with the following ERROR ID.',
|
||||
solution: 'The following actions may solve the problem.',
|
||||
solution1: 'Update your os and browser',
|
||||
solution2: 'Disable an adblocker',
|
||||
solution3: 'Clear the browser cache',
|
||||
solution4: '(Tor Browser) Set dom.webaudio.enabled to true',
|
||||
otherOption: 'Other options',
|
||||
otherOption1: 'Clear preferences and cache',
|
||||
otherOption2: 'Start the simple client',
|
||||
otherOption3: 'Start the repair tool',
|
||||
}, locale?._bootErrors || {});
|
||||
const reload = locale?.reload || 'Reload';
|
||||
|
||||
const reloadEls = document.querySelectorAll('[data-i18n-reload]');
|
||||
for (const el of reloadEls) {
|
||||
el.textContent = reload;
|
||||
}
|
||||
|
||||
const i18nEls = document.querySelectorAll('[data-i18n]');
|
||||
for (const el of i18nEls) {
|
||||
const key = el.dataset.i18n;
|
||||
if (key && messages[key]) {
|
||||
el.textContent = messages[key];
|
||||
}
|
||||
}
|
||||
});
|
||||
})();
|
@@ -2,15 +2,15 @@ doctype html
|
||||
|
||||
//
|
||||
-
|
||||
_____ _ _
|
||||
| |_|___ ___| |_ ___ _ _
|
||||
_____ _ _
|
||||
| |_|___ ___| |_ ___ _ _
|
||||
| | | | |_ -|_ -| '_| -_| | |
|
||||
|_|_|_|_|___|___|_,_|___|_ |
|
||||
|___|
|
||||
|___|
|
||||
Thank you for using Misskey!
|
||||
If you are reading this message... how about joining the development?
|
||||
https://github.com/misskey-dev/misskey
|
||||
|
||||
|
||||
|
||||
html
|
||||
|
||||
@@ -27,39 +27,45 @@ html
|
||||
style
|
||||
include ../error.css
|
||||
|
||||
script
|
||||
include ../error.js
|
||||
|
||||
body
|
||||
svg.icon-warning(xmlns="http://www.w3.org/2000/svg", viewBox="0 0 24 24", stroke-width="2", stroke="currentColor", fill="none", stroke-linecap="round", stroke-linejoin="round")
|
||||
path(stroke="none", d="M0 0h24v24H0z", fill="none")
|
||||
path(d="M12 9v2m0 4v.01")
|
||||
path(d="M5 19h14a2 2 0 0 0 1.84 -2.75l-7.1 -12.25a2 2 0 0 0 -3.5 0l-7.1 12.25a2 2 0 0 0 1.75 2.75")
|
||||
|
||||
h1 An error has occurred!
|
||||
h1(data-i18n="title") Failed to initialize Misskey
|
||||
|
||||
button.button-big(onclick="location.reload();")
|
||||
span.button-label-big Refresh
|
||||
span.button-label-big(data-i18n-reload) Reload
|
||||
|
||||
p.dont-worry Don't worry, it's (probably) not your fault.
|
||||
|
||||
p If reloading after a period of time does not resolve the problem, contact the server administrator with the following ERROR ID.
|
||||
p(data-i18n="serverError") If reloading after a period of time does not resolve the problem, contact the server administrator with the following ERROR ID.
|
||||
|
||||
div#errors
|
||||
code.
|
||||
ERROR CODE: #{code}
|
||||
ERROR ID: #{id}
|
||||
|
||||
p You may also try the following options:
|
||||
p
|
||||
b(data-i18n="solution") The following actions may solve the problem.
|
||||
|
||||
p Update your os and browser.
|
||||
p Disable an adblocker.
|
||||
p(data-i18n="solution1") Update your os and browser
|
||||
p(data-i18n="solution2") Disable an adblocker
|
||||
p(data-i18n="solution3") Clear your browser cache
|
||||
p(data-i18n="solution4") (Tor Browser) Set dom.webaudio.enabled to true
|
||||
|
||||
a(href="/flush")
|
||||
button.button-small
|
||||
span.button-label-small Clear preferences and cache
|
||||
br
|
||||
a(href="/cli")
|
||||
button.button-small
|
||||
span.button-label-small Start the simple client
|
||||
br
|
||||
a(href="/bios")
|
||||
button.button-small
|
||||
span.button-label-small Start the repair tool
|
||||
details(style="color: #86b300;")
|
||||
summary(data-i18n="otherOption") Other options
|
||||
a(href="/flush")
|
||||
button.button-small
|
||||
span.button-label-small(data-i18n="otherOption1") Clear preferences and cache
|
||||
br
|
||||
a(href="/cli")
|
||||
button.button-small
|
||||
span.button-label-small(data-i18n="otherOption2") Start the simple client
|
||||
br
|
||||
a(href="/bios")
|
||||
button.button-small
|
||||
span.button-label-small(data-i18n="otherOption3") Start the repair tool
|
||||
|
@@ -139,29 +139,99 @@ describe('Note', () => {
|
||||
});
|
||||
|
||||
describe('Deletion', () => {
|
||||
describe('Check Delete consistency', () => {
|
||||
let carol: LoginUser;
|
||||
describe('Check Delete is delivered', () => {
|
||||
describe('To followers', () => {
|
||||
let carol: LoginUser;
|
||||
|
||||
beforeAll(async () => {
|
||||
carol = await createAccount('a.test');
|
||||
beforeAll(async () => {
|
||||
carol = await createAccount('a.test');
|
||||
|
||||
await carol.client.request('following/create', { userId: bobInA.id });
|
||||
await sleep();
|
||||
await carol.client.request('following/create', { userId: bobInA.id });
|
||||
await sleep();
|
||||
});
|
||||
|
||||
test('Check', async () => {
|
||||
const note = (await bob.client.request('notes/create', { text: 'I\'m Bob.' })).createdNote;
|
||||
const noteInA = await resolveRemoteNote('b.test', note.id, carol);
|
||||
await bob.client.request('notes/delete', { noteId: note.id });
|
||||
await sleep();
|
||||
|
||||
await rejects(
|
||||
async () => await carol.client.request('notes/show', { noteId: noteInA.id }),
|
||||
(err: any) => {
|
||||
strictEqual(err.code, 'NO_SUCH_NOTE');
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await carol.client.request('following/delete', { userId: bobInA.id });
|
||||
await sleep();
|
||||
});
|
||||
});
|
||||
|
||||
test('Delete is derivered to followers', async () => {
|
||||
const note = (await bob.client.request('notes/create', { text: 'I\'m Bob.' })).createdNote;
|
||||
const noteInA = await resolveRemoteNote('b.test', note.id, carol);
|
||||
await bob.client.request('notes/delete', { noteId: note.id });
|
||||
await sleep();
|
||||
describe('To renoted and not followed user', () => {
|
||||
test('Check', async () => {
|
||||
const note = (await bob.client.request('notes/create', { text: 'I\'m Bob.' })).createdNote;
|
||||
const noteInA = await resolveRemoteNote('b.test', note.id, alice);
|
||||
await alice.client.request('notes/create', { renoteId: noteInA.id });
|
||||
await sleep();
|
||||
|
||||
await rejects(
|
||||
async () => await carol.client.request('notes/show', { noteId: noteInA.id }),
|
||||
(err: any) => {
|
||||
strictEqual(err.code, 'NO_SUCH_NOTE');
|
||||
return true;
|
||||
},
|
||||
);
|
||||
await bob.client.request('notes/delete', { noteId: note.id });
|
||||
await sleep();
|
||||
|
||||
await rejects(
|
||||
async () => await alice.client.request('notes/show', { noteId: noteInA.id }),
|
||||
(err: any) => {
|
||||
strictEqual(err.code, 'NO_SUCH_NOTE');
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('To replied and not followed user', () => {
|
||||
test('Check', async () => {
|
||||
const note = (await bob.client.request('notes/create', { text: 'I\'m Bob.' })).createdNote;
|
||||
const noteInA = await resolveRemoteNote('b.test', note.id, alice);
|
||||
await alice.client.request('notes/create', { text: 'Hello Bob!', replyId: noteInA.id });
|
||||
await sleep();
|
||||
|
||||
await bob.client.request('notes/delete', { noteId: note.id });
|
||||
await sleep();
|
||||
|
||||
await rejects(
|
||||
async () => await alice.client.request('notes/show', { noteId: noteInA.id }),
|
||||
(err: any) => {
|
||||
strictEqual(err.code, 'NO_SUCH_NOTE');
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* FIXME: not delivered
|
||||
* @see https://github.com/misskey-dev/misskey/issues/15548
|
||||
*/
|
||||
describe('To only resolved and not followed user', () => {
|
||||
test.failing('Check', async () => {
|
||||
const note = (await bob.client.request('notes/create', { text: 'I\'m Bob.' })).createdNote;
|
||||
const noteInA = await resolveRemoteNote('b.test', note.id, alice);
|
||||
await sleep();
|
||||
|
||||
await bob.client.request('notes/delete', { noteId: note.id });
|
||||
await sleep();
|
||||
|
||||
await rejects(
|
||||
async () => await alice.client.request('notes/show', { noteId: noteInA.id }),
|
||||
(err: any) => {
|
||||
strictEqual(err.code, 'NO_SUCH_NOTE');
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
@@ -182,7 +182,6 @@ describe('クリップ', () => {
|
||||
{ label: 'nameがnull', parameters: { name: null } },
|
||||
{ label: 'nameが最大長+1', parameters: { name: 'x'.repeat(101) } },
|
||||
{ label: 'isPublicがboolじゃない', parameters: { isPublic: 'true' } },
|
||||
{ label: 'descriptionがゼロ長', parameters: { description: '' } },
|
||||
{ label: 'descriptionが最大長+1', parameters: { description: 'a'.repeat(2049) } },
|
||||
];
|
||||
test.each(createClipDenyPattern)('の作成は$labelならできない', async ({ parameters }) => failedApiCall({
|
||||
@@ -199,6 +198,23 @@ describe('クリップ', () => {
|
||||
id: '3d81ceae-475f-4600-b2a8-2bc116157532',
|
||||
}));
|
||||
|
||||
test('の作成はdescriptionが空文字ならnullになる', async () => {
|
||||
const clip = await successfulApiCall({
|
||||
endpoint: 'clips/create',
|
||||
parameters: {
|
||||
...defaultCreate(),
|
||||
description: '',
|
||||
},
|
||||
user: alice,
|
||||
});
|
||||
|
||||
assert.deepStrictEqual(clip, {
|
||||
...clip,
|
||||
...defaultCreate(),
|
||||
description: null,
|
||||
});
|
||||
});
|
||||
|
||||
test('の更新ができる', async () => {
|
||||
const res = await update({
|
||||
clipId: (await create()).id,
|
||||
@@ -249,6 +265,24 @@ describe('クリップ', () => {
|
||||
...assertion,
|
||||
}));
|
||||
|
||||
test('の更新はdescriptionが空文字ならnullになる', async () => {
|
||||
const clip = await successfulApiCall({
|
||||
endpoint: 'clips/update',
|
||||
parameters: {
|
||||
clipId: (await create()).id,
|
||||
name: 'updated',
|
||||
description: '',
|
||||
},
|
||||
user: alice,
|
||||
});
|
||||
|
||||
assert.deepStrictEqual(clip, {
|
||||
...clip,
|
||||
name: 'updated',
|
||||
description: null,
|
||||
});
|
||||
});
|
||||
|
||||
test('の削除ができる', async () => {
|
||||
await deleteClip({
|
||||
clipId: (await create()).id,
|
||||
|
@@ -397,7 +397,7 @@ describe('Timelines', () => {
|
||||
assert.strictEqual(res.body.some(note => note.id === bobNote2.id), true);
|
||||
assert.strictEqual(res.body.some(note => note.id === carolNote1.id), false);
|
||||
assert.strictEqual(res.body.some(note => note.id === carolNote2.id), false);
|
||||
}, 1000 * 15);
|
||||
}, 1000 * 30);
|
||||
|
||||
test.concurrent('フォローしているユーザーのチャンネル投稿が含まれない', async () => {
|
||||
const [alice, bob] = await Promise.all([signup(), signup()]);
|
||||
|
@@ -3,6 +3,8 @@
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { UtilityService } from '@/core/UtilityService.js';
|
||||
|
||||
process.env.NODE_ENV = 'test';
|
||||
|
||||
import { jest } from '@jest/globals';
|
||||
@@ -36,6 +38,7 @@ describe('RelayService', () => {
|
||||
RelayService,
|
||||
UserEntityService,
|
||||
SystemAccountService,
|
||||
UtilityService,
|
||||
],
|
||||
})
|
||||
.useMocker((token) => {
|
||||
|
@@ -8,6 +8,8 @@ import httpSignature from '@peertube/http-signature';
|
||||
|
||||
import { genRsaKeyPair } from '@/misc/gen-key-pair.js';
|
||||
import { ApRequestCreator } from '@/core/activitypub/ApRequestService.js';
|
||||
import { assertActivityMatchesUrls, FetchAllowSoftFailMask } from '@/core/activitypub/misc/check-against-url.js';
|
||||
import { IObject } from '@/core/activitypub/type.js';
|
||||
|
||||
export const buildParsedSignature = (signingString: string, signature: string, algorithm: string) => {
|
||||
return {
|
||||
@@ -24,6 +26,10 @@ export const buildParsedSignature = (signingString: string, signature: string, a
|
||||
};
|
||||
};
|
||||
|
||||
function cartesianProduct<T, U>(a: T[], b: U[]): [T, U][] {
|
||||
return a.flatMap(a => b.map(b => [a, b] as [T, U]));
|
||||
}
|
||||
|
||||
describe('ap-request', () => {
|
||||
test('createSignedPost with verify', async () => {
|
||||
const keypair = await genRsaKeyPair();
|
||||
@@ -58,4 +64,123 @@ describe('ap-request', () => {
|
||||
const result = httpSignature.verifySignature(parsed, keypair.publicKey);
|
||||
assert.deepStrictEqual(result, true);
|
||||
});
|
||||
|
||||
test('rejects non matching domain', () => {
|
||||
assert.doesNotThrow(() => assertActivityMatchesUrls(
|
||||
'https://alice.example.com/abc',
|
||||
{ id: 'https://alice.example.com/abc' } as IObject,
|
||||
[
|
||||
'https://alice.example.com/abc',
|
||||
],
|
||||
FetchAllowSoftFailMask.Strict,
|
||||
), 'validation should pass base case');
|
||||
assert.throws(() => assertActivityMatchesUrls(
|
||||
'https://alice.example.com/abc',
|
||||
{ id: 'https://bob.example.com/abc' } as IObject,
|
||||
[
|
||||
'https://alice.example.com/abc',
|
||||
],
|
||||
FetchAllowSoftFailMask.Any,
|
||||
), 'validation should fail no matter what if the response URL is inconsistent with the object ID');
|
||||
|
||||
// fix issues like threads
|
||||
// https://github.com/misskey-dev/misskey/issues/15039
|
||||
const withOrWithoutWWW = [
|
||||
'https://alice.example.com/abc',
|
||||
'https://www.alice.example.com/abc',
|
||||
];
|
||||
|
||||
cartesianProduct(
|
||||
cartesianProduct(
|
||||
withOrWithoutWWW,
|
||||
withOrWithoutWWW,
|
||||
),
|
||||
withOrWithoutWWW,
|
||||
).forEach(([[a, b], c]) => {
|
||||
assert.doesNotThrow(() => assertActivityMatchesUrls(
|
||||
a,
|
||||
{ id: b } as IObject,
|
||||
[
|
||||
c,
|
||||
],
|
||||
FetchAllowSoftFailMask.Strict,
|
||||
), 'validation should pass with or without www. subdomain');
|
||||
});
|
||||
});
|
||||
|
||||
test('cross origin lookup', () => {
|
||||
assert.doesNotThrow(() => assertActivityMatchesUrls(
|
||||
'https://alice.example.com/abc',
|
||||
{ id: 'https://bob.example.com/abc' } as IObject,
|
||||
[
|
||||
'https://bob.example.com/abc',
|
||||
],
|
||||
FetchAllowSoftFailMask.CrossOrigin | FetchAllowSoftFailMask.NonCanonicalId,
|
||||
), 'validation should pass if the response is otherwise consistent and cross-origin is allowed');
|
||||
assert.throws(() => assertActivityMatchesUrls(
|
||||
'https://alice.example.com/abc',
|
||||
{ id: 'https://bob.example.com/abc' } as IObject,
|
||||
[
|
||||
'https://bob.example.com/abc',
|
||||
],
|
||||
FetchAllowSoftFailMask.Strict,
|
||||
), 'validation should fail if the response is otherwise consistent and cross-origin is not allowed');
|
||||
});
|
||||
|
||||
test('rejects non-canonical ID', () => {
|
||||
assert.throws(() => assertActivityMatchesUrls(
|
||||
'https://alice.example.com/@alice',
|
||||
{ id: 'https://alice.example.com/users/alice' } as IObject,
|
||||
[
|
||||
'https://alice.example.com/users/alice'
|
||||
],
|
||||
FetchAllowSoftFailMask.Strict,
|
||||
), 'throws if the response ID did not exactly match the expected ID');
|
||||
assert.doesNotThrow(() => assertActivityMatchesUrls(
|
||||
'https://alice.example.com/@alice',
|
||||
{ id: 'https://alice.example.com/users/alice' } as IObject,
|
||||
[
|
||||
'https://alice.example.com/users/alice',
|
||||
],
|
||||
FetchAllowSoftFailMask.NonCanonicalId,
|
||||
), 'does not throw if non-canonical ID is allowed');
|
||||
});
|
||||
|
||||
test('origin relaxed alignment', () => {
|
||||
assert.doesNotThrow(() => assertActivityMatchesUrls(
|
||||
'https://alice.example.com/abc',
|
||||
{ id: 'https://ap.alice.example.com/abc' } as IObject,
|
||||
[
|
||||
'https://ap.alice.example.com/abc',
|
||||
],
|
||||
FetchAllowSoftFailMask.MisalignedOrigin | FetchAllowSoftFailMask.NonCanonicalId,
|
||||
), 'validation should pass if response is a subdomain of the expected origin');
|
||||
assert.throws(() => assertActivityMatchesUrls(
|
||||
'https://alice.multi-tenant.example.com/abc',
|
||||
{ id: 'https://alice.multi-tenant.example.com/abc' } as IObject,
|
||||
[
|
||||
'https://bob.multi-tenant.example.com/abc',
|
||||
],
|
||||
FetchAllowSoftFailMask.MisalignedOrigin | FetchAllowSoftFailMask.NonCanonicalId,
|
||||
), 'validation should fail if response is a disjoint domain of the expected origin');
|
||||
assert.throws(() => assertActivityMatchesUrls(
|
||||
'https://alice.example.com/abc',
|
||||
{ id: 'https://ap.alice.example.com/abc' } as IObject,
|
||||
[
|
||||
'https://ap.alice.example.com/abc',
|
||||
],
|
||||
FetchAllowSoftFailMask.Strict,
|
||||
), 'throws if relaxed origin is forbidden');
|
||||
});
|
||||
|
||||
test('resist HTTP downgrade', () => {
|
||||
assert.throws(() => assertActivityMatchesUrls(
|
||||
'https://alice.example.com/abc',
|
||||
{ id: 'https://alice.example.com/abc' } as IObject,
|
||||
[
|
||||
'http://alice.example.com/abc',
|
||||
],
|
||||
FetchAllowSoftFailMask.Strict,
|
||||
), 'throws if HTTP downgrade is detected');
|
||||
});
|
||||
});
|
||||
|
@@ -25,16 +25,16 @@
|
||||
"misskey-js": "workspace:*",
|
||||
"frontend-shared": "workspace:*",
|
||||
"punycode.js": "2.3.1",
|
||||
"rollup": "4.34.7",
|
||||
"rollup": "4.34.8",
|
||||
"sass": "1.85.0",
|
||||
"shiki": "2.4.1",
|
||||
"shiki": "3.0.0",
|
||||
"tinycolor2": "1.6.0",
|
||||
"tsc-alias": "1.8.10",
|
||||
"tsconfig-paths": "4.2.0",
|
||||
"typescript": "5.7.3",
|
||||
"uuid": "11.0.5",
|
||||
"uuid": "11.1.0",
|
||||
"json5": "2.2.3",
|
||||
"vite": "6.1.0",
|
||||
"vite": "6.1.1",
|
||||
"vue": "3.5.13"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -42,29 +42,29 @@
|
||||
"@testing-library/vue": "8.1.0",
|
||||
"@types/estree": "1.0.6",
|
||||
"@types/micromatch": "4.0.9",
|
||||
"@types/node": "22.13.4",
|
||||
"@types/node": "22.13.5",
|
||||
"@types/punycode.js": "npm:@types/punycode@2.1.4",
|
||||
"@types/tinycolor2": "1.4.6",
|
||||
"@types/ws": "8.5.14",
|
||||
"@typescript-eslint/eslint-plugin": "8.24.0",
|
||||
"@typescript-eslint/parser": "8.24.0",
|
||||
"@vitest/coverage-v8": "3.0.5",
|
||||
"@typescript-eslint/eslint-plugin": "8.24.1",
|
||||
"@typescript-eslint/parser": "8.24.1",
|
||||
"@vitest/coverage-v8": "3.0.6",
|
||||
"@vue/runtime-core": "3.5.13",
|
||||
"acorn": "8.14.0",
|
||||
"cross-env": "7.0.3",
|
||||
"eslint-plugin-import": "2.31.0",
|
||||
"eslint-plugin-vue": "9.32.0",
|
||||
"fast-glob": "3.3.3",
|
||||
"happy-dom": "17.1.0",
|
||||
"happy-dom": "17.1.4",
|
||||
"intersection-observer": "0.12.2",
|
||||
"micromatch": "4.0.8",
|
||||
"msw": "2.7.0",
|
||||
"msw": "2.7.1",
|
||||
"nodemon": "3.1.9",
|
||||
"prettier": "3.5.1",
|
||||
"prettier": "3.5.2",
|
||||
"start-server-and-test": "2.0.10",
|
||||
"vite-plugin-turbosnap": "1.0.3",
|
||||
"vue-component-type-helpers": "2.2.2",
|
||||
"vue-component-type-helpers": "2.2.4",
|
||||
"vue-eslint-parser": "9.4.3",
|
||||
"vue-tsc": "2.2.2"
|
||||
"vue-tsc": "2.2.4"
|
||||
}
|
||||
}
|
||||
|
@@ -21,9 +21,9 @@
|
||||
"lint": "pnpm typecheck && pnpm eslint"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "22.13.4",
|
||||
"@typescript-eslint/eslint-plugin": "8.24.0",
|
||||
"@typescript-eslint/parser": "8.24.0",
|
||||
"@types/node": "22.13.5",
|
||||
"@typescript-eslint/eslint-plugin": "8.24.1",
|
||||
"@typescript-eslint/parser": "8.24.1",
|
||||
"esbuild": "0.25.0",
|
||||
"eslint-plugin-vue": "9.32.0",
|
||||
"nodemon": "3.1.9",
|
||||
|
@@ -16,6 +16,7 @@
|
||||
"lint": "pnpm typecheck && pnpm eslint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@analytics/google-analytics": "1.1.0",
|
||||
"@discordapp/twemoji": "15.1.0",
|
||||
"@github/webauthn-json": "2.1.1",
|
||||
"@mcaptcha/vanilla-glue": "0.1.0-alpha-3",
|
||||
@@ -29,11 +30,12 @@
|
||||
"@vitejs/plugin-vue": "5.2.1",
|
||||
"@vue/compiler-sfc": "3.5.13",
|
||||
"aiscript-vscode": "github:aiscript-dev/aiscript-vscode#v0.1.15",
|
||||
"analytics": "0.8.16",
|
||||
"astring": "1.9.0",
|
||||
"broadcast-channel": "7.0.0",
|
||||
"buraha": "0.0.1",
|
||||
"canvas-confetti": "1.9.3",
|
||||
"chart.js": "4.4.7",
|
||||
"chart.js": "4.4.8",
|
||||
"chartjs-adapter-date-fns": "3.0.0",
|
||||
"chartjs-chart-matrix": "2.0.1",
|
||||
"chartjs-plugin-gradient": "0.6.1",
|
||||
@@ -56,10 +58,10 @@
|
||||
"misskey-reversi": "workspace:*",
|
||||
"photoswipe": "5.4.4",
|
||||
"punycode.js": "2.3.1",
|
||||
"rollup": "4.34.7",
|
||||
"rollup": "4.34.8",
|
||||
"sanitize-html": "2.14.0",
|
||||
"sass": "1.85.0",
|
||||
"shiki": "2.4.1",
|
||||
"shiki": "3.0.0",
|
||||
"strict-event-emitter-types": "2.0.0",
|
||||
"textarea-caret": "3.1.0",
|
||||
"three": "0.173.0",
|
||||
@@ -68,47 +70,47 @@
|
||||
"tsc-alias": "1.8.10",
|
||||
"tsconfig-paths": "4.2.0",
|
||||
"typescript": "5.7.3",
|
||||
"uuid": "11.0.5",
|
||||
"uuid": "11.1.0",
|
||||
"v-code-diff": "1.13.1",
|
||||
"vite": "6.1.0",
|
||||
"vite": "6.1.1",
|
||||
"vue": "3.5.13",
|
||||
"vuedraggable": "next"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@misskey-dev/summaly": "5.2.0",
|
||||
"@storybook/addon-actions": "8.5.6",
|
||||
"@storybook/addon-essentials": "8.5.6",
|
||||
"@storybook/addon-interactions": "8.5.6",
|
||||
"@storybook/addon-links": "8.5.6",
|
||||
"@storybook/addon-mdx-gfm": "8.5.6",
|
||||
"@storybook/addon-storysource": "8.5.6",
|
||||
"@storybook/blocks": "8.5.6",
|
||||
"@storybook/components": "8.5.6",
|
||||
"@storybook/core-events": "8.5.6",
|
||||
"@storybook/manager-api": "8.5.6",
|
||||
"@storybook/preview-api": "8.5.6",
|
||||
"@storybook/react": "8.5.6",
|
||||
"@storybook/react-vite": "8.5.6",
|
||||
"@storybook/test": "8.5.6",
|
||||
"@storybook/theming": "8.5.6",
|
||||
"@storybook/types": "8.5.6",
|
||||
"@storybook/vue3": "8.5.6",
|
||||
"@storybook/vue3-vite": "8.5.6",
|
||||
"@storybook/addon-actions": "8.5.8",
|
||||
"@storybook/addon-essentials": "8.5.8",
|
||||
"@storybook/addon-interactions": "8.5.8",
|
||||
"@storybook/addon-links": "8.5.8",
|
||||
"@storybook/addon-mdx-gfm": "8.5.8",
|
||||
"@storybook/addon-storysource": "8.5.8",
|
||||
"@storybook/blocks": "8.5.8",
|
||||
"@storybook/components": "8.5.8",
|
||||
"@storybook/core-events": "8.5.8",
|
||||
"@storybook/manager-api": "8.5.8",
|
||||
"@storybook/preview-api": "8.5.8",
|
||||
"@storybook/react": "8.5.8",
|
||||
"@storybook/react-vite": "8.5.8",
|
||||
"@storybook/test": "8.5.8",
|
||||
"@storybook/theming": "8.5.8",
|
||||
"@storybook/types": "8.5.8",
|
||||
"@storybook/vue3": "8.5.8",
|
||||
"@storybook/vue3-vite": "8.5.8",
|
||||
"@testing-library/vue": "8.1.0",
|
||||
"@types/canvas-confetti": "1.9.0",
|
||||
"@types/estree": "1.0.6",
|
||||
"@types/matter-js": "0.19.8",
|
||||
"@types/micromatch": "4.0.9",
|
||||
"@types/node": "22.13.4",
|
||||
"@types/node": "22.13.5",
|
||||
"@types/punycode.js": "npm:@types/punycode@2.1.4",
|
||||
"@types/sanitize-html": "2.13.0",
|
||||
"@types/seedrandom": "3.0.8",
|
||||
"@types/throttle-debounce": "5.0.2",
|
||||
"@types/tinycolor2": "1.4.6",
|
||||
"@types/ws": "8.5.14",
|
||||
"@typescript-eslint/eslint-plugin": "8.24.0",
|
||||
"@typescript-eslint/parser": "8.24.0",
|
||||
"@vitest/coverage-v8": "3.0.5",
|
||||
"@typescript-eslint/eslint-plugin": "8.24.1",
|
||||
"@typescript-eslint/parser": "8.24.1",
|
||||
"@vitest/coverage-v8": "3.0.6",
|
||||
"@vue/runtime-core": "3.5.13",
|
||||
"acorn": "8.14.0",
|
||||
"cross-env": "7.0.3",
|
||||
@@ -116,24 +118,24 @@
|
||||
"eslint-plugin-import": "2.31.0",
|
||||
"eslint-plugin-vue": "9.32.0",
|
||||
"fast-glob": "3.3.3",
|
||||
"happy-dom": "17.1.0",
|
||||
"happy-dom": "17.1.4",
|
||||
"intersection-observer": "0.12.2",
|
||||
"micromatch": "4.0.8",
|
||||
"msw": "2.7.0",
|
||||
"msw": "2.7.1",
|
||||
"msw-storybook-addon": "2.0.4",
|
||||
"nodemon": "3.1.9",
|
||||
"prettier": "3.5.1",
|
||||
"prettier": "3.5.2",
|
||||
"react": "19.0.0",
|
||||
"react-dom": "19.0.0",
|
||||
"seedrandom": "3.0.5",
|
||||
"start-server-and-test": "2.0.10",
|
||||
"storybook": "8.5.6",
|
||||
"storybook": "8.5.8",
|
||||
"storybook-addon-misskey-theme": "github:misskey-dev/storybook-addon-misskey-theme",
|
||||
"vite-plugin-turbosnap": "1.0.3",
|
||||
"vitest": "3.0.5",
|
||||
"vitest": "3.0.6",
|
||||
"vitest-fetch-mock": "0.4.3",
|
||||
"vue-component-type-helpers": "2.2.2",
|
||||
"vue-component-type-helpers": "2.2.4",
|
||||
"vue-eslint-parser": "9.4.3",
|
||||
"vue-tsc": "2.2.2"
|
||||
"vue-tsc": "2.2.4"
|
||||
}
|
||||
}
|
||||
|
@@ -7,6 +7,7 @@ import { defineAsyncComponent, reactive, ref } from 'vue';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import { apiUrl } from '@@/js/config.js';
|
||||
import type { MenuItem, MenuButton } from '@/types/menu.js';
|
||||
import { defaultMemoryStorage } from '@/memory-storage';
|
||||
import { showSuspendedDialog } from '@/scripts/show-suspended-dialog.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import { miLocalStorage } from '@/local-storage.js';
|
||||
@@ -40,6 +41,8 @@ export function incNotesCount() {
|
||||
export async function signout() {
|
||||
if (!$i) return;
|
||||
|
||||
defaultMemoryStorage.clear();
|
||||
|
||||
waiting();
|
||||
document.cookie.split(';').forEach((cookie) => {
|
||||
const cookieName = cookie.split('=')[0].trim();
|
||||
@@ -107,7 +110,7 @@ export async function removeAccount(idOrToken: Account['id']) {
|
||||
}
|
||||
|
||||
function fetchAccount(token: string, id?: string, forceShowDialog?: boolean): Promise<Account> {
|
||||
document.cookie = "token=; path=/; max-age=0";
|
||||
document.cookie = 'token=; path=/; max-age=0';
|
||||
document.cookie = `token=${token}; path=/queue; max-age=86400; SameSite=Strict; Secure`; // bull dashboardの認証とかで使う
|
||||
|
||||
return new Promise((done, fail) => {
|
||||
|
107
packages/frontend/src/analytics.ts
Normal file
107
packages/frontend/src/analytics.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import * as Misskey from 'misskey-js';
|
||||
import type { AnalyticsInstance, AnalyticsPlugin } from 'analytics';
|
||||
|
||||
/**
|
||||
* analytics moduleを読み込まなくても動作するようにするためのラッパー
|
||||
*/
|
||||
class AnalyticsProxy implements AnalyticsInstance {
|
||||
private analytics?: AnalyticsInstance;
|
||||
|
||||
constructor(analytics?: AnalyticsInstance) {
|
||||
if (analytics) {
|
||||
this.analytics = analytics;
|
||||
}
|
||||
}
|
||||
|
||||
public setAnalytics(analytics: AnalyticsInstance) {
|
||||
if (this.analytics) {
|
||||
throw new Error('Analytics instance already exists.');
|
||||
}
|
||||
this.analytics = analytics;
|
||||
}
|
||||
|
||||
public identify(...args: Parameters<AnalyticsInstance['identify']>) {
|
||||
return this.analytics?.identify(...args) ?? Promise.resolve();
|
||||
}
|
||||
|
||||
public track(...args: Parameters<AnalyticsInstance['track']>) {
|
||||
return this.analytics?.track(...args) ?? Promise.resolve();
|
||||
}
|
||||
|
||||
public page(...args: Parameters<AnalyticsInstance['page']>) {
|
||||
return this.analytics?.page(...args) ?? Promise.resolve();
|
||||
}
|
||||
|
||||
public user(...args: Parameters<AnalyticsInstance['user']>) {
|
||||
return this.analytics?.user(...args) ?? Promise.resolve();
|
||||
}
|
||||
|
||||
public reset(...args: Parameters<AnalyticsInstance['reset']>) {
|
||||
return this.analytics?.reset(...args) ?? Promise.resolve();
|
||||
}
|
||||
|
||||
public ready(...args: Parameters<AnalyticsInstance['ready']>) {
|
||||
return this.analytics?.ready(...args) ?? function () { void 0; };
|
||||
}
|
||||
|
||||
public on(...args: Parameters<AnalyticsInstance['on']>) {
|
||||
return this.analytics?.on(...args) ?? function () { void 0; };
|
||||
}
|
||||
|
||||
public once(...args: Parameters<AnalyticsInstance['once']>) {
|
||||
return this.analytics?.once(...args) ?? function () { void 0; };
|
||||
}
|
||||
|
||||
public getState(...args: Parameters<AnalyticsInstance['getState']>) {
|
||||
return this.analytics?.getState(...args) ?? Promise.resolve();
|
||||
}
|
||||
|
||||
public get storage() {
|
||||
return this.analytics?.storage ?? {
|
||||
getItem: () => null,
|
||||
setItem: () => void 0,
|
||||
removeItem: () => void 0,
|
||||
};
|
||||
}
|
||||
|
||||
public get plugins() {
|
||||
return this.analytics?.plugins ?? {
|
||||
enable: (p, c) => Promise.resolve(c ? c() : void 0),
|
||||
disable: (p, c) => Promise.resolve(c ? c() : void 0),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const analytics = new AnalyticsProxy();
|
||||
|
||||
export async function initAnalytics(instance: Misskey.entities.MetaDetailed) {
|
||||
// アナリティクスプロバイダに関する設定がひとつもない場合は、アナリティクスモジュールを読み込まない
|
||||
if (!instance.googleAnalyticsMeasurementId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { default: Analytics } = await import('analytics');
|
||||
const plugins: AnalyticsPlugin[] = [];
|
||||
|
||||
// Google Analytics
|
||||
if (instance.googleAnalyticsMeasurementId) {
|
||||
const { default: googleAnalytics } = await import('@analytics/google-analytics');
|
||||
|
||||
plugins.push(googleAnalytics({
|
||||
measurementIds: [instance.googleAnalyticsMeasurementId],
|
||||
debug: _DEV_,
|
||||
}));
|
||||
}
|
||||
|
||||
analytics.setAnalytics(Analytics({
|
||||
app: 'misskey',
|
||||
version: _VERSION_,
|
||||
debug: _DEV_,
|
||||
plugins,
|
||||
}));
|
||||
}
|
@@ -4,9 +4,9 @@
|
||||
*/
|
||||
|
||||
import { computed, watch, version as vueVersion } from 'vue';
|
||||
import type { App } from 'vue';
|
||||
import { compareVersions } from 'compare-versions';
|
||||
import { version, lang, updateLocale, locale } from '@@/js/config.js';
|
||||
import type { App } from 'vue';
|
||||
import widgets from '@/widgets/index.js';
|
||||
import directives from '@/directives/index.js';
|
||||
import components from '@/components/index.js';
|
||||
@@ -21,6 +21,7 @@ import { reloadChannel } from '@/scripts/unison-reload.js';
|
||||
import { getUrlWithoutLoginId } from '@/scripts/login-id.js';
|
||||
import { getAccountFromId } from '@/scripts/get-account-from-id.js';
|
||||
import { deckStore } from '@/ui/deck/deck-store.js';
|
||||
import { analytics, initAnalytics } from '@/analytics.js';
|
||||
import { miLocalStorage } from '@/local-storage.js';
|
||||
import { fetchCustomEmojis } from '@/custom-emojis.js';
|
||||
import { setupRouter } from '@/router/main.js';
|
||||
@@ -241,6 +242,19 @@ export async function common(createVue: () => App<Element>) {
|
||||
await fetchCustomEmojis();
|
||||
} catch (err) { /* empty */ }
|
||||
|
||||
// analytics
|
||||
fetchInstanceMetaPromise.then(async () => {
|
||||
await initAnalytics(instance);
|
||||
|
||||
if ($i) {
|
||||
analytics.identify($i.id);
|
||||
}
|
||||
|
||||
analytics.page({
|
||||
path: window.location.pathname,
|
||||
});
|
||||
});
|
||||
|
||||
const app = createVue();
|
||||
|
||||
setupRouter(app, createMainRouter);
|
||||
|
@@ -49,6 +49,7 @@ import sanitizeHtml from 'sanitize-html';
|
||||
import { emojilist, getEmojiName } from '@@/js/emojilist.js';
|
||||
import { char2twemojiFilePath, char2fluentEmojiFilePath } from '@@/js/emoji-base.js';
|
||||
import { MFM_TAGS, MFM_PARAMS } from '@@/js/const.js';
|
||||
import type { EmojiDef } from '@/scripts/search-emoji.js';
|
||||
import contains from '@/scripts/contains.js';
|
||||
import { acct } from '@/filters/user.js';
|
||||
import * as os from '@/os.js';
|
||||
@@ -58,7 +59,6 @@ import { i18n } from '@/i18n.js';
|
||||
import { miLocalStorage } from '@/local-storage.js';
|
||||
import { customEmojis } from '@/custom-emojis.js';
|
||||
import { searchEmoji } from '@/scripts/search-emoji.js';
|
||||
import type { EmojiDef } from '@/scripts/search-emoji.js';
|
||||
|
||||
const lib = emojilist.filter(x => x.category !== 'flags');
|
||||
|
||||
@@ -198,8 +198,10 @@ function exec() {
|
||||
users.value = JSON.parse(cache);
|
||||
fetching.value = false;
|
||||
} else {
|
||||
const [username, host] = props.q.toString().split('@');
|
||||
misskeyApi('users/search-by-username-and-host', {
|
||||
username: props.q,
|
||||
username: username,
|
||||
host: host,
|
||||
limit: 10,
|
||||
detail: false,
|
||||
}).then(searchedUsers => {
|
||||
|
@@ -257,7 +257,7 @@ import type { Keymap } from '@/scripts/hotkey.js';
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
note: Misskey.entities.Note;
|
||||
initialTab: string;
|
||||
initialTab?: string;
|
||||
}>(), {
|
||||
initialTab: 'replies',
|
||||
});
|
||||
|
@@ -32,6 +32,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
import { computed, onMounted, onUnmounted, provide, ref, shallowRef } from 'vue';
|
||||
import { url } from '@@/js/config.js';
|
||||
import { getScrollContainer } from '@@/js/scroll.js';
|
||||
import type { PageMetadata } from '@/scripts/page-metadata.js';
|
||||
import RouterView from '@/components/global/RouterView.vue';
|
||||
import MkWindow from '@/components/MkWindow.vue';
|
||||
import { popout as _popout } from '@/scripts/popout.js';
|
||||
@@ -39,11 +40,11 @@ import { copyToClipboard } from '@/scripts/copy-to-clipboard.js';
|
||||
import { useScrollPositionManager } from '@/nirax.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import { provideMetadataReceiver, provideReactiveMetadata } from '@/scripts/page-metadata.js';
|
||||
import type { PageMetadata } from '@/scripts/page-metadata.js';
|
||||
import { openingWindowsCount } from '@/os.js';
|
||||
import { claimAchievement } from '@/scripts/achievements.js';
|
||||
import { useRouterFactory } from '@/router/supplier.js';
|
||||
import { mainRouter } from '@/router/main.js';
|
||||
import { analytics } from '@/analytics.js';
|
||||
|
||||
const props = defineProps<{
|
||||
initialPath: string;
|
||||
@@ -99,6 +100,14 @@ windowRouter.addListener('replace', ctx => {
|
||||
history.value.push({ path: ctx.path, key: ctx.key });
|
||||
});
|
||||
|
||||
windowRouter.addListener('change', ctx => {
|
||||
console.log('windowRouter: change', ctx.path);
|
||||
analytics.page({
|
||||
path: ctx.path,
|
||||
title: ctx.path,
|
||||
});
|
||||
});
|
||||
|
||||
windowRouter.init();
|
||||
|
||||
provide('router', windowRouter);
|
||||
@@ -160,6 +169,11 @@ function popout() {
|
||||
useScrollPositionManager(() => getScrollContainer(contents.value), windowRouter);
|
||||
|
||||
onMounted(() => {
|
||||
analytics.page({
|
||||
path: props.initialPath,
|
||||
title: props.initialPath,
|
||||
});
|
||||
|
||||
openingWindowsCount.value++;
|
||||
if (openingWindowsCount.value >= 3) {
|
||||
claimAchievement('open3windows');
|
||||
|
@@ -4,14 +4,14 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
-->
|
||||
|
||||
<template>
|
||||
<div :class="$style.root"><i class="ti ti-alert-triangle" style="margin-right: 8px;"></i>{{ i18n.ts.remoteUserCaution }}<a :class="$style.link" :href="href" rel="nofollow noopener" target="_blank">{{ i18n.ts.showOnRemote }}</a></div>
|
||||
<div :class="$style.root"><i class="ti ti-alert-triangle" style="margin-right: 8px;"></i>{{ i18n.ts.remoteUserCaution }}<a v-if="href" :class="$style.link" :href="href" rel="nofollow noopener" target="_blank">{{ i18n.ts.showOnRemote }}</a></div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { i18n } from '@/i18n.js';
|
||||
|
||||
defineProps<{
|
||||
href: string;
|
||||
href?: string;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
|
57
packages/frontend/src/memory-storage.ts
Normal file
57
packages/frontend/src/memory-storage.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
export type MemoryStorage = {
|
||||
has: (key: string) => boolean;
|
||||
getItem: <T>(key: string) => T | null;
|
||||
setItem: (key: string, value: unknown) => void;
|
||||
removeItem: (key: string) => void;
|
||||
clear: () => void;
|
||||
size: number;
|
||||
};
|
||||
|
||||
class MemoryStorageImpl implements MemoryStorage {
|
||||
private readonly storage: Map<string, unknown>;
|
||||
|
||||
constructor() {
|
||||
this.storage = new Map();
|
||||
}
|
||||
|
||||
has(key: string): boolean {
|
||||
return this.storage.has(key);
|
||||
}
|
||||
|
||||
getItem<T>(key: string): T | null {
|
||||
return this.storage.has(key) ? this.storage.get(key) as T : null;
|
||||
}
|
||||
|
||||
setItem(key: string, value: unknown): void {
|
||||
this.storage.set(key, value);
|
||||
}
|
||||
|
||||
removeItem(key: string): void {
|
||||
this.storage.delete(key);
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.storage.clear();
|
||||
}
|
||||
|
||||
get size(): number {
|
||||
return this.storage.size;
|
||||
}
|
||||
}
|
||||
|
||||
export function createMemoryStorage(): MemoryStorage {
|
||||
return new MemoryStorageImpl();
|
||||
}
|
||||
|
||||
/**
|
||||
* SessionStorageよりも更に短い期間でクリアされるストレージです
|
||||
* - ブラウザの再読み込みやタブの閉じると内容が揮発します
|
||||
* - このストレージは他のタブと共有されません
|
||||
* - アカウント切り替えやログアウトを行うと内容が揮発します
|
||||
*/
|
||||
export const defaultMemoryStorage: MemoryStorage = createMemoryStorage();
|
@@ -8,20 +8,34 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
<template #header><XHeader :actions="headerActions" :tabs="headerTabs"/></template>
|
||||
<MkSpacer :contentMax="700" :marginMin="16" :marginMax="32">
|
||||
<FormSuspense :p="init">
|
||||
<MkFolder>
|
||||
<template #label>DeepL Translation</template>
|
||||
<div class="_gaps_m">
|
||||
<MkFolder>
|
||||
<template #label>Google Analytics<span class="_beta">{{ i18n.ts.beta }}</span></template>
|
||||
|
||||
<div class="_gaps_m">
|
||||
<MkInput v-model="deeplAuthKey">
|
||||
<template #prefix><i class="ti ti-key"></i></template>
|
||||
<template #label>DeepL Auth Key</template>
|
||||
</MkInput>
|
||||
<MkSwitch v-model="deeplIsPro">
|
||||
<template #label>Pro account</template>
|
||||
</MkSwitch>
|
||||
<MkButton primary @click="save_deepl">Save</MkButton>
|
||||
</div>
|
||||
</MkFolder>
|
||||
<div class="_gaps_m">
|
||||
<MkInput v-model="googleAnalyticsMeasurementId">
|
||||
<template #prefix><i class="ti ti-key"></i></template>
|
||||
<template #label>Measurement ID</template>
|
||||
</MkInput>
|
||||
<MkButton primary @click="save_googleAnalytics">Save</MkButton>
|
||||
</div>
|
||||
</MkFolder>
|
||||
|
||||
<MkFolder>
|
||||
<template #label>DeepL Translation</template>
|
||||
|
||||
<div class="_gaps_m">
|
||||
<MkInput v-model="deeplAuthKey">
|
||||
<template #prefix><i class="ti ti-key"></i></template>
|
||||
<template #label>DeepL Auth Key</template>
|
||||
</MkInput>
|
||||
<MkSwitch v-model="deeplIsPro">
|
||||
<template #label>Pro account</template>
|
||||
</MkSwitch>
|
||||
<MkButton primary @click="save_deepl">Save</MkButton>
|
||||
</div>
|
||||
</MkFolder>
|
||||
</div>
|
||||
</FormSuspense>
|
||||
</MkSpacer>
|
||||
</MkStickyContainer>
|
||||
@@ -44,10 +58,13 @@ import MkFolder from '@/components/MkFolder.vue';
|
||||
const deeplAuthKey = ref<string>('');
|
||||
const deeplIsPro = ref<boolean>(false);
|
||||
|
||||
const googleAnalyticsMeasurementId = ref<string>('');
|
||||
|
||||
async function init() {
|
||||
const meta = await misskeyApi('admin/meta');
|
||||
deeplAuthKey.value = meta.deeplAuthKey;
|
||||
deeplAuthKey.value = meta.deeplAuthKey ?? '';
|
||||
deeplIsPro.value = meta.deeplIsPro;
|
||||
googleAnalyticsMeasurementId.value = meta.googleAnalyticsMeasurementId ?? '';
|
||||
}
|
||||
|
||||
function save_deepl() {
|
||||
@@ -59,6 +76,14 @@ function save_deepl() {
|
||||
});
|
||||
}
|
||||
|
||||
function save_googleAnalytics() {
|
||||
os.apiWithDialog('admin/update-meta', {
|
||||
googleAnalyticsMeasurementId: googleAnalyticsMeasurementId.value,
|
||||
}).then(() => {
|
||||
fetchInstance(true);
|
||||
});
|
||||
}
|
||||
|
||||
const headerActions = computed(() => []);
|
||||
|
||||
const headerTabs = computed(() => []);
|
||||
|
@@ -9,6 +9,9 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
<template #header><XHeader :actions="headerActions" :tabs="headerTabs"/></template>
|
||||
<MkSpacer :contentMax="900">
|
||||
<div class="_gaps">
|
||||
<div :class="$style.inputs">
|
||||
<MkButton style="margin-left: auto" @click="resetQuery">{{ i18n.ts.reset }}</MkButton>
|
||||
</div>
|
||||
<div :class="$style.inputs">
|
||||
<MkSelect v-model="sort" style="flex: 1;">
|
||||
<template #label>{{ i18n.ts.sort }}</template>
|
||||
@@ -57,8 +60,10 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, shallowRef, ref } from 'vue';
|
||||
import { computed, shallowRef, ref, watchEffect } from 'vue';
|
||||
import XHeader from './_header_.vue';
|
||||
import { defaultMemoryStorage } from '@/memory-storage';
|
||||
import MkButton from '@/components/MkButton.vue';
|
||||
import MkInput from '@/components/MkInput.vue';
|
||||
import MkSelect from '@/components/MkSelect.vue';
|
||||
import MkPagination from '@/components/MkPagination.vue';
|
||||
@@ -69,13 +74,22 @@ import { definePageMetadata } from '@/scripts/page-metadata.js';
|
||||
import MkUserCardMini from '@/components/MkUserCardMini.vue';
|
||||
import { dateString } from '@/filters/date.js';
|
||||
|
||||
const paginationComponent = shallowRef<InstanceType<typeof MkPagination>>();
|
||||
type SearchQuery = {
|
||||
sort?: string;
|
||||
state?: string;
|
||||
origin?: string;
|
||||
username?: string;
|
||||
hostname?: string;
|
||||
};
|
||||
|
||||
const sort = ref('+createdAt');
|
||||
const state = ref('all');
|
||||
const origin = ref('local');
|
||||
const searchUsername = ref('');
|
||||
const searchHost = ref('');
|
||||
const paginationComponent = shallowRef<InstanceType<typeof MkPagination>>();
|
||||
const storedQuery = JSON.parse(defaultMemoryStorage.getItem('admin-users-query') ?? '{}') as SearchQuery;
|
||||
|
||||
const sort = ref(storedQuery.sort ?? '+createdAt');
|
||||
const state = ref(storedQuery.state ?? 'all');
|
||||
const origin = ref(storedQuery.origin ?? 'local');
|
||||
const searchUsername = ref(storedQuery.username ?? '');
|
||||
const searchHost = ref(storedQuery.hostname ?? '');
|
||||
const pagination = {
|
||||
endpoint: 'admin/show-users' as const,
|
||||
limit: 10,
|
||||
@@ -119,6 +133,14 @@ function show(user) {
|
||||
os.pageWindow(`/admin/user/${user.id}`);
|
||||
}
|
||||
|
||||
function resetQuery() {
|
||||
sort.value = '+createdAt';
|
||||
state.value = 'all';
|
||||
origin.value = 'local';
|
||||
searchUsername.value = '';
|
||||
searchHost.value = '';
|
||||
}
|
||||
|
||||
const headerActions = computed(() => [{
|
||||
icon: 'ti ti-search',
|
||||
text: i18n.ts.search,
|
||||
@@ -137,6 +159,16 @@ const headerActions = computed(() => [{
|
||||
|
||||
const headerTabs = computed(() => []);
|
||||
|
||||
watchEffect(() => {
|
||||
defaultMemoryStorage.setItem('admin-users-query', JSON.stringify({
|
||||
sort: sort.value,
|
||||
state: state.value,
|
||||
origin: origin.value,
|
||||
username: searchUsername.value,
|
||||
hostname: searchHost.value,
|
||||
}));
|
||||
});
|
||||
|
||||
definePageMetadata(() => ({
|
||||
title: i18n.ts.users,
|
||||
icon: 'ti ti-users',
|
||||
|
@@ -63,6 +63,7 @@ import { dateString } from '@/filters/date.js';
|
||||
import MkClipPreview from '@/components/MkClipPreview.vue';
|
||||
import { defaultStore } from '@/store.js';
|
||||
import { pleaseLogin } from '@/scripts/please-login.js';
|
||||
import { getAppearNote } from '@/scripts/get-appear-note.js';
|
||||
import { serverContext, assertServerContext } from '@/server-context.js';
|
||||
import { $i } from '@/account.js';
|
||||
|
||||
@@ -132,10 +133,11 @@ function fetchNote() {
|
||||
noteId: props.noteId,
|
||||
}).then(res => {
|
||||
note.value = res;
|
||||
const appearNote = getAppearNote(res);
|
||||
// 古いノートは被クリップ数をカウントしていないので、2023-10-01以前のものは強制的にnotes/clipsを叩く
|
||||
if (note.value.clippedCount > 0 || new Date(note.value.createdAt).getTime() < new Date('2023-10-01').getTime()) {
|
||||
if ((appearNote.clippedCount ?? 0) > 0 || new Date(appearNote.createdAt).getTime() < new Date('2023-10-01').getTime()) {
|
||||
misskeyApi('notes/clips', {
|
||||
noteId: note.value.id,
|
||||
noteId: appearNote.id,
|
||||
}).then((_clips) => {
|
||||
clips.value = _clips;
|
||||
});
|
||||
@@ -170,7 +172,7 @@ definePageMetadata(() => ({
|
||||
avatar: note.value.user,
|
||||
path: `/notes/${note.value.id}`,
|
||||
share: {
|
||||
title: i18n.tsx.noteOf({ user: note.value.user.name }),
|
||||
title: i18n.tsx.noteOf({ user: note.value.user.name ?? note.value.user.username }),
|
||||
text: note.value.text,
|
||||
},
|
||||
} : {},
|
||||
|
@@ -7,6 +7,7 @@ import { EventEmitter } from 'eventemitter3';
|
||||
import type { IRouter, Resolved, RouteDef, RouterEvent, RouterFlag } from '@/nirax.js';
|
||||
|
||||
import type { App, ShallowRef } from 'vue';
|
||||
import { analytics } from '@/analytics.js';
|
||||
|
||||
/**
|
||||
* {@link Router}による画面遷移を可能とするために{@link mainRouter}をセットアップする。
|
||||
@@ -29,6 +30,14 @@ export function setupRouter(app: App, routerFactory: ((path: string) => IRouter)
|
||||
window.history.replaceState({ key: ctx.key }, '', ctx.path);
|
||||
});
|
||||
|
||||
mainRouter.addListener('change', ctx => {
|
||||
console.log('mainRouter: change', ctx.path);
|
||||
analytics.page({
|
||||
path: ctx.path,
|
||||
title: ctx.path,
|
||||
});
|
||||
});
|
||||
|
||||
mainRouter.init();
|
||||
|
||||
setMainRouter(mainRouter);
|
||||
|
@@ -4,9 +4,9 @@
|
||||
*/
|
||||
|
||||
import { nextTick, ref, defineAsyncComponent } from 'vue';
|
||||
import type { Ref } from 'vue';
|
||||
import getCaretCoordinates from 'textarea-caret';
|
||||
import { toASCII } from 'punycode.js';
|
||||
import type { Ref } from 'vue';
|
||||
import { popup } from '@/os.js';
|
||||
|
||||
export type SuggestionType = 'user' | 'hashtag' | 'emoji' | 'mfmTag' | 'mfmParam';
|
||||
@@ -98,15 +98,21 @@ export class Autocomplete {
|
||||
|
||||
const isMention = mentionIndex !== -1;
|
||||
const isHashtag = hashtagIndex !== -1;
|
||||
const isMfmParam = mfmParamIndex !== -1 && afterLastMfmParam?.includes('.') && !afterLastMfmParam?.includes(' ');
|
||||
const isMfmParam = mfmParamIndex !== -1 && afterLastMfmParam?.includes('.') && !afterLastMfmParam.includes(' ');
|
||||
const isMfmTag = mfmTagIndex !== -1 && !isMfmParam;
|
||||
const isEmoji = emojiIndex !== -1 && text.split(/:[a-z0-9_+\-]+:/).pop()!.includes(':');
|
||||
|
||||
let opened = false;
|
||||
|
||||
if (isMention && this.onlyType.includes('user')) {
|
||||
const username = text.substring(mentionIndex + 1);
|
||||
if (username !== '' && username.match(/^[a-zA-Z0-9_]+$/)) {
|
||||
// ユーザのサジェスト中に@を入力すると、その位置から新たにユーザ名を取りなおそうとしてしまう
|
||||
// この動きはリモートユーザのサジェストを阻害するので、@を検知したらその位置よりも前の@を探し、
|
||||
// ホスト名を含むリモートのユーザ名を全て拾えるようにする
|
||||
const mentionIndexAlt = text.lastIndexOf('@', mentionIndex - 1);
|
||||
const username = mentionIndexAlt === -1
|
||||
? text.substring(mentionIndex + 1)
|
||||
: text.substring(mentionIndexAlt + 1);
|
||||
if (username !== '' && username.match(/^[a-zA-Z0-9_@.]+$/)) {
|
||||
this.open('user', username);
|
||||
opened = true;
|
||||
} else if (username === '') {
|
||||
|
@@ -54,10 +54,6 @@ export async function lookup(router?: Router) {
|
||||
title = i18n.ts._remoteLookupErrors._responseInvalid.title;
|
||||
text = i18n.ts._remoteLookupErrors._responseInvalid.description;
|
||||
break;
|
||||
case 'a2c9c61a-cb72-43ab-a964-3ca5fddb410a':
|
||||
title = i18n.ts._remoteLookupErrors._responseInvalid.title;
|
||||
text = i18n.ts._remoteLookupErrors._responseInvalidIdHostNotMatch.description;
|
||||
break;
|
||||
case 'dc94d745-1262-4e63-a17d-fecaa57efc82':
|
||||
title = i18n.ts._remoteLookupErrors._noSuchObject.title;
|
||||
text = i18n.ts._remoteLookupErrors._noSuchObject.description;
|
||||
|
@@ -24,9 +24,9 @@
|
||||
"devDependencies": {
|
||||
"@types/matter-js": "0.19.8",
|
||||
"@types/seedrandom": "3.0.8",
|
||||
"@types/node": "22.13.4",
|
||||
"@typescript-eslint/eslint-plugin": "8.24.0",
|
||||
"@typescript-eslint/parser": "8.24.0",
|
||||
"@types/node": "22.13.5",
|
||||
"@typescript-eslint/eslint-plugin": "8.24.1",
|
||||
"@typescript-eslint/parser": "8.24.1",
|
||||
"nodemon": "3.1.9",
|
||||
"execa": "9.5.2",
|
||||
"typescript": "5.7.3",
|
||||
|
@@ -8,13 +8,13 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@readme/openapi-parser": "2.7.0",
|
||||
"@types/node": "22.13.4",
|
||||
"@typescript-eslint/eslint-plugin": "8.24.0",
|
||||
"@typescript-eslint/parser": "8.24.0",
|
||||
"@types/node": "22.13.5",
|
||||
"@typescript-eslint/eslint-plugin": "8.24.1",
|
||||
"@typescript-eslint/parser": "8.24.1",
|
||||
"openapi-types": "12.1.3",
|
||||
"openapi-typescript": "6.7.6",
|
||||
"ts-case-convert": "2.1.0",
|
||||
"tsx": "4.19.2",
|
||||
"tsx": "4.19.3",
|
||||
"typescript": "5.7.3"
|
||||
},
|
||||
"files": [
|
||||
|
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"type": "module",
|
||||
"name": "misskey-js",
|
||||
"version": "2025.2.1-alpha.0",
|
||||
"version": "2025.2.1-beta.2",
|
||||
"description": "Misskey SDK for JavaScript",
|
||||
"license": "MIT",
|
||||
"main": "./built/index.js",
|
||||
@@ -35,12 +35,12 @@
|
||||
"directory": "packages/misskey-js"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@microsoft/api-extractor": "7.50.0",
|
||||
"@microsoft/api-extractor": "7.50.1",
|
||||
"@swc/jest": "0.2.37",
|
||||
"@types/jest": "29.5.14",
|
||||
"@types/node": "22.13.4",
|
||||
"@typescript-eslint/eslint-plugin": "8.24.0",
|
||||
"@typescript-eslint/parser": "8.24.0",
|
||||
"@types/node": "22.13.5",
|
||||
"@typescript-eslint/eslint-plugin": "8.24.1",
|
||||
"@typescript-eslint/parser": "8.24.1",
|
||||
"jest": "29.7.0",
|
||||
"jest-fetch-mock": "3.0.3",
|
||||
"jest-websocket-mock": "2.5.0",
|
||||
|
@@ -5051,6 +5051,7 @@ export type components = {
|
||||
enableTurnstile: boolean;
|
||||
turnstileSiteKey: string | null;
|
||||
enableTestcaptcha: boolean;
|
||||
googleAnalyticsMeasurementId: string | null;
|
||||
swPublickey: string | null;
|
||||
/** @default /assets/ai.png */
|
||||
mascotImageUrl: string;
|
||||
@@ -8260,6 +8261,7 @@ export type operations = {
|
||||
enableTurnstile: boolean;
|
||||
turnstileSiteKey: string | null;
|
||||
enableTestcaptcha: boolean;
|
||||
googleAnalyticsMeasurementId: string | null;
|
||||
swPublickey: string | null;
|
||||
/** @default /assets/ai.png */
|
||||
mascotImageUrl: string | null;
|
||||
@@ -10626,6 +10628,7 @@ export type operations = {
|
||||
turnstileSiteKey?: string | null;
|
||||
turnstileSecretKey?: string | null;
|
||||
enableTestcaptcha?: boolean;
|
||||
googleAnalyticsMeasurementId?: string | null;
|
||||
/** @enum {string} */
|
||||
sensitiveMediaDetection?: 'none' | 'all' | 'local' | 'remote';
|
||||
/** @enum {string} */
|
||||
|
@@ -22,9 +22,9 @@
|
||||
"lint": "pnpm typecheck && pnpm eslint"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "22.13.4",
|
||||
"@typescript-eslint/eslint-plugin": "8.24.0",
|
||||
"@typescript-eslint/parser": "8.24.0",
|
||||
"@types/node": "22.13.5",
|
||||
"@typescript-eslint/eslint-plugin": "8.24.1",
|
||||
"@typescript-eslint/parser": "8.24.1",
|
||||
"execa": "9.5.2",
|
||||
"nodemon": "3.1.9",
|
||||
"typescript": "5.7.3",
|
||||
|
@@ -14,7 +14,7 @@
|
||||
"misskey-js": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@typescript-eslint/parser": "8.24.0",
|
||||
"@typescript-eslint/parser": "8.24.1",
|
||||
"@typescript/lib-webworker": "npm:@types/serviceworker@0.0.74",
|
||||
"eslint-plugin-import": "2.31.0",
|
||||
"nodemon": "3.1.9",
|
||||
|
Reference in New Issue
Block a user