Merge branch 'develop' into fix-7311

This commit is contained in:
かっこかり 2024-04-14 11:38:00 +09:00 committed by GitHub
commit 0db9ec2d9b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 327 additions and 44 deletions

View File

@ -50,6 +50,9 @@
- Fix: エンドポイント`notes/translate`のエラーを改善
- Fix: CleanRemoteFilesProcessorService report progress from 100% (#13632)
- Fix: 一部の音声ファイルが映像ファイルとして扱われる問題を修正
- Fix: リプライのみの引用リートと、CWのみの引用リートが純粋なリートとして誤って扱われてしまう問題を修正
- Fix: 登録にメール認証が必須になっている場合、登録されているメールアドレスを削除できないように
(Cherry-picked from https://github.com/MisskeyIO/misskey/pull/606)
## 2024.3.1

View File

@ -30,9 +30,13 @@ Cypress.Commands.add('visitHome', () => {
})
Cypress.Commands.add('resetState', () => {
// iframe.contentWindow.indexedDB.deleteDatabase() がchromeのバグで使用できないため、indexedDBを無効化している。
// see https://github.com/misskey-dev/misskey/issues/13605#issuecomment-2053652123
/*
cy.window().then(win => {
win.indexedDB.deleteDatabase('keyval-store');
});
*/
cy.request('POST', '/api/reset-db', {}).as('reset');
cy.get('@reset').its('status').should('equal', 204);
cy.reload(true);

View File

@ -13,7 +13,7 @@ import type { NotesRepository } from '@/models/_.js';
import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
import { FanoutTimelineName, FanoutTimelineService } from '@/core/FanoutTimelineService.js';
import { isUserRelated } from '@/misc/is-user-related.js';
import { isPureRenote } from '@/misc/is-pure-renote.js';
import { isQuote, isRenote } from '@/misc/is-renote.js';
import { CacheService } from '@/core/CacheService.js';
import { isReply } from '@/misc/is-reply.js';
import { isInstanceMuted } from '@/misc/is-instance-muted.js';
@ -95,7 +95,7 @@ export class FanoutTimelineEndpointService {
if (ps.excludePureRenotes) {
const parentFilter = filter;
filter = (note) => !isPureRenote(note) && parentFilter(note);
filter = (note) => (!isRenote(note) || isQuote(note)) && parentFilter(note);
}
if (ps.me) {
@ -116,7 +116,7 @@ export class FanoutTimelineEndpointService {
filter = (note) => {
if (isUserRelated(note, userIdsWhoBlockingMe, ps.ignoreAuthorFromBlock)) return false;
if (isUserRelated(note, userIdsWhoMeMuting, ps.ignoreAuthorFromMute)) return false;
if (isPureRenote(note) && isUserRelated(note, userIdsWhoMeMutingRenotes, ps.ignoreAuthorFromMute)) return false;
if (isRenote(note) && !isQuote(note) && isUserRelated(note, userIdsWhoMeMutingRenotes, ps.ignoreAuthorFromMute)) return false;
if (isInstanceMuted(note, userMutedInstances)) return false;
return parentFilter(note);

View File

@ -306,7 +306,7 @@ export class NoteCreateService implements OnApplicationShutdown {
}
// Check blocking
if (data.renote && !this.isQuote(data)) {
if (this.isRenote(data) && !this.isQuote(data)) {
if (data.renote.userHost === null) {
if (data.renote.userId !== user.id) {
const blocked = await this.userBlockingService.checkBlocked(data.renote.userId, user.id);
@ -641,7 +641,7 @@ export class NoteCreateService implements OnApplicationShutdown {
}
// If it is renote
if (data.renote) {
if (this.isRenote(data)) {
const type = this.isQuote(data) ? 'quote' : 'renote';
// Notify
@ -725,9 +725,20 @@ export class NoteCreateService implements OnApplicationShutdown {
}
@bindThis
private isQuote(note: Option): note is Option & { renote: MiNote } {
// sync with misc/is-quote.ts
return !!note.renote && (!!note.text || !!note.cw || (!!note.files && !!note.files.length) || !!note.poll);
private isRenote(note: Option): note is Option & { renote: MiNote } {
return note.renote != null;
}
@bindThis
private isQuote(note: Option & { renote: MiNote }): note is Option & { renote: MiNote } & (
{ text: string } | { cw: string } | { reply: MiNote } | { poll: IPoll } | { files: MiDriveFile[] }
) {
// NOTE: SYNC WITH misc/is-quote.ts
return note.text != null ||
note.reply != null ||
note.cw != null ||
note.poll != null ||
(note.files != null && note.files.length > 0);
}
@bindThis
@ -795,7 +806,7 @@ export class NoteCreateService implements OnApplicationShutdown {
private async renderNoteOrRenoteActivity(data: Option, note: MiNote) {
if (data.localOnly) return null;
const content = data.renote && !this.isQuote(data)
const content = this.isRenote(data) && !this.isQuote(data)
? this.apRendererService.renderAnnounce(data.renote.uri ? data.renote.uri : `${this.config.url}/notes/${data.renote.id}`, note)
: this.apRendererService.renderCreate(await this.apRendererService.renderNote(note, false), note);

View File

@ -24,7 +24,7 @@ import { bindThis } from '@/decorators.js';
import { MetaService } from '@/core/MetaService.js';
import { SearchService } from '@/core/SearchService.js';
import { ModerationLogService } from '@/core/ModerationLogService.js';
import { isPureRenote } from '@/misc/is-pure-renote.js';
import { isQuote, isRenote } from '@/misc/is-renote.js';
@Injectable()
export class NoteDeleteService {
@ -79,7 +79,7 @@ export class NoteDeleteService {
let renote: MiNote | null = null;
// if deleted note is renote
if (isPureRenote(note)) {
if (isRenote(note) && !isQuote(note)) {
renote = await this.notesRepository.findOneBy({
id: note.renoteId,
});

View File

@ -1,15 +0,0 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { MiNote } from '@/models/Note.js';
export function isPureRenote(note: MiNote): note is MiNote & { renoteId: NonNullable<MiNote['renoteId']> } {
if (!note.renoteId) return false;
if (note.text) return false; // it's quoted with text
if (note.fileIds.length !== 0) return false; // it's quoted with files
if (note.hasPoll) return false; // it's quoted with poll
return true;
}

View File

@ -1,12 +0,0 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { MiNote } from '@/models/Note.js';
// eslint-disable-next-line import/no-default-export
export default function(note: MiNote): boolean {
// sync with NoteCreateService.isQuote
return note.renoteId != null && (note.text != null || note.hasPoll || (note.fileIds != null && note.fileIds.length > 0));
}

View File

@ -0,0 +1,36 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { MiNote } from '@/models/Note.js';
type Renote =
MiNote & {
renoteId: NonNullable<MiNote['renoteId']>
};
type Quote =
Renote & ({
text: NonNullable<MiNote['text']>
} | {
cw: NonNullable<MiNote['cw']>
} | {
replyId: NonNullable<MiNote['replyId']>
reply: NonNullable<MiNote['reply']>
} | {
hasPoll: true
});
export function isRenote(note: MiNote): note is Renote {
return note.renoteId != null;
}
export function isQuote(note: Renote): note is Quote {
// NOTE: SYNC WITH NoteCreateService.isQuote
return note.text != null ||
note.cw != null ||
note.replyId != null ||
note.hasPoll ||
note.fileIds.length > 0;
}

View File

@ -28,7 +28,7 @@ import { UtilityService } from '@/core/UtilityService.js';
import { UserEntityService } from '@/core/entities/UserEntityService.js';
import { bindThis } from '@/decorators.js';
import { IActivity } from '@/core/activitypub/type.js';
import { isPureRenote } from '@/misc/is-pure-renote.js';
import { isQuote, isRenote } from '@/misc/is-renote.js';
import type { FastifyInstance, FastifyRequest, FastifyReply, FastifyPluginOptions, FastifyBodyParser } from 'fastify';
import type { FindOptionsWhere } from 'typeorm';
@ -91,7 +91,7 @@ export class ActivityPubServerService {
*/
@bindThis
private async packActivity(note: MiNote): Promise<any> {
if (isPureRenote(note)) {
if (isRenote(note) && !isQuote(note)) {
const renote = await this.notesRepository.findOneByOrFail({ id: note.renoteId });
return this.apRendererService.renderAnnounce(renote.uri ? renote.uri : `${this.config.url}/notes/${renote.id}`, note);
}

View File

@ -194,6 +194,7 @@ export class FileServerService {
reply.header('Content-Range', `bytes ${start}-${end}/${file.file.size}`);
reply.header('Accept-Ranges', 'bytes');
reply.header('Content-Length', chunksize);
reply.code(206);
} else {
image = {
data: fs.createReadStream(file.path),
@ -263,7 +264,6 @@ export class FileServerService {
const parts = range.replace(/bytes=/, '').split('-');
const start = parseInt(parts[0], 10);
let end = parts[1] ? parseInt(parts[1], 10) : file.file.size - 1;
console.log(end);
if (end > file.file.size) {
end = file.file.size - 1;
}
@ -433,6 +433,7 @@ export class FileServerService {
reply.header('Content-Range', `bytes ${start}-${end}/${file.file.size}`);
reply.header('Accept-Ranges', 'bytes');
reply.header('Content-Length', chunksize);
reply.code(206);
} else {
image = {
data: fs.createReadStream(file.path),
@ -529,6 +530,9 @@ export class FileServerService {
if (!file.storedInternal) {
if (!(file.isLink && file.uri)) return '204';
const result = await this.downloadAndDetectTypeFromUrl(file.uri);
if (!file.size) {
file.size = (await fs.promises.stat(result.path)).size;
}
return {
...result,
url: file.uri,

View File

@ -15,6 +15,7 @@ import { DI } from '@/di-symbols.js';
import { GlobalEventService } from '@/core/GlobalEventService.js';
import { L_CHARS, secureRndstr } from '@/misc/secure-rndstr.js';
import { UserAuthService } from '@/core/UserAuthService.js';
import { MetaService } from '@/core/MetaService.js';
import { ApiError } from '../../error.js';
export const meta = {
@ -39,6 +40,12 @@ export const meta = {
code: 'UNAVAILABLE',
id: 'a2defefb-f220-8849-0af6-17f816099323',
},
emailRequired: {
message: 'Email address is required.',
code: 'EMAIL_REQUIRED',
id: '324c7a88-59f2-492f-903f-89134f93e47e',
},
},
res: {
@ -66,6 +73,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
@Inject(DI.userProfilesRepository)
private userProfilesRepository: UserProfilesRepository,
private metaService: MetaService,
private userEntityService: UserEntityService,
private emailService: EmailService,
private userAuthService: UserAuthService,
@ -97,6 +105,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
if (!res.available) {
throw new ApiError(meta.errors.unavailable);
}
} else if ((await this.metaService.fetch()).emailRequiredForSignup) {
throw new ApiError(meta.errors.emailRequired);
}
await this.userProfilesRepository.update(me.id, {

View File

@ -16,7 +16,7 @@ import { Endpoint } from '@/server/api/endpoint-base.js';
import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
import { NoteCreateService } from '@/core/NoteCreateService.js';
import { DI } from '@/di-symbols.js';
import { isPureRenote } from '@/misc/is-pure-renote.js';
import { isQuote, isRenote } from '@/misc/is-renote.js';
import { MetaService } from '@/core/MetaService.js';
import { UtilityService } from '@/core/UtilityService.js';
import { IdentifiableError } from '@/misc/identifiable-error.js';
@ -275,7 +275,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
if (renote == null) {
throw new ApiError(meta.errors.noSuchRenoteTarget);
} else if (isPureRenote(renote)) {
} else if (isRenote(renote) && !isQuote(renote)) {
throw new ApiError(meta.errors.cannotReRenote);
}
@ -321,7 +321,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
if (reply == null) {
throw new ApiError(meta.errors.noSuchReplyTarget);
} else if (isPureRenote(reply)) {
} else if (isRenote(reply) && !isQuote(reply)) {
throw new ApiError(meta.errors.cannotReplyToPureRenote);
} else if (!await this.noteEntityService.isVisibleForMe(reply, me.id)) {
throw new ApiError(meta.errors.cannotReplyToInvisibleNote);

View File

@ -0,0 +1,144 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Test } from '@nestjs/testing';
import { CoreModule } from '@/core/CoreModule.js';
import { NoteCreateService } from '@/core/NoteCreateService.js';
import { GlobalModule } from '@/GlobalModule.js';
import { MiNote } from '@/models/Note.js';
import { IPoll } from '@/models/Poll.js';
import { MiDriveFile } from '@/models/DriveFile.js';
describe('NoteCreateService', () => {
let noteCreateService: NoteCreateService;
beforeAll(async () => {
const app = await Test.createTestingModule({
imports: [GlobalModule, CoreModule],
}).compile();
noteCreateService = app.get<NoteCreateService>(NoteCreateService);
});
describe('is-renote', () => {
const base: MiNote = {
id: 'some-note-id',
replyId: null,
reply: null,
renoteId: null,
renote: null,
threadId: null,
text: null,
name: null,
cw: null,
userId: 'some-user-id',
user: null,
localOnly: false,
reactionAcceptance: null,
renoteCount: 0,
repliesCount: 0,
clippedCount: 0,
reactions: {},
visibility: 'public',
uri: null,
url: null,
fileIds: [],
attachedFileTypes: [],
visibleUserIds: [],
mentions: [],
mentionedRemoteUsers: '',
reactionAndUserPairCache: [],
emojis: [],
tags: [],
hasPoll: false,
channelId: null,
channel: null,
userHost: null,
replyUserId: null,
replyUserHost: null,
renoteUserId: null,
renoteUserHost: null,
};
const poll: IPoll = {
choices: ['kinoko', 'takenoko'],
multiple: false,
expiresAt: null,
};
const file: MiDriveFile = {
id: 'some-file-id',
userId: null,
user: null,
userHost: null,
md5: '',
name: '',
type: '',
size: 0,
comment: null,
blurhash: null,
properties: {},
storedInternal: false,
url: '',
thumbnailUrl: null,
webpublicUrl: null,
webpublicType: null,
accessKey: null,
thumbnailAccessKey: null,
webpublicAccessKey: null,
uri: null,
src: null,
folderId: null,
folder: null,
isSensitive: false,
maybeSensitive: false,
maybePorn: false,
isLink: false,
requestHeaders: null,
requestIp: null,
};
test('note without renote should not be Renote', () => {
const note = { renote: null };
expect(noteCreateService['isRenote'](note)).toBe(false);
});
test('note with renote should be Renote and not be Quote', () => {
const note = { renote: base };
expect(noteCreateService['isRenote'](note)).toBe(true);
expect(noteCreateService['isQuote'](note)).toBe(false);
});
test('note with renote and text should be Quote', () => {
const note = { renote: base, text: 'some-text' };
expect(noteCreateService['isRenote'](note)).toBe(true);
expect(noteCreateService['isQuote'](note)).toBe(true);
});
test('note with renote and cw should be Quote', () => {
const note = { renote: base, cw: 'some-cw' };
expect(noteCreateService['isRenote'](note)).toBe(true);
expect(noteCreateService['isQuote'](note)).toBe(true);
});
test('note with renote and reply should be Quote', () => {
const note = { renote: base, reply: { ...base, id: 'another-note-id' } };
expect(noteCreateService['isRenote'](note)).toBe(true);
expect(noteCreateService['isQuote'](note)).toBe(true);
});
test('note with renote and poll should be Quote', () => {
const note = { renote: base, poll };
expect(noteCreateService['isRenote'](note)).toBe(true);
expect(noteCreateService['isQuote'](note)).toBe(true);
});
test('note with renote and non-empty files should be Quote', () => {
const note = { renote: base, files: [file] };
expect(noteCreateService['isRenote'](note)).toBe(true);
expect(noteCreateService['isQuote'](note)).toBe(true);
});
});
});

View File

@ -0,0 +1,88 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { isQuote, isRenote } from '@/misc/is-renote.js';
import { MiNote } from '@/models/Note.js';
const base: MiNote = {
id: 'some-note-id',
replyId: null,
reply: null,
renoteId: null,
renote: null,
threadId: null,
text: null,
name: null,
cw: null,
userId: 'some-user-id',
user: null,
localOnly: false,
reactionAcceptance: null,
renoteCount: 0,
repliesCount: 0,
clippedCount: 0,
reactions: {},
visibility: 'public',
uri: null,
url: null,
fileIds: [],
attachedFileTypes: [],
visibleUserIds: [],
mentions: [],
mentionedRemoteUsers: '',
reactionAndUserPairCache: [],
emojis: [],
tags: [],
hasPoll: false,
channelId: null,
channel: null,
userHost: null,
replyUserId: null,
replyUserHost: null,
renoteUserId: null,
renoteUserHost: null,
};
describe('misc:is-renote', () => {
test('note without renoteId should not be Renote', () => {
expect(isRenote(base)).toBe(false);
});
test('note with renoteId should be Renote and not be Quote', () => {
const note: MiNote = { ...base, renoteId: 'some-renote-id' };
expect(isRenote(note)).toBe(true);
expect(isQuote(note as any)).toBe(false);
});
test('note with renoteId and text should be Quote', () => {
const note: MiNote = { ...base, renoteId: 'some-renote-id', text: 'some-text' };
expect(isRenote(note)).toBe(true);
expect(isQuote(note as any)).toBe(true);
});
test('note with renoteId and cw should be Quote', () => {
const note: MiNote = { ...base, renoteId: 'some-renote-id', cw: 'some-cw' };
expect(isRenote(note)).toBe(true);
expect(isQuote(note as any)).toBe(true);
});
test('note with renoteId and replyId should be Quote', () => {
const note: MiNote = { ...base, renoteId: 'some-renote-id', replyId: 'some-reply-id' };
expect(isRenote(note)).toBe(true);
expect(isQuote(note as any)).toBe(true);
});
test('note with renoteId and poll should be Quote', () => {
const note: MiNote = { ...base, renoteId: 'some-renote-id', hasPoll: true };
expect(isRenote(note)).toBe(true);
expect(isQuote(note as any)).toBe(true);
});
test('note with renoteId and non-empty fileIds should be Quote', () => {
const note: MiNote = { ...base, renoteId: 'some-renote-id', fileIds: ['some-file-id'] };
expect(isRenote(note)).toBe(true);
expect(isQuote(note as any)).toBe(true);
});
});

View File

@ -15,6 +15,16 @@ const fallbackName = (key: string) => `idbfallback::${key}`;
let idbAvailable = typeof window !== 'undefined' ? !!(window.indexedDB && window.indexedDB.open) : true;
// iframe.contentWindow.indexedDB.deleteDatabase() がchromeのバグで使用できないため、indexedDBを無効化している。
// バグが治って再度有効化するのであれば、cypressのコマンド内のコメントアウトを外すこと
// see https://github.com/misskey-dev/misskey/issues/13605#issuecomment-2053652123
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
if (window.Cypress) {
idbAvailable = false;
console.log('Cypress detected. It will use localStorage.');
}
if (idbAvailable) {
await iset('idb-test', 'test')
.catch(err => {