mirror of
https://github.com/signalapp/Signal-Desktop.git
synced 2026-04-02 00:07:56 +01:00
267 lines
7.0 KiB
TypeScript
267 lines
7.0 KiB
TypeScript
// Copyright 2026 Signal Messenger, LLC
|
|
// SPDX-License-Identifier: AGPL-3.0-only
|
|
|
|
import type { AciString, ServiceIdString } from '../types/ServiceId.std.js';
|
|
import type {
|
|
ConversationAttributesType,
|
|
ReadonlyMessageAttributesType,
|
|
} from '../model-types.js';
|
|
import { getMessageAge } from './getMessageAge.std.js';
|
|
import {
|
|
getAdminDeleteMaxAgeMs,
|
|
getNormalDeleteMaxAgeMs,
|
|
} from './getDeleteMaxAgeMs.dom.js';
|
|
import { DAY } from './durations/index.std.js';
|
|
import { isGroupV2, isMe } from './whatTypeOfConversation.dom.js';
|
|
import { isSignalConversation } from './isSignalConversation.dom.js';
|
|
import { getSourceServiceId } from '../messages/sources.preload.js';
|
|
import { SignalService as Proto } from '../protobuf/index.std.js';
|
|
|
|
export type DeleteForEveryoneMessage = Pick<
|
|
ReadonlyMessageAttributesType,
|
|
| 'type'
|
|
| 'sourceServiceId'
|
|
| 'sent_at'
|
|
| 'serverTimestamp'
|
|
| 'deletedForEveryone'
|
|
| 'sms'
|
|
>;
|
|
|
|
export type DeleteForEveryoneConversation = Pick<
|
|
ConversationAttributesType,
|
|
'id' | 'e164' | 'serviceId' | 'groupId' | 'groupVersion'
|
|
>;
|
|
|
|
type Result<T extends object = Record<never, never>> =
|
|
| Readonly<{ ok: true } & T>
|
|
| Readonly<{ ok: false; reason: string }>;
|
|
|
|
function checkCommon(
|
|
targetConversation: DeleteForEveryoneConversation,
|
|
targetMessage: DeleteForEveryoneMessage,
|
|
options?: { allowAlreadyDeleted?: boolean }
|
|
): Result {
|
|
if (isSignalConversation(targetConversation)) {
|
|
return { ok: false, reason: 'signal conversation' };
|
|
}
|
|
if (isMe(targetConversation)) {
|
|
return { ok: false, reason: 'note to self conversation' };
|
|
}
|
|
if (!options?.allowAlreadyDeleted && targetMessage.deletedForEveryone) {
|
|
return { ok: false, reason: 'already deleted' };
|
|
}
|
|
if (targetMessage.sms) {
|
|
return { ok: false, reason: 'sms message' };
|
|
}
|
|
return { ok: true };
|
|
}
|
|
|
|
function canUseNormalDelete(options: {
|
|
deleterAci: AciString;
|
|
messageAuthorAci: ServiceIdString | undefined;
|
|
messageAge: number;
|
|
gracePeriodMs?: number;
|
|
}): Result {
|
|
const {
|
|
deleterAci,
|
|
messageAuthorAci,
|
|
messageAge,
|
|
gracePeriodMs = 0,
|
|
} = options;
|
|
if (deleterAci !== messageAuthorAci) {
|
|
return { ok: false, reason: 'not message author' };
|
|
}
|
|
if (messageAge > getNormalDeleteMaxAgeMs() + gracePeriodMs) {
|
|
return { ok: false, reason: 'message is too old' };
|
|
}
|
|
return { ok: true };
|
|
}
|
|
|
|
function isMemberGroupAdmin(
|
|
conversation: Pick<ConversationAttributesType, 'membersV2'>,
|
|
aci: AciString
|
|
): boolean {
|
|
const members = conversation.membersV2 ?? [];
|
|
const member = members.find(m => m.aci === aci);
|
|
return member?.role === Proto.Member.Role.ADMINISTRATOR;
|
|
}
|
|
|
|
function canUseAdminDelete(options: {
|
|
targetConversation: DeleteForEveryoneConversation;
|
|
isDeleterGroupAdmin: boolean;
|
|
messageAge: number;
|
|
gracePeriodMs?: number;
|
|
}): Result {
|
|
const {
|
|
targetConversation,
|
|
isDeleterGroupAdmin,
|
|
messageAge,
|
|
gracePeriodMs = 0,
|
|
} = options;
|
|
if (!isGroupV2(targetConversation)) {
|
|
return { ok: false, reason: 'not a group conversation' };
|
|
}
|
|
if (!isDeleterGroupAdmin) {
|
|
return { ok: false, reason: 'does not have admin role' };
|
|
}
|
|
if (messageAge > getAdminDeleteMaxAgeMs() + gracePeriodMs) {
|
|
return { ok: false, reason: 'message is too old' };
|
|
}
|
|
return { ok: true };
|
|
}
|
|
|
|
export type CanDeleteForEveryoneOptions = Readonly<{
|
|
targetMessage: DeleteForEveryoneMessage;
|
|
targetConversation: DeleteForEveryoneConversation;
|
|
ourAci: AciString;
|
|
isDeleterGroupAdmin: boolean;
|
|
}>;
|
|
|
|
export type CanDeleteForEveryoneResult = Result<{
|
|
needsAdminDelete: boolean;
|
|
}>;
|
|
|
|
export function canSendDeleteForEveryone(
|
|
options: CanDeleteForEveryoneOptions
|
|
): CanDeleteForEveryoneResult {
|
|
const { targetConversation, targetMessage, ourAci, isDeleterGroupAdmin } =
|
|
options;
|
|
|
|
const commonCheck = checkCommon(targetConversation, targetMessage);
|
|
if (!commonCheck.ok) {
|
|
return commonCheck;
|
|
}
|
|
|
|
const messageAuthorAci = getSourceServiceId(targetMessage);
|
|
const messageAge = getMessageAge(Date.now(), targetMessage);
|
|
|
|
// Prefer normal delete for own messages
|
|
const normalCheck = canUseNormalDelete({
|
|
deleterAci: ourAci,
|
|
messageAuthorAci,
|
|
messageAge,
|
|
});
|
|
if (normalCheck.ok) {
|
|
return { ok: true, needsAdminDelete: false };
|
|
}
|
|
|
|
// Admin delete for group messages
|
|
const adminCheck = canUseAdminDelete({
|
|
targetConversation,
|
|
isDeleterGroupAdmin,
|
|
messageAge,
|
|
});
|
|
if (adminCheck.ok) {
|
|
return { ok: true, needsAdminDelete: true };
|
|
}
|
|
|
|
return { ok: false, reason: 'no permission' };
|
|
}
|
|
|
|
export type CanRetrySendDeleteForEveryoneOptions = Readonly<{
|
|
targetMessage: DeleteForEveryoneMessage &
|
|
Pick<ReadonlyMessageAttributesType, 'deletedForEveryoneFailed'>;
|
|
targetConversation: DeleteForEveryoneConversation;
|
|
isAdminDelete: boolean;
|
|
isDeleterGroupAdmin: boolean;
|
|
ourAci: AciString;
|
|
}>;
|
|
|
|
export type CanRetrySendDeleteForEveryoneResult = Result;
|
|
|
|
export function canRetrySendDeleteForEveryone(
|
|
options: CanRetrySendDeleteForEveryoneOptions
|
|
): CanRetrySendDeleteForEveryoneResult {
|
|
const {
|
|
targetMessage,
|
|
targetConversation,
|
|
isAdminDelete,
|
|
isDeleterGroupAdmin,
|
|
ourAci,
|
|
} = options;
|
|
|
|
if (
|
|
!targetMessage.deletedForEveryone ||
|
|
!targetMessage.deletedForEveryoneFailed
|
|
) {
|
|
return { ok: false, reason: 'not a failed delete' };
|
|
}
|
|
|
|
const commonCheck = checkCommon(targetConversation, targetMessage, {
|
|
allowAlreadyDeleted: true,
|
|
});
|
|
if (!commonCheck.ok) {
|
|
return commonCheck;
|
|
}
|
|
|
|
const messageAuthorAci = getSourceServiceId(targetMessage);
|
|
const messageAge = getMessageAge(Date.now(), targetMessage);
|
|
|
|
if (!isAdminDelete) {
|
|
return canUseNormalDelete({
|
|
deleterAci: ourAci,
|
|
messageAuthorAci,
|
|
messageAge,
|
|
});
|
|
}
|
|
|
|
return canUseAdminDelete({
|
|
targetConversation,
|
|
isDeleterGroupAdmin,
|
|
messageAge,
|
|
});
|
|
}
|
|
|
|
export type CanReceiveDeleteForEveryoneOptions = Readonly<{
|
|
targetMessage: DeleteForEveryoneMessage;
|
|
targetConversation: ConversationAttributesType;
|
|
isAdminDelete: boolean;
|
|
deleteServerTimestamp: number;
|
|
deleteSentByAci: AciString;
|
|
}>;
|
|
|
|
export type CanReceiveForEveryoneResult = Result;
|
|
|
|
const MESSAGE_SEND_GRACE_PERIOD = DAY;
|
|
|
|
export function canReceiveDeleteForEveryone(
|
|
options: CanReceiveDeleteForEveryoneOptions
|
|
): CanReceiveForEveryoneResult {
|
|
const {
|
|
targetMessage,
|
|
targetConversation,
|
|
isAdminDelete,
|
|
deleteServerTimestamp,
|
|
deleteSentByAci,
|
|
} = options;
|
|
|
|
const commonCheck = checkCommon(targetConversation, targetMessage);
|
|
if (!commonCheck.ok) {
|
|
return commonCheck;
|
|
}
|
|
|
|
const messageAuthorAci = getSourceServiceId(targetMessage);
|
|
const messageAge = getMessageAge(deleteServerTimestamp, targetMessage);
|
|
|
|
if (!isAdminDelete) {
|
|
return canUseNormalDelete({
|
|
deleterAci: deleteSentByAci,
|
|
messageAuthorAci,
|
|
messageAge,
|
|
gracePeriodMs: MESSAGE_SEND_GRACE_PERIOD,
|
|
});
|
|
}
|
|
|
|
const isDeleterGroupAdmin = isMemberGroupAdmin(
|
|
targetConversation,
|
|
deleteSentByAci
|
|
);
|
|
|
|
return canUseAdminDelete({
|
|
targetConversation,
|
|
isDeleterGroupAdmin,
|
|
messageAge,
|
|
gracePeriodMs: MESSAGE_SEND_GRACE_PERIOD,
|
|
});
|
|
}
|