mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-10 12:42:27 +09:00
Compare commits
28
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ab68b61653 | ||
|
|
639ade3802 | ||
|
|
3f1f899b23 | ||
|
|
51cb750502 | ||
|
|
1e6c332eae | ||
|
|
18ae2e563e | ||
|
|
a4d039c910 | ||
|
|
6163fd5644 | ||
|
|
3e2ddaca4f | ||
|
|
054a59e622 | ||
|
|
84d7290ed9 | ||
|
|
f4c1254d91 | ||
|
|
c01d22dc05 | ||
|
|
4c0f02d8a5 | ||
|
|
2770482baf | ||
|
|
8e39a00e34 | ||
|
|
7bd0d3a962 | ||
|
|
bc7f701e87 | ||
|
|
0d8116d73e | ||
|
|
255cbc1248 | ||
|
|
098aeef412 | ||
|
|
baa18aed5b | ||
|
|
b7c8dab019 | ||
|
|
587324fa38 | ||
|
|
cc3a9c8613 | ||
|
|
b0645300ec | ||
|
|
d7d4e8da03 | ||
|
|
16ae98e189 |
@@ -32,6 +32,7 @@ RUN apt-get update \
|
||||
jq \
|
||||
libasound2 \
|
||||
libatk-bridge2.0-0 \
|
||||
libaom-dev \
|
||||
libavcodec-dev \
|
||||
libavfilter-dev \
|
||||
libavformat-dev \
|
||||
@@ -51,9 +52,15 @@ RUN apt-get update \
|
||||
libcurl4-openssl-dev \
|
||||
libswresample-dev \
|
||||
libswscale-dev \
|
||||
libdav1d-dev \
|
||||
libde265-dev \
|
||||
liblcms2-dev \
|
||||
libvips-dev \
|
||||
libyuv-dev \
|
||||
libwayland-dev \
|
||||
libwebp-dev \
|
||||
nasm \
|
||||
yasm \
|
||||
libssl-dev \
|
||||
libx11-xcb1 \
|
||||
libxcb-dri3-0 \
|
||||
@@ -78,6 +85,7 @@ RUN apt-get update \
|
||||
unzip \
|
||||
xz-utils \
|
||||
xdg-utils \
|
||||
zlib1g-dev \
|
||||
zstd \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
@@ -127,6 +135,26 @@ RUN curl --retry 5 --retry-delay 2 --retry-all-errors -fsSL https://deb.nodesour
|
||||
|
||||
RUN python3 -m pip install --break-system-packages --no-cache-dir awscli
|
||||
|
||||
COPY fluxer_media_proxy/tools/install-native-deps.sh /tmp/fluxer-install-native-deps.sh
|
||||
RUN /tmp/fluxer-install-native-deps.sh /usr/local \
|
||||
&& rm /tmp/fluxer-install-native-deps.sh
|
||||
|
||||
ENV PKG_CONFIG_PATH=/usr/local/lib/pkgconfig:/usr/local/lib64/pkgconfig
|
||||
ENV LD_LIBRARY_PATH=/usr/local/lib
|
||||
|
||||
RUN printf '%s\n' \
|
||||
'#include <libheif/heif.h>' \
|
||||
'#include <string.h>' \
|
||||
'#if !LIBHEIF_HAVE_VERSION(1, 23, 0)' \
|
||||
'#error the source-built libheif headers must win the include search' \
|
||||
'#endif' \
|
||||
'int main(void) { return strcmp(heif_get_version(), LIBHEIF_VERSION) != 0; }' \
|
||||
>/tmp/fluxer-heif-probe.c \
|
||||
&& cc /tmp/fluxer-heif-probe.c $(pkg-config --cflags --libs libheif) -o /tmp/fluxer-heif-probe \
|
||||
&& /tmp/fluxer-heif-probe \
|
||||
&& [ "$(pkg-config --variable=prefix libheif)" = /usr/local ] \
|
||||
&& rm /tmp/fluxer-heif-probe.c /tmp/fluxer-heif-probe
|
||||
|
||||
COPY tools/fonts/requirements.txt /tmp/fluxer-fonts-requirements.txt
|
||||
RUN python3 -m pip install --break-system-packages --no-cache-dir -r /tmp/fluxer-fonts-requirements.txt \
|
||||
&& rm /tmp/fluxer-fonts-requirements.txt \
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
name: fluxer-dev
|
||||
|
||||
services:
|
||||
workspace:
|
||||
build:
|
||||
|
||||
@@ -152,8 +152,8 @@ jobs:
|
||||
'packages/markdown_parser/rust/src/**') }}
|
||||
|
||||
rust:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 60
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
timeout-minutes: 45
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
||||
@@ -174,11 +174,15 @@ jobs:
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Cache cargo
|
||||
uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9
|
||||
with:
|
||||
workspaces: |
|
||||
. -> target
|
||||
save-if: ${{ github.ref == 'refs/heads/main' }}
|
||||
path: |
|
||||
~/.cargo/registry
|
||||
~/.cargo/git
|
||||
target
|
||||
key: rust-${{ runner.os }}-${{ hashFiles('fluxer_media_proxy/tools/install-native-deps.sh') }}-${{ hashFiles('Cargo.lock') }}
|
||||
restore-keys: |
|
||||
rust-${{ runner.os }}-${{ hashFiles('fluxer_media_proxy/tools/install-native-deps.sh') }}-
|
||||
|
||||
- name: Install cargo-deny
|
||||
run: cargo install cargo-deny --version 0.19.6 --locked
|
||||
@@ -189,6 +193,12 @@ jobs:
|
||||
- name: Check desktop native dependencies
|
||||
run: tools/ci/check-desktop-native-workspaces.sh dependencies
|
||||
|
||||
- name: Cache native media dependencies
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9
|
||||
with:
|
||||
path: /opt/fluxer-native
|
||||
key: media-native-${{ runner.os }}-${{ hashFiles('fluxer_media_proxy/tools/install-native-deps.sh') }}
|
||||
|
||||
- name: Install native dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
@@ -197,21 +207,34 @@ jobs:
|
||||
binutils \
|
||||
clang \
|
||||
cmake \
|
||||
curl \
|
||||
libaom-dev \
|
||||
libavfilter-dev \
|
||||
libclang-dev \
|
||||
libcurl4-openssl-dev \
|
||||
libdav1d-dev \
|
||||
libde265-dev \
|
||||
libfido2-dev \
|
||||
libheif-dev \
|
||||
liblcms2-dev \
|
||||
libpipewire-0.3-dev \
|
||||
libspa-0.2-dev \
|
||||
libssl-dev \
|
||||
libudev-dev \
|
||||
libvips-dev \
|
||||
libwebp-dev \
|
||||
libyuv-dev \
|
||||
meson \
|
||||
nasm \
|
||||
ninja-build \
|
||||
pkg-config \
|
||||
xz-utils \
|
||||
yasm \
|
||||
zlib1g-dev
|
||||
sudo fluxer_media_proxy/tools/install-native-deps.sh /opt/fluxer-native
|
||||
echo "PKG_CONFIG_PATH=/opt/fluxer-native/lib/pkgconfig:/opt/fluxer-native/lib64/pkgconfig" >> "$GITHUB_ENV"
|
||||
echo "LD_LIBRARY_PATH=/opt/fluxer-native/lib:/opt/fluxer-native/lib64" >> "$GITHUB_ENV"
|
||||
echo "/opt/fluxer-native/bin" >> "$GITHUB_PATH"
|
||||
|
||||
- name: Install Node.js dependencies
|
||||
run: pnpm --filter fluxer_admin install
|
||||
@@ -228,7 +251,16 @@ jobs:
|
||||
- name: Clippy (desktop native workspaces on Linux, warnings as errors)
|
||||
run: tools/ci/check-desktop-native-workspaces.sh clippy
|
||||
|
||||
- name: Verify the source-built ffmpeg CLI is on PATH
|
||||
run: |
|
||||
set -euo pipefail
|
||||
command -v ffmpeg
|
||||
test "$(command -v ffmpeg)" = /opt/fluxer-native/bin/ffmpeg
|
||||
ffmpeg -hide_banner -version
|
||||
|
||||
- name: Run tests
|
||||
env:
|
||||
FLUXER_REQUIRE_MEDIA_FIXTURES: "1"
|
||||
run: cargo test --workspace --all-features --locked
|
||||
|
||||
- name: Run desktop native workspace tests on Linux
|
||||
|
||||
Generated
+1
-3
@@ -1809,6 +1809,7 @@ dependencies = [
|
||||
"clap",
|
||||
"criterion",
|
||||
"fluxer_common",
|
||||
"futures-util",
|
||||
"hex",
|
||||
"hmac 0.13.0",
|
||||
"http 1.4.2",
|
||||
@@ -1989,13 +1990,10 @@ dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
"base64",
|
||||
"fluxer-svc",
|
||||
"fluxer_common",
|
||||
"hex",
|
||||
"moka",
|
||||
"rand 0.10.1",
|
||||
"reqwest",
|
||||
"scylla",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tokio",
|
||||
|
||||
@@ -140,7 +140,7 @@ FLUXER_DISCOVERY_ENABLED=true
|
||||
# budget roughly shared_buffers + (server max_connections x 12 MB) +
|
||||
# (3 x autovacuum_work_mem) + 300 MB for page cache and WAL. Note this is the
|
||||
# server setting, distinct from the per-service FLUXER_POSTGRES_MAX_CONNECTIONS
|
||||
# pool sizes used by the api, worker, app-proxy and shards.
|
||||
# pool sizes used by the api, worker and shards.
|
||||
#FLUXER_POSTGRES_SERVER_MAX_CONNECTIONS=150
|
||||
#FLUXER_POSTGRES_SHARED_BUFFERS=512MB
|
||||
#FLUXER_POSTGRES_EFFECTIVE_CACHE_SIZE=2GB
|
||||
|
||||
@@ -434,6 +434,7 @@ services:
|
||||
FLUXER_MEDIA_PROXY_PORT: "8080"
|
||||
FLUXER_MEDIA_PROXY_MODE: upload
|
||||
FLUXER_MEDIA_PROXY_STORAGE_BACKEND: s3
|
||||
FLUXER_MEDIA_PROXY_PUBLIC_ENDPOINT: ${FLUXER_PUBLIC_ORIGIN:-${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}}/media
|
||||
depends_on:
|
||||
seaweedfs-init: {condition: service_completed_successfully}
|
||||
nats: {condition: service_healthy}
|
||||
@@ -459,7 +460,6 @@ services:
|
||||
limits:
|
||||
memory: ${FLUXER_APP_PROXY_MEMORY_LIMIT:-256mb}
|
||||
environment:
|
||||
<<: *fluxer-postgres-env
|
||||
FLUXER_APP_PROXY_HOST: 0.0.0.0
|
||||
FLUXER_APP_PROXY_PORT: "8080"
|
||||
DISCOVERY_UPSTREAM_URL: http://caddy:8088/api/.well-known/fluxer
|
||||
@@ -476,11 +476,9 @@ services:
|
||||
FLUXER_CSP_EXTRA_WORKER_SRC: ${FLUXER_CSP_EXTRA_WORKER_SRC:-}
|
||||
FLUXER_CSP_EXTRA_MANIFEST_SRC: ${FLUXER_CSP_EXTRA_MANIFEST_SRC:-}
|
||||
FLUXER_CSP_REPORT_URI: ${FLUXER_CSP_REPORT_URI:-}
|
||||
FLUXER_POSTGRES_MAX_CONNECTIONS: "5"
|
||||
depends_on:
|
||||
api: {condition: service_healthy}
|
||||
caddy: {condition: service_healthy}
|
||||
postgres: {condition: service_healthy}
|
||||
|
||||
snowflakes:
|
||||
<<: *fluxer-service
|
||||
|
||||
@@ -9034,7 +9034,6 @@
|
||||
"NOT_FRIENDS_WITH_USER",
|
||||
"NOT_OWNER_OF_ADMIN_API_KEY",
|
||||
"NSFW_CONTENT_AGE_RESTRICTED",
|
||||
"NSFW_EMOJI_STICKER_BLOCKED",
|
||||
"PASSKEY_AUTHENTICATION_FAILED",
|
||||
"PASSKEYS_DISABLED",
|
||||
"PHONE_ADD_NOT_ELIGIBLE",
|
||||
@@ -12283,11 +12282,6 @@
|
||||
"type": "array",
|
||||
"items": {"$ref": "#/components/schemas/MessageStickerResponse"}
|
||||
},
|
||||
"nsfw_emojis": {
|
||||
"type": "array",
|
||||
"items": {"$ref": "#/components/schemas/SnowflakeType"},
|
||||
"description": "IDs of custom emojis in this message that are classified as NSFW"
|
||||
},
|
||||
"reactions": {
|
||||
"description": "The reactions on the message",
|
||||
"nullable": true,
|
||||
@@ -12431,11 +12425,6 @@
|
||||
"type": "array",
|
||||
"items": {"$ref": "#/components/schemas/MessageStickerResponse"}
|
||||
},
|
||||
"nsfw_emojis": {
|
||||
"type": "array",
|
||||
"items": {"$ref": "#/components/schemas/SnowflakeType"},
|
||||
"description": "IDs of custom emojis in this message that are classified as NSFW"
|
||||
},
|
||||
"reactions": {
|
||||
"description": "The reactions on the message",
|
||||
"nullable": true,
|
||||
@@ -12881,8 +12870,7 @@
|
||||
"description": "The unique identifier of the sticker"
|
||||
},
|
||||
"name": {"type": "string", "description": "The name of the sticker"},
|
||||
"animated": {"type": "boolean", "description": "Whether the sticker is animated"},
|
||||
"nsfw": {"type": "boolean", "description": "Whether this sticker is classified as NSFW"}
|
||||
"animated": {"type": "boolean", "description": "Whether the sticker is animated"}
|
||||
},
|
||||
"required": ["id", "name", "animated"]
|
||||
},
|
||||
|
||||
@@ -12,7 +12,6 @@ pub struct I32Flag {
|
||||
|
||||
pub mod user_flag_bits {
|
||||
pub const STAFF: u64 = 1 << 0;
|
||||
pub const CTP_MEMBER: u64 = 1 << 1;
|
||||
pub const PARTNER: u64 = 1 << 2;
|
||||
pub const BUG_HUNTER: u64 = 1 << 3;
|
||||
pub const FRIENDLY_BOT: u64 = 1 << 4;
|
||||
@@ -40,10 +39,6 @@ pub const USER_FLAGS: &[U64Flag] = &[
|
||||
name: "STAFF",
|
||||
value: user_flag_bits::STAFF,
|
||||
},
|
||||
U64Flag {
|
||||
name: "CTP_MEMBER",
|
||||
value: user_flag_bits::CTP_MEMBER,
|
||||
},
|
||||
U64Flag {
|
||||
name: "PARTNER",
|
||||
value: user_flag_bits::PARTNER,
|
||||
|
||||
@@ -30,12 +30,6 @@ pub fn user_profile_badges(
|
||||
tooltip: "Fluxer Staff".into(),
|
||||
});
|
||||
}
|
||||
if !is_self_hosted && flags & user_flag_bits::CTP_MEMBER != 0 {
|
||||
badges.push(BadgeDef {
|
||||
icon_url: format!("{cdn}/badges/ctp.svg"),
|
||||
tooltip: "Fluxer Community Team".into(),
|
||||
});
|
||||
}
|
||||
if !is_self_hosted && flags & user_flag_bits::PARTNER != 0 {
|
||||
badges.push(BadgeDef {
|
||||
icon_url: format!("{cdn}/badges/partner.svg"),
|
||||
|
||||
@@ -51,10 +51,6 @@ const PATCHABLE_USER_FLAGS: &[UserFlag] = &[
|
||||
name: "STAFF",
|
||||
value: 1 << 0,
|
||||
},
|
||||
UserFlag {
|
||||
name: "CTP_MEMBER",
|
||||
value: 1 << 1,
|
||||
},
|
||||
UserFlag {
|
||||
name: "PARTNER",
|
||||
value: 1 << 2,
|
||||
|
||||
@@ -90,7 +90,6 @@ function makeMessageRow(channelId: ChannelID, messageId: MessageID): MessageRow
|
||||
message_reference: null,
|
||||
message_snapshots: null,
|
||||
call: null,
|
||||
nsfw_emojis: null,
|
||||
has_reaction: false,
|
||||
version: 1,
|
||||
};
|
||||
|
||||
@@ -60,7 +60,6 @@ function buildMessageRow(messageId: MessageID, hasReaction: boolean | null): Mes
|
||||
message_reference: null,
|
||||
message_snapshots: null,
|
||||
call: null,
|
||||
nsfw_emojis: null,
|
||||
has_reaction: hasReaction,
|
||||
version: 1,
|
||||
};
|
||||
|
||||
@@ -205,7 +205,6 @@ export class CallService {
|
||||
participant_ids: new Set(allRecipients),
|
||||
ended_timestamp: null,
|
||||
},
|
||||
nsfw_emojis: null,
|
||||
has_reaction: false,
|
||||
version: 1,
|
||||
});
|
||||
|
||||
@@ -1,13 +1,8 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {dispatchChannelEvent} from '@app/api/channel/services/ChannelGatewayDispatch';
|
||||
import {ChannelTypes, GUILD_TEXT_BASED_CHANNEL_TYPES, Permissions} from '@fluxer/constants/src/ChannelConstants';
|
||||
import {
|
||||
GuildExplicitContentFilterTypes,
|
||||
GuildFeatures,
|
||||
GuildNSFWLevel,
|
||||
GuildOperations,
|
||||
} from '@fluxer/constants/src/GuildConstants';
|
||||
import {Permissions} from '@fluxer/constants/src/ChannelConstants';
|
||||
import {GuildOperations} from '@fluxer/constants/src/GuildConstants';
|
||||
import type {LimitKey} from '@fluxer/constants/src/LimitConfigMetadata';
|
||||
import {MAX_REACTIONS_PER_MESSAGE, MAX_USERS_PER_MESSAGE_REACTION} from '@fluxer/constants/src/LimitConstants';
|
||||
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
|
||||
@@ -17,10 +12,7 @@ import {UnknownMessageError} from '@fluxer/errors/src/domains/channel/UnknownMes
|
||||
import {FeatureTemporarilyDisabledError} from '@fluxer/errors/src/domains/core/FeatureTemporarilyDisabledError';
|
||||
import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError';
|
||||
import {MissingPermissionsError} from '@fluxer/errors/src/domains/core/MissingPermissionsError';
|
||||
import {NsfwEmojiStickerBlockedError} from '@fluxer/errors/src/domains/moderation/NsfwEmojiStickerBlockedError';
|
||||
import {resolveLimit} from '@fluxer/limits/src/LimitResolver';
|
||||
import type {GuildMemberResponse} from '@fluxer/schema/src/domains/guild/GuildMemberSchemas';
|
||||
import type {GuildResponse} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas';
|
||||
import type {UserPartialResponse} from '@fluxer/schema/src/domains/user/UserResponseSchemas';
|
||||
import {isValidSingleUnicodeEmoji} from '@fluxer/schema/src/primitives/EmojiValidators';
|
||||
import {snowflakeToDate} from '@fluxer/snowflake/src/Snowflake';
|
||||
@@ -226,21 +218,6 @@ export class MessageReactionService extends MessageInteractionBase {
|
||||
hasPermission: channel.guildId ? hasPermission : undefined,
|
||||
});
|
||||
}
|
||||
if (parsedEmoji.id) {
|
||||
const reactionEmojiId = createEmojiID(BigInt(parsedEmoji.id));
|
||||
const emojiObj = await this.guildRepository.getEmojiById(reactionEmojiId);
|
||||
if (emojiObj?.isNsfw) {
|
||||
const isNSFWAllowed = this.isNSFWContentAllowedForReaction({
|
||||
channel,
|
||||
guild,
|
||||
member: authChannel.member,
|
||||
isBot: requestingUser?.isBot,
|
||||
});
|
||||
if (!isNSFWAllowed) {
|
||||
throw new NsfwEmojiStickerBlockedError();
|
||||
}
|
||||
}
|
||||
}
|
||||
if (reactionCount >= maxUsersPerReaction) {
|
||||
throw new MaxUsersPerMessageReactionError(maxUsersPerReaction);
|
||||
}
|
||||
@@ -548,42 +525,4 @@ export class MessageReactionService extends MessageInteractionBase {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private isNSFWContentAllowedForReaction(params: {
|
||||
channel: Channel;
|
||||
guild: GuildResponse | null;
|
||||
member: GuildMemberResponse | null;
|
||||
isBot?: boolean;
|
||||
}): boolean {
|
||||
const {channel, guild, member, isBot} = params;
|
||||
if (isBot) {
|
||||
return true;
|
||||
}
|
||||
if (GUILD_TEXT_BASED_CHANNEL_TYPES.has(channel.type) && channel.isNsfw) {
|
||||
return true;
|
||||
}
|
||||
if (channel.type === ChannelTypes.DM_PERSONAL_NOTES) {
|
||||
return true;
|
||||
}
|
||||
if (!guild) {
|
||||
return false;
|
||||
}
|
||||
const guildMarkedNsfw = guild.nsfw_level === GuildNSFWLevel.AGE_RESTRICTED;
|
||||
if (guildMarkedNsfw) {
|
||||
return true;
|
||||
}
|
||||
const features = new Set(guild.features ?? []);
|
||||
if (features.has(GuildFeatures.DISCOVERABLE)) {
|
||||
return false;
|
||||
}
|
||||
const explicitContentFilter = guild.explicit_content_filter;
|
||||
if (explicitContentFilter === GuildExplicitContentFilterTypes.DISABLED) {
|
||||
return true;
|
||||
}
|
||||
if (explicitContentFilter === GuildExplicitContentFilterTypes.MEMBERS_WITHOUT_ROLES) {
|
||||
const hasRoles = member && member.roles.length > 0;
|
||||
return !!hasRoles;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -443,8 +443,7 @@ export class MessageMentionService {
|
||||
return validUserIds;
|
||||
}
|
||||
const authorHasStaffFlag = (author.flags & UserFlags.STAFF) === UserFlags.STAFF;
|
||||
const authorHasCtpFlag = (author.flags & UserFlags.CTP_MEMBER) === UserFlags.CTP_MEMBER;
|
||||
if (authorHasStaffFlag || authorHasCtpFlag) {
|
||||
if (authorHasStaffFlag) {
|
||||
return validUserIds;
|
||||
}
|
||||
const staffTargets = validUsers.filter((user) => (user.flags & UserFlags.STAFF) === UserFlags.STAFF);
|
||||
|
||||
@@ -6,7 +6,6 @@ import {ATTACHMENT_MAX_SIZE_NON_PREMIUM} from '@fluxer/constants/src/LimitConsta
|
||||
import {UserFlags} from '@fluxer/constants/src/UserConstants';
|
||||
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
|
||||
import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError';
|
||||
import {NsfwEmojiStickerBlockedError} from '@fluxer/errors/src/domains/moderation/NsfwEmojiStickerBlockedError';
|
||||
import type {GuildMemberResponse} from '@fluxer/schema/src/domains/guild/GuildMemberSchemas';
|
||||
import type {GuildResponse} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas';
|
||||
import type {RichEmbedRequest} from '@fluxer/schema/src/domains/message/MessageRequestSchemas';
|
||||
@@ -15,8 +14,8 @@ import {snowflakeToDate} from '@fluxer/snowflake/src/Snowflake';
|
||||
import * as BucketUtils from '@fluxer/snowflake/src/SnowflakeBuckets';
|
||||
import type {IVirusScanService} from '@pkgs/virus_scan/src/IVirusScanService';
|
||||
import {AttachmentDecayService} from '../../../attachment/AttachmentDecayService';
|
||||
import type {ChannelID, EmojiID, GuildID, MessageID, RoleID, StickerID, UserID, WebhookID} from '../../../BrandedTypes';
|
||||
import {createAttachmentID, createEmojiID, createGuildID} from '../../../BrandedTypes';
|
||||
import type {ChannelID, GuildID, MessageID, RoleID, StickerID, UserID, WebhookID} from '../../../BrandedTypes';
|
||||
import {createAttachmentID, createGuildID} from '../../../BrandedTypes';
|
||||
import {Config} from '../../../Config';
|
||||
import {getContentMessage} from '../../../content_i18n/ContentI18n';
|
||||
import type {
|
||||
@@ -121,7 +120,7 @@ export class MessagePersistenceService {
|
||||
constructor(
|
||||
private channelRepository: IChannelRepositoryAggregate,
|
||||
private userRepository: IUserRepository,
|
||||
private guildRepository: IGuildRepositoryAggregate,
|
||||
guildRepository: IGuildRepositoryAggregate,
|
||||
private embedService: EmbedService,
|
||||
private readonly storageService: IStorageService,
|
||||
attachmentUploadTraceRepository: AttachmentUploadTraceRepository,
|
||||
@@ -168,18 +167,10 @@ export class MessagePersistenceService {
|
||||
isBot,
|
||||
dmNsfwContext: params.dmNsfwContext,
|
||||
});
|
||||
let nsfwEmojiIds = new Set<EmojiID>();
|
||||
if (params.content) {
|
||||
if (!isNSFWAllowed) {
|
||||
await this.enforceNsfwEmojiRestrictions(params.content);
|
||||
} else {
|
||||
nsfwEmojiIds = await this.collectNsfwEmojiIds(params.content);
|
||||
}
|
||||
}
|
||||
const [sanitizedContent, attachmentResult, processedStickers] = await Promise.all([
|
||||
this.sanitizeContentIfNeeded(params, authorId),
|
||||
this.processAttachments(params, isNSFWAllowed ? 'allow' : 'block'),
|
||||
this.processStickers(params, authorId, isNSFWAllowed),
|
||||
this.processStickers(params, authorId),
|
||||
]);
|
||||
let messageContent = sanitizedContent;
|
||||
let processedAttachments: Array<MessageAttachment> = params.processedAttachments
|
||||
@@ -242,7 +233,6 @@ export class MessagePersistenceService {
|
||||
? params.messageSnapshots.map((snapshot) => snapshot.toMessageSnapshot())
|
||||
: null,
|
||||
call: null,
|
||||
nsfw_emojis: nsfwEmojiIds.size > 0 ? nsfwEmojiIds : null,
|
||||
has_reaction: false,
|
||||
version: 1,
|
||||
};
|
||||
@@ -305,7 +295,6 @@ export class MessagePersistenceService {
|
||||
private async processStickers(
|
||||
params: CreateMessageParams,
|
||||
authorId: UserID | null,
|
||||
isNSFWAllowed?: boolean,
|
||||
): Promise<Array<MessageStickerItem>> {
|
||||
if (!params.stickerIds || params.stickerIds.length === 0) {
|
||||
return [];
|
||||
@@ -315,37 +304,9 @@ export class MessagePersistenceService {
|
||||
userId: authorId,
|
||||
guildId: params.guildId,
|
||||
hasPermission: params.hasPermission,
|
||||
isNSFWAllowed: isNSFWAllowed ?? true,
|
||||
});
|
||||
}
|
||||
|
||||
private async enforceNsfwEmojiRestrictions(content: string): Promise<void> {
|
||||
const nsfwIds = await this.collectNsfwEmojiIds(content);
|
||||
if (nsfwIds.size > 0) {
|
||||
throw new NsfwEmojiStickerBlockedError();
|
||||
}
|
||||
}
|
||||
|
||||
private async collectNsfwEmojiIds(content: string): Promise<Set<EmojiID>> {
|
||||
const CUSTOM_EMOJI_REGEX = /<a?:[^:]+:(\d+)>/g;
|
||||
const emojiIds = new Set<EmojiID>();
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = CUSTOM_EMOJI_REGEX.exec(content)) !== null) {
|
||||
emojiIds.add(createEmojiID(BigInt(match[1])));
|
||||
}
|
||||
if (emojiIds.size === 0) {
|
||||
return new Set();
|
||||
}
|
||||
const lookups = await Promise.all([...emojiIds].map((id) => this.guildRepository.getEmojiById(id)));
|
||||
const nsfwEmojiIds = new Set<EmojiID>();
|
||||
for (const emoji of lookups) {
|
||||
if (emoji?.isNsfw) {
|
||||
nsfwEmojiIds.add(emoji.id);
|
||||
}
|
||||
}
|
||||
return nsfwEmojiIds;
|
||||
}
|
||||
|
||||
private async runPostPersistenceOperations(context: {
|
||||
message: Message;
|
||||
params: CreateMessageParams;
|
||||
@@ -431,9 +392,6 @@ export class MessagePersistenceService {
|
||||
if (data.content !== undefined && data.content !== message.content) {
|
||||
let sanitizedContent = data.content && hasVisibleContent(data.content) ? data.content : '';
|
||||
if (sanitizedContent) {
|
||||
if (!isNSFWAllowed) {
|
||||
await this.enforceNsfwEmojiRestrictions(sanitizedContent);
|
||||
}
|
||||
sanitizedContent = await this.contentService.sanitizeCustomEmojis({
|
||||
content: sanitizedContent,
|
||||
userId: message.authorId ?? null,
|
||||
|
||||
@@ -96,7 +96,6 @@ function makeMessage(messageId: bigint = 2n, content: string = '', channelId: bi
|
||||
message_reference: null,
|
||||
message_snapshots: null,
|
||||
call: null,
|
||||
nsfw_emojis: null,
|
||||
has_reaction: null,
|
||||
version: 1,
|
||||
});
|
||||
|
||||
@@ -48,7 +48,6 @@ function makeMessage(channelId: ChannelID, messageId: MessageID): Message {
|
||||
message_reference: null,
|
||||
message_snapshots: null,
|
||||
call: null,
|
||||
nsfw_emojis: null,
|
||||
has_reaction: null,
|
||||
version: 1,
|
||||
});
|
||||
|
||||
@@ -18,7 +18,7 @@ import {FeatureTemporarilyDisabledError} from '@fluxer/errors/src/domains/core/F
|
||||
import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError';
|
||||
import {MissingPermissionsError} from '@fluxer/errors/src/domains/core/MissingPermissionsError';
|
||||
import {SlowmodeRateLimitError} from '@fluxer/errors/src/domains/core/SlowmodeRateLimitError';
|
||||
import {NsfwEmojiStickerBlockedError} from '@fluxer/errors/src/domains/moderation/NsfwEmojiStickerBlockedError';
|
||||
import {NsfwContentRequiresAgeVerificationError} from '@fluxer/errors/src/domains/moderation/NsfwContentRequiresAgeVerificationError';
|
||||
import type {GuildMemberResponse} from '@fluxer/schema/src/domains/guild/GuildMemberSchemas';
|
||||
import type {GuildResponse} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas';
|
||||
import {snowflakeToDate} from '@fluxer/snowflake/src/Snowflake';
|
||||
@@ -845,7 +845,7 @@ export class MessageSendService {
|
||||
const guildNsfw = guild != null && guild.nsfw_level === GuildNSFWLevel.AGE_RESTRICTED;
|
||||
const destAllowsNsfw = channel.isNsfw || guildNsfw;
|
||||
if (!destAllowsNsfw) {
|
||||
throw new NsfwEmojiStickerBlockedError();
|
||||
throw new NsfwContentRequiresAgeVerificationError();
|
||||
}
|
||||
}
|
||||
if (data.message_reference && guild && !isForwardMessage) {
|
||||
|
||||
@@ -4,7 +4,6 @@ import {Permissions} from '@fluxer/constants/src/ChannelConstants';
|
||||
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
|
||||
import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError';
|
||||
import {MissingPermissionsError} from '@fluxer/errors/src/domains/core/MissingPermissionsError';
|
||||
import {NsfwEmojiStickerBlockedError} from '@fluxer/errors/src/domains/moderation/NsfwEmojiStickerBlockedError';
|
||||
import type {GuildID, StickerID, UserID} from '../../../BrandedTypes';
|
||||
import type {MessageStickerItem} from '../../../database/types/MessageTypes';
|
||||
import type {IGuildRepositoryAggregate} from '../../../guild/repositories/IGuildRepositoryAggregate';
|
||||
@@ -25,9 +24,8 @@ export class MessageStickerService {
|
||||
userId: UserID | null;
|
||||
guildId: GuildID | null;
|
||||
hasPermission?: (permission: bigint) => Promise<boolean>;
|
||||
isNSFWAllowed?: boolean;
|
||||
}): Promise<Array<MessageStickerItem>> {
|
||||
const {stickerIds, userId, guildId, hasPermission, isNSFWAllowed = true} = params;
|
||||
const {stickerIds, userId, guildId, hasPermission} = params;
|
||||
let hasGlobalExpressions = 0;
|
||||
if (userId) {
|
||||
const user = await this.userRepository.findUnique(userId);
|
||||
@@ -49,26 +47,18 @@ export class MessageStickerService {
|
||||
if (!stickerFromAnyGuild) {
|
||||
throw InputValidationError.fromCode('sticker', ValidationErrorCodes.CUSTOM_STICKER_NOT_FOUND);
|
||||
}
|
||||
if (!isNSFWAllowed && stickerFromAnyGuild.isNsfw) {
|
||||
throw new NsfwEmojiStickerBlockedError();
|
||||
}
|
||||
return {
|
||||
sticker_id: stickerFromAnyGuild.id,
|
||||
name: stickerFromAnyGuild.name,
|
||||
animated: stickerFromAnyGuild.animated,
|
||||
...(stickerFromAnyGuild.isNsfw ? {nsfw: true} : {}),
|
||||
};
|
||||
}
|
||||
const guildSticker = await this.guildRepository.getSticker(stickerId, guildId);
|
||||
if (guildSticker) {
|
||||
if (!isNSFWAllowed && guildSticker.isNsfw) {
|
||||
throw new NsfwEmojiStickerBlockedError();
|
||||
}
|
||||
return {
|
||||
sticker_id: guildSticker.id,
|
||||
name: guildSticker.name,
|
||||
animated: guildSticker.animated,
|
||||
...(guildSticker.isNsfw ? {nsfw: true} : {}),
|
||||
};
|
||||
}
|
||||
const stickerFromOtherGuild = await this.guildRepository.getStickerById(stickerId);
|
||||
@@ -87,14 +77,10 @@ export class MessageStickerService {
|
||||
throw new MissingPermissionsError();
|
||||
}
|
||||
}
|
||||
if (!isNSFWAllowed && stickerFromOtherGuild.isNsfw) {
|
||||
throw new NsfwEmojiStickerBlockedError();
|
||||
}
|
||||
return {
|
||||
sticker_id: stickerFromOtherGuild.id,
|
||||
name: stickerFromOtherGuild.name,
|
||||
animated: stickerFromOtherGuild.animated,
|
||||
...(stickerFromOtherGuild.isNsfw ? {nsfw: true} : {}),
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"billing.eu_withdrawal_waiver_checkout": "إذا كنت مستهلكًا في الاتحاد الأوروبي/المنطقة الاقتصادية الأوروبية، فإنني أوافق صراحةً على تقديم المحتوى الرقمي لـ {product_name} {premium_tier_name} فورًا، وأقرّ بأنني أفقد حقي القانوني في الانسحاب بمجرد تقديم الوصول. لا يؤثر هذا على حقوق المستهلك الإلزامية الأخرى. اطّلع على [شروط الخدمة]({terms_url}).",
|
||||
"bulk_message_deletion.complete": "انتهينا من حذف رسائلك. أزلنا {message_count, plural, =0 {0 رسالة} one {رسالة واحدة} two {رسالتين} few {# رسائل} many {# رسالة} other {# رسالة}} من {channel_count, plural, =0 {0 مكان} one {مكان واحد} two {مكانين} few {# أماكن} many {# مكانًا} other {# مكان}}.",
|
||||
"content.virus_detected": "تم الإبلاغ عن هذا الملف على أنه قد يكون غير آمن وتمت إزالته.",
|
||||
"guild.bulk_create.emoji_limit": "تم الوصول إلى الحد الأقصى لعدد الرموز التعبيرية ({limit}).",
|
||||
"guild.bulk_create.sticker_limit": "تم الوصول إلى الحد الأقصى لعدد الملصقات ({limit}).",
|
||||
"guild.bulk_create.unknown_error": "حدث خطأ غير معروف.",
|
||||
"guild.default_category_text": "القنوات النصية",
|
||||
"guild.default_category_voice": "القنوات الصوتية",
|
||||
"guild.default_channel_text": "عام",
|
||||
"guild.default_channel_voice": "عام"
|
||||
"billing.eu_withdrawal_waiver_checkout": "إذا كنت مستهلكًا في الاتحاد الأوروبي/المنطقة الاقتصادية الأوروبية، فإنني أوافق صراحةً على تقديم المحتوى الرقمي لـ {product_name} {premium_tier_name} فورًا، وأقرّ بأنني أفقد حقي القانوني في الانسحاب بمجرد تقديم الوصول. لا يؤثر هذا على حقوق المستهلك الإلزامية الأخرى. اطّلع على [شروط الخدمة]({terms_url}).",
|
||||
"bulk_message_deletion.complete": "انتهينا من حذف رسائلك. أزلنا {message_count, plural, =0 {0 رسالة} one {رسالة واحدة} two {رسالتين} few {# رسائل} many {# رسالة} other {# رسالة}} من {channel_count, plural, =0 {0 مكان} one {مكان واحد} two {مكانين} few {# أماكن} many {# مكانًا} other {# مكان}}.",
|
||||
"content.virus_detected": "تم الإبلاغ عن هذا الملف على أنه قد يكون غير آمن وتمت إزالته.",
|
||||
"guild.bulk_create.emoji_limit": "تم الوصول إلى الحد الأقصى لعدد الرموز التعبيرية ({limit}).",
|
||||
"guild.bulk_create.sticker_limit": "تم الوصول إلى الحد الأقصى لعدد الملصقات ({limit}).",
|
||||
"guild.bulk_create.unknown_error": "حدث خطأ غير معروف.",
|
||||
"guild.default_category_text": "القنوات النصية",
|
||||
"guild.default_category_voice": "القنوات الصوتية",
|
||||
"guild.default_channel_text": "عام",
|
||||
"guild.default_channel_voice": "عام"
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"billing.eu_withdrawal_waiver_checkout": "Ако съм потребител от ЕС/ЕИП, изрично се съгласявам цифровото съдържание на {product_name} {premium_tier_name} да бъде предоставено незабавно и признавам, че губя законното си право на отказ, след като достъпът бъде предоставен. Това не засяга други задължителни потребителски права. Виж [Условията за ползване]({terms_url}).",
|
||||
"bulk_message_deletion.complete": "Изтриването на съобщенията ти приключи. Премахнахме {message_count, plural, =0 {0 съобщения} one {# съобщение} other {# съобщения}} от {channel_count, plural, =0 {0 места} one {# място} other {# места}}.",
|
||||
"content.virus_detected": "Този файл беше маркиран като потенциално опасен и е премахнат.",
|
||||
"guild.bulk_create.emoji_limit": "Постигнат е максималният брой емотикони ({limit}).",
|
||||
"guild.bulk_create.sticker_limit": "Постигнат е максималният брой стикери ({limit}).",
|
||||
"guild.bulk_create.unknown_error": "Възникна неизвестна грешка.",
|
||||
"guild.default_category_text": "Текстови канали",
|
||||
"guild.default_category_voice": "Гласови канали",
|
||||
"guild.default_channel_text": "общ",
|
||||
"guild.default_channel_voice": "Общ"
|
||||
"billing.eu_withdrawal_waiver_checkout": "Ако съм потребител от ЕС/ЕИП, изрично се съгласявам цифровото съдържание на {product_name} {premium_tier_name} да бъде предоставено незабавно и признавам, че губя законното си право на отказ, след като достъпът бъде предоставен. Това не засяга други задължителни потребителски права. Виж [Условията за ползване]({terms_url}).",
|
||||
"bulk_message_deletion.complete": "Изтриването на съобщенията ти приключи. Премахнахме {message_count, plural, =0 {0 съобщения} one {# съобщение} other {# съобщения}} от {channel_count, plural, =0 {0 места} one {# място} other {# места}}.",
|
||||
"content.virus_detected": "Този файл беше маркиран като потенциално опасен и е премахнат.",
|
||||
"guild.bulk_create.emoji_limit": "Постигнат е максималният брой емотикони ({limit}).",
|
||||
"guild.bulk_create.sticker_limit": "Постигнат е максималният брой стикери ({limit}).",
|
||||
"guild.bulk_create.unknown_error": "Възникна неизвестна грешка.",
|
||||
"guild.default_category_text": "Текстови канали",
|
||||
"guild.default_category_voice": "Гласови канали",
|
||||
"guild.default_channel_text": "общ",
|
||||
"guild.default_channel_voice": "Общ"
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"billing.eu_withdrawal_waiver_checkout": "Pokud jsem spotřebitelem z EU/EHP, výslovně souhlasím s tím, že digitální obsah {product_name} {premium_tier_name} bude poskytnut okamžitě, a beru na vědomí, že po poskytnutí přístupu ztrácím zákonné právo na odstoupení od smlouvy. Tím nejsou dotčena další povinná práva spotřebitele. Viz [Podmínky služby]({terms_url}).",
|
||||
"bulk_message_deletion.complete": "Dokončili jsme mazání tvých zpráv. Odstranili jsme {message_count, plural, =0 {0 zpráv} one {# zprávu} few {# zprávy} many {# zpráv} other {# zpráv}} z {channel_count, plural, =0 {0 míst} one {# místa} few {# míst} many {# míst} other {# míst}}.",
|
||||
"content.virus_detected": "Tento soubor byl označen jako potenciálně nebezpečný a byl odstraněn.",
|
||||
"guild.bulk_create.emoji_limit": "Dosáhl jste maximálního počtu emoji ({limit}).",
|
||||
"guild.bulk_create.sticker_limit": "Dosáhl jste maximálního počtu nálepek ({limit}).",
|
||||
"guild.bulk_create.unknown_error": "Nastala neznámá chyba.",
|
||||
"guild.default_category_text": "Textové kanály",
|
||||
"guild.default_category_voice": "Hlasové kanály",
|
||||
"guild.default_channel_text": "obecný",
|
||||
"guild.default_channel_voice": "Obecný"
|
||||
"billing.eu_withdrawal_waiver_checkout": "Pokud jsem spotřebitelem z EU/EHP, výslovně souhlasím s tím, že digitální obsah {product_name} {premium_tier_name} bude poskytnut okamžitě, a beru na vědomí, že po poskytnutí přístupu ztrácím zákonné právo na odstoupení od smlouvy. Tím nejsou dotčena další povinná práva spotřebitele. Viz [Podmínky služby]({terms_url}).",
|
||||
"bulk_message_deletion.complete": "Dokončili jsme mazání tvých zpráv. Odstranili jsme {message_count, plural, =0 {0 zpráv} one {# zprávu} few {# zprávy} many {# zpráv} other {# zpráv}} z {channel_count, plural, =0 {0 míst} one {# místa} few {# míst} many {# míst} other {# míst}}.",
|
||||
"content.virus_detected": "Tento soubor byl označen jako potenciálně nebezpečný a byl odstraněn.",
|
||||
"guild.bulk_create.emoji_limit": "Dosáhl jste maximálního počtu emoji ({limit}).",
|
||||
"guild.bulk_create.sticker_limit": "Dosáhl jste maximálního počtu nálepek ({limit}).",
|
||||
"guild.bulk_create.unknown_error": "Nastala neznámá chyba.",
|
||||
"guild.default_category_text": "Textové kanály",
|
||||
"guild.default_category_voice": "Hlasové kanály",
|
||||
"guild.default_channel_text": "obecný",
|
||||
"guild.default_channel_voice": "Obecný"
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"billing.eu_withdrawal_waiver_checkout": "Hvis jeg er en EU/EØS-forbruger, giver jeg udtrykkeligt samtykke til, at {product_name}s {premium_tier_name}-abonnement digitalt indhold leveres øjeblikkeligt, og jeg anerkender, at jeg mister min lovbestemte fortrydelsesret, når adgangen gives. Dette påvirker ikke andre ufravigelige forbrugerrettigheder. Se [servicevilkårene]({terms_url}).",
|
||||
"bulk_message_deletion.complete": "Vi er færdige med at slette dine beskeder. Vi fjernede {message_count, plural, =0 {0 beskeder} one {# besked} other {# beskeder}} fra {channel_count, plural, =0 {0 steder} one {# sted} other {# steder}}.",
|
||||
"content.virus_detected": "Den fil blev markeret som potentielt usikker og er blevet fjernet.",
|
||||
"guild.bulk_create.emoji_limit": "Maksimalt antal emoji nået ({limit}).",
|
||||
"guild.bulk_create.sticker_limit": "Maksimalt antal klistermærker nået ({limit}).",
|
||||
"guild.bulk_create.unknown_error": "Ukendt fejl.",
|
||||
"guild.default_category_text": "Tekstkanaler",
|
||||
"guild.default_category_voice": "Stemmekanaler",
|
||||
"guild.default_channel_text": "generelt",
|
||||
"guild.default_channel_voice": "Generelt"
|
||||
"billing.eu_withdrawal_waiver_checkout": "Hvis jeg er en EU/EØS-forbruger, giver jeg udtrykkeligt samtykke til, at {product_name}s {premium_tier_name}-abonnement digitalt indhold leveres øjeblikkeligt, og jeg anerkender, at jeg mister min lovbestemte fortrydelsesret, når adgangen gives. Dette påvirker ikke andre ufravigelige forbrugerrettigheder. Se [servicevilkårene]({terms_url}).",
|
||||
"bulk_message_deletion.complete": "Vi er færdige med at slette dine beskeder. Vi fjernede {message_count, plural, =0 {0 beskeder} one {# besked} other {# beskeder}} fra {channel_count, plural, =0 {0 steder} one {# sted} other {# steder}}.",
|
||||
"content.virus_detected": "Den fil blev markeret som potentielt usikker og er blevet fjernet.",
|
||||
"guild.bulk_create.emoji_limit": "Maksimalt antal emoji nået ({limit}).",
|
||||
"guild.bulk_create.sticker_limit": "Maksimalt antal klistermærker nået ({limit}).",
|
||||
"guild.bulk_create.unknown_error": "Ukendt fejl.",
|
||||
"guild.default_category_text": "Tekstkanaler",
|
||||
"guild.default_category_voice": "Stemmekanaler",
|
||||
"guild.default_channel_text": "generelt",
|
||||
"guild.default_channel_voice": "Generelt"
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"billing.eu_withdrawal_waiver_checkout": "Als EU/EWR-Verbraucher stimmst du ausdrücklich zu, dass {product_name} {premium_tier_name} digitale Inhalte sofort bereitgestellt werden, und erkennst an, dass dein gesetzliches Widerrufsrecht erlischt, sobald der Zugriff gewährt wird. Dies berührt nicht andere zwingende Verbraucherrechte. Sieh dir die [Nutzungsbedingungen]({terms_url}) an.",
|
||||
"bulk_message_deletion.complete": "Wir haben deine Nachrichten gelöscht. Entfernt wurden {message_count, plural, =0 {0 Nachrichten} one {# Nachricht} other {# Nachrichten}} aus {channel_count, plural, =0 {0 Orten} one {# Ort} other {# Orten}}.",
|
||||
"content.virus_detected": "Diese Datei wurde als potenziell unsicher eingestuft und entfernt.",
|
||||
"guild.bulk_create.emoji_limit": "Maximale Anzahl an Emojis erreicht ({limit}).",
|
||||
"guild.bulk_create.sticker_limit": "Maximale Anzahl an Stickern erreicht ({limit}).",
|
||||
"guild.bulk_create.unknown_error": "Unbekannter Fehler.",
|
||||
"guild.default_category_text": "Textkanäle",
|
||||
"guild.default_category_voice": "Sprachkanäle",
|
||||
"guild.default_channel_text": "Allgemein",
|
||||
"guild.default_channel_voice": "Allgemein"
|
||||
"billing.eu_withdrawal_waiver_checkout": "Als EU/EWR-Verbraucher stimmst du ausdrücklich zu, dass {product_name} {premium_tier_name} digitale Inhalte sofort bereitgestellt werden, und erkennst an, dass dein gesetzliches Widerrufsrecht erlischt, sobald der Zugriff gewährt wird. Dies berührt nicht andere zwingende Verbraucherrechte. Sieh dir die [Nutzungsbedingungen]({terms_url}) an.",
|
||||
"bulk_message_deletion.complete": "Wir haben deine Nachrichten gelöscht. Entfernt wurden {message_count, plural, =0 {0 Nachrichten} one {# Nachricht} other {# Nachrichten}} aus {channel_count, plural, =0 {0 Orten} one {# Ort} other {# Orten}}.",
|
||||
"content.virus_detected": "Diese Datei wurde als potenziell unsicher eingestuft und entfernt.",
|
||||
"guild.bulk_create.emoji_limit": "Maximale Anzahl an Emojis erreicht ({limit}).",
|
||||
"guild.bulk_create.sticker_limit": "Maximale Anzahl an Stickern erreicht ({limit}).",
|
||||
"guild.bulk_create.unknown_error": "Unbekannter Fehler.",
|
||||
"guild.default_category_text": "Textkanäle",
|
||||
"guild.default_category_voice": "Sprachkanäle",
|
||||
"guild.default_channel_text": "Allgemein",
|
||||
"guild.default_channel_voice": "Allgemein"
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"billing.eu_withdrawal_waiver_checkout": "Αν είμαι καταναλωτής ΕΕ/ΕΟΧ, συναινώ ρητά στην άμεση παροχή του ψηφιακού περιεχομένου του πλάνου {premium_tier_name} του {product_name} και αναγνωρίζω ότι χάνω το νόμιμο δικαίωμα υπαναχώρησης μόλις παρασχεθεί η πρόσβαση. Αυτό δεν επηρεάζει άλλα υποχρεωτικά δικαιώματα των καταναλωτών. Ανατρέξτε στους [Όρους Παροχής Υπηρεσιών]({terms_url}).",
|
||||
"bulk_message_deletion.complete": "Ολοκληρώσαμε τη διαγραφή των μηνυμάτων σου. Αφαιρέσαμε {message_count, plural, =0 {0 μηνύματα} one {# μήνυμα} other {# μηνύματα}} από {channel_count, plural, =0 {0 τοποθεσίες} one {# τοποθεσία} other {# τοποθεσίες}}.",
|
||||
"content.virus_detected": "Αυτό το αρχείο επισημάνθηκε ως δυνητικά μη ασφαλές και έχει αφαιρεθεί.",
|
||||
"guild.bulk_create.emoji_limit": "Επιτεύχθηκε το μέγιστο όριο emoji ({limit}).",
|
||||
"guild.bulk_create.sticker_limit": "Έφτασε το μέγιστο όριο αυτοκόλλητων ({limit}).",
|
||||
"guild.bulk_create.unknown_error": "Άγνωστο σφάλμα.",
|
||||
"guild.default_category_text": "Κανάλια κειμένου",
|
||||
"guild.default_category_voice": "Φωνητικά κανάλια",
|
||||
"guild.default_channel_text": "γενικό",
|
||||
"guild.default_channel_voice": "Γενικό"
|
||||
"billing.eu_withdrawal_waiver_checkout": "Αν είμαι καταναλωτής ΕΕ/ΕΟΧ, συναινώ ρητά στην άμεση παροχή του ψηφιακού περιεχομένου του πλάνου {premium_tier_name} του {product_name} και αναγνωρίζω ότι χάνω το νόμιμο δικαίωμα υπαναχώρησης μόλις παρασχεθεί η πρόσβαση. Αυτό δεν επηρεάζει άλλα υποχρεωτικά δικαιώματα των καταναλωτών. Ανατρέξτε στους [Όρους Παροχής Υπηρεσιών]({terms_url}).",
|
||||
"bulk_message_deletion.complete": "Ολοκληρώσαμε τη διαγραφή των μηνυμάτων σου. Αφαιρέσαμε {message_count, plural, =0 {0 μηνύματα} one {# μήνυμα} other {# μηνύματα}} από {channel_count, plural, =0 {0 τοποθεσίες} one {# τοποθεσία} other {# τοποθεσίες}}.",
|
||||
"content.virus_detected": "Αυτό το αρχείο επισημάνθηκε ως δυνητικά μη ασφαλές και έχει αφαιρεθεί.",
|
||||
"guild.bulk_create.emoji_limit": "Επιτεύχθηκε το μέγιστο όριο emoji ({limit}).",
|
||||
"guild.bulk_create.sticker_limit": "Έφτασε το μέγιστο όριο αυτοκόλλητων ({limit}).",
|
||||
"guild.bulk_create.unknown_error": "Άγνωστο σφάλμα.",
|
||||
"guild.default_category_text": "Κανάλια κειμένου",
|
||||
"guild.default_category_voice": "Φωνητικά κανάλια",
|
||||
"guild.default_channel_text": "γενικό",
|
||||
"guild.default_channel_voice": "Γενικό"
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"billing.eu_withdrawal_waiver_checkout": "If I am an EU/EEA consumer, I expressly consent to {product_name} {premium_tier_name} digital content being provided immediately and acknowledge that I lose my statutory withdrawal right once access is provided. This does not affect other mandatory consumer rights. See the [Terms of Service]({terms_url}).",
|
||||
"bulk_message_deletion.complete": "We've finished deleting your messages. We removed {message_count, plural, =0 {0 messages} one {# message} other {# messages}} from {channel_count, plural, =0 {0 places} one {# place} other {# places}}.",
|
||||
"content.virus_detected": "That file was flagged as potentially unsafe and has been removed.",
|
||||
"guild.bulk_create.emoji_limit": "Maximum emojis reached ({limit}).",
|
||||
"guild.bulk_create.sticker_limit": "Maximum stickers reached ({limit}).",
|
||||
"guild.bulk_create.unknown_error": "Unknown error.",
|
||||
"guild.default_category_text": "Text channels",
|
||||
"guild.default_category_voice": "Voice channels",
|
||||
"guild.default_channel_text": "general",
|
||||
"guild.default_channel_voice": "General"
|
||||
"billing.eu_withdrawal_waiver_checkout": "If I am an EU/EEA consumer, I expressly consent to {product_name} {premium_tier_name} digital content being provided immediately and acknowledge that I lose my statutory withdrawal right once access is provided. This does not affect other mandatory consumer rights. See the [Terms of Service]({terms_url}).",
|
||||
"bulk_message_deletion.complete": "We've finished deleting your messages. We removed {message_count, plural, =0 {0 messages} one {# message} other {# messages}} from {channel_count, plural, =0 {0 places} one {# place} other {# places}}.",
|
||||
"content.virus_detected": "That file was flagged as potentially unsafe and has been removed.",
|
||||
"guild.bulk_create.emoji_limit": "Maximum emojis reached ({limit}).",
|
||||
"guild.bulk_create.sticker_limit": "Maximum stickers reached ({limit}).",
|
||||
"guild.bulk_create.unknown_error": "Unknown error.",
|
||||
"guild.default_category_text": "Text channels",
|
||||
"guild.default_category_voice": "Voice channels",
|
||||
"guild.default_channel_text": "general",
|
||||
"guild.default_channel_voice": "General"
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"billing.eu_withdrawal_waiver_checkout": "Si soy un consumidor de la UE/EEE, doy mi consentimiento expreso para que el contenido digital de {product_name} {premium_tier_name} se proporcione de inmediato y reconozco que pierdo mi derecho legal de desistimiento una vez que se otorgue el acceso. Esto no afecta otros derechos de consumo obligatorios. Consulta los [Términos de servicio]({terms_url}).",
|
||||
"bulk_message_deletion.complete": "Terminamos de eliminar tus mensajes. Eliminamos {message_count, plural, =0 {0 mensajes} one {# mensaje} other {# mensajes}} de {channel_count, plural, =0 {0 lugares} one {# lugar} other {# lugares}}.",
|
||||
"content.virus_detected": "Ese archivo fue marcado como potencialmente inseguro y se ha eliminado.",
|
||||
"guild.bulk_create.emoji_limit": "Se alcanzó el límite máximo de emojis ({limit}).",
|
||||
"guild.bulk_create.sticker_limit": "Se alcanzó el límite máximo de stickers ({limit}).",
|
||||
"guild.bulk_create.unknown_error": "Error desconocido.",
|
||||
"guild.default_category_text": "Canales de texto",
|
||||
"guild.default_category_voice": "Canales de voz",
|
||||
"guild.default_channel_text": "general",
|
||||
"guild.default_channel_voice": "General"
|
||||
"billing.eu_withdrawal_waiver_checkout": "Si soy un consumidor de la UE/EEE, doy mi consentimiento expreso para que el contenido digital de {product_name} {premium_tier_name} se proporcione de inmediato y reconozco que pierdo mi derecho legal de desistimiento una vez que se otorgue el acceso. Esto no afecta otros derechos de consumo obligatorios. Consulta los [Términos de servicio]({terms_url}).",
|
||||
"bulk_message_deletion.complete": "Terminamos de eliminar tus mensajes. Eliminamos {message_count, plural, =0 {0 mensajes} one {# mensaje} other {# mensajes}} de {channel_count, plural, =0 {0 lugares} one {# lugar} other {# lugares}}.",
|
||||
"content.virus_detected": "Ese archivo fue marcado como potencialmente inseguro y se ha eliminado.",
|
||||
"guild.bulk_create.emoji_limit": "Se alcanzó el límite máximo de emojis ({limit}).",
|
||||
"guild.bulk_create.sticker_limit": "Se alcanzó el límite máximo de stickers ({limit}).",
|
||||
"guild.bulk_create.unknown_error": "Error desconocido.",
|
||||
"guild.default_category_text": "Canales de texto",
|
||||
"guild.default_category_voice": "Canales de voz",
|
||||
"guild.default_channel_text": "general",
|
||||
"guild.default_channel_voice": "General"
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"billing.eu_withdrawal_waiver_checkout": "Si soy un consumidor de la UE/EEE, doy mi consentimiento expreso para que el contenido digital de {product_name} {premium_tier_name} se proporcione de inmediato y reconozco que pierdo mi derecho legal de desistimiento una vez que se conceda el acceso. Esto no afecta a otros derechos de consumo obligatorios. Consulta los [Términos del servicio]({terms_url}).",
|
||||
"bulk_message_deletion.complete": "Hemos terminado de eliminar tus mensajes. Hemos eliminado {message_count, plural, =0 {0 mensajes} one {# mensaje} other {# mensajes}} de {channel_count, plural, =0 {0 sitios} one {# sitio} other {# sitios}}.",
|
||||
"content.virus_detected": "Ese archivo se ha marcado como potencialmente inseguro y se ha eliminado.",
|
||||
"guild.bulk_create.emoji_limit": "Se ha alcanzado el límite máximo de emojis ({limit}).",
|
||||
"guild.bulk_create.sticker_limit": "Se ha alcanzado el límite máximo de stickers ({limit}).",
|
||||
"guild.bulk_create.unknown_error": "Error desconocido.",
|
||||
"guild.default_category_text": "Canales de texto",
|
||||
"guild.default_category_voice": "Canales de voz",
|
||||
"guild.default_channel_text": "general",
|
||||
"guild.default_channel_voice": "General"
|
||||
"billing.eu_withdrawal_waiver_checkout": "Si soy un consumidor de la UE/EEE, doy mi consentimiento expreso para que el contenido digital de {product_name} {premium_tier_name} se proporcione de inmediato y reconozco que pierdo mi derecho legal de desistimiento una vez que se conceda el acceso. Esto no afecta a otros derechos de consumo obligatorios. Consulta los [Términos del servicio]({terms_url}).",
|
||||
"bulk_message_deletion.complete": "Hemos terminado de eliminar tus mensajes. Hemos eliminado {message_count, plural, =0 {0 mensajes} one {# mensaje} other {# mensajes}} de {channel_count, plural, =0 {0 sitios} one {# sitio} other {# sitios}}.",
|
||||
"content.virus_detected": "Ese archivo se ha marcado como potencialmente inseguro y se ha eliminado.",
|
||||
"guild.bulk_create.emoji_limit": "Se ha alcanzado el límite máximo de emojis ({limit}).",
|
||||
"guild.bulk_create.sticker_limit": "Se ha alcanzado el límite máximo de stickers ({limit}).",
|
||||
"guild.bulk_create.unknown_error": "Error desconocido.",
|
||||
"guild.default_category_text": "Canales de texto",
|
||||
"guild.default_category_voice": "Canales de voz",
|
||||
"guild.default_channel_text": "general",
|
||||
"guild.default_channel_voice": "General"
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"billing.eu_withdrawal_waiver_checkout": "Jos olen EU/ETA-kuluttaja, annan nimenomaisen suostumukseni sille, että {product_name}-tuotteen {premium_tier_name} digitaalinen sisältö toimitetaan välittömästi, ja hyväksyn, että menetän lakisääteisen peruutusoikeuteni, kun pääsy on myönnetty. Tämä ei vaikuta muihin pakollisiin kuluttajaoikeuksiin. Katso [käyttöehdot]({terms_url}).",
|
||||
"bulk_message_deletion.complete": "Olemme poistaneet viestisi. Poistimme {message_count, plural, =0 {0 viestiä} one {# viestin} other {# viestiä}} {channel_count, plural, =0 {0 paikasta} one {# paikasta} other {# paikasta}}.",
|
||||
"content.virus_detected": "Tiedosto merkittiin mahdollisesti vaaralliseksi ja se on poistettu.",
|
||||
"guild.bulk_create.emoji_limit": "Enimmäismäärä emoji-kuvakkeita on saavutettu ({limit}).",
|
||||
"guild.bulk_create.sticker_limit": "Saavutettu enimmäismäärä tarroja ({limit}).",
|
||||
"guild.bulk_create.unknown_error": "Tuntematon virhe.",
|
||||
"guild.default_category_text": "Tekstikanavat",
|
||||
"guild.default_category_voice": "Äänikanavat",
|
||||
"guild.default_channel_text": "yleinen",
|
||||
"guild.default_channel_voice": "Yleinen"
|
||||
"billing.eu_withdrawal_waiver_checkout": "Jos olen EU/ETA-kuluttaja, annan nimenomaisen suostumukseni sille, että {product_name}-tuotteen {premium_tier_name} digitaalinen sisältö toimitetaan välittömästi, ja hyväksyn, että menetän lakisääteisen peruutusoikeuteni, kun pääsy on myönnetty. Tämä ei vaikuta muihin pakollisiin kuluttajaoikeuksiin. Katso [käyttöehdot]({terms_url}).",
|
||||
"bulk_message_deletion.complete": "Olemme poistaneet viestisi. Poistimme {message_count, plural, =0 {0 viestiä} one {# viestin} other {# viestiä}} {channel_count, plural, =0 {0 paikasta} one {# paikasta} other {# paikasta}}.",
|
||||
"content.virus_detected": "Tiedosto merkittiin mahdollisesti vaaralliseksi ja se on poistettu.",
|
||||
"guild.bulk_create.emoji_limit": "Enimmäismäärä emoji-kuvakkeita on saavutettu ({limit}).",
|
||||
"guild.bulk_create.sticker_limit": "Saavutettu enimmäismäärä tarroja ({limit}).",
|
||||
"guild.bulk_create.unknown_error": "Tuntematon virhe.",
|
||||
"guild.default_category_text": "Tekstikanavat",
|
||||
"guild.default_category_voice": "Äänikanavat",
|
||||
"guild.default_channel_text": "yleinen",
|
||||
"guild.default_channel_voice": "Yleinen"
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"billing.eu_withdrawal_waiver_checkout": "En tant que consommateur UE/EEE, je consens expressément à ce que le contenu numérique {product_name} {premium_tier_name} soit fourni immédiatement et reconnais perdre mon droit de rétractation légal dès l'accès fourni. Cela n'affecte pas les autres droits impératifs des consommateurs. Voir les [Conditions d'utilisation]({terms_url}).",
|
||||
"bulk_message_deletion.complete": "Nous avons terminé la suppression de tes messages. Nous avons retiré {message_count, plural, =0 {0 message} one {# message} other {# messages}} dans {channel_count, plural, =0 {0 endroit} one {# endroit} other {# endroits}}.",
|
||||
"content.virus_detected": "Ce fichier a été signalé comme potentiellement dangereux et a été supprimé.",
|
||||
"guild.bulk_create.emoji_limit": "Nombre maximal d’émojis atteint ({limit}).",
|
||||
"guild.bulk_create.sticker_limit": "Nombre maximal d’autocollants atteint ({limit}).",
|
||||
"guild.bulk_create.unknown_error": "Erreur inconnue.",
|
||||
"guild.default_category_text": "Salons textuels",
|
||||
"guild.default_category_voice": "Salons vocaux",
|
||||
"guild.default_channel_text": "général",
|
||||
"guild.default_channel_voice": "Général"
|
||||
"billing.eu_withdrawal_waiver_checkout": "En tant que consommateur UE/EEE, je consens expressément à ce que le contenu numérique {product_name} {premium_tier_name} soit fourni immédiatement et reconnais perdre mon droit de rétractation légal dès l'accès fourni. Cela n'affecte pas les autres droits impératifs des consommateurs. Voir les [Conditions d'utilisation]({terms_url}).",
|
||||
"bulk_message_deletion.complete": "Nous avons terminé la suppression de tes messages. Nous avons retiré {message_count, plural, =0 {0 message} one {# message} other {# messages}} dans {channel_count, plural, =0 {0 endroit} one {# endroit} other {# endroits}}.",
|
||||
"content.virus_detected": "Ce fichier a été signalé comme potentiellement dangereux et a été supprimé.",
|
||||
"guild.bulk_create.emoji_limit": "Nombre maximal d’émojis atteint ({limit}).",
|
||||
"guild.bulk_create.sticker_limit": "Nombre maximal d’autocollants atteint ({limit}).",
|
||||
"guild.bulk_create.unknown_error": "Erreur inconnue.",
|
||||
"guild.default_category_text": "Salons textuels",
|
||||
"guild.default_category_voice": "Salons vocaux",
|
||||
"guild.default_channel_text": "général",
|
||||
"guild.default_channel_voice": "Général"
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"billing.eu_withdrawal_waiver_checkout": "אם אני צרכן באיחוד האירופי/EEA, אני מסכים במפורש לכך שתוכן דיגיטלי של {product_name} {premium_tier_name} יסופק באופן מיידי ומאשר שאני מאבד את זכות הביטול הסטטוטורית שלי מרגע שהגישה ניתנת. אין בכך כדי לפגוע בזכויות צרכניות מחייבות אחרות. ראו [תנאי השירות]({terms_url}).",
|
||||
"bulk_message_deletion.complete": "סיימנו למחוק את ההודעות שלך. הסרנו {message_count, plural, =0 {0 הודעות} one {הודעה אחת} two {שתי הודעות} other {# הודעות}} מתוך {channel_count, plural, =0 {0 מקומות} one {מקום אחד} two {שני מקומות} other {# מקומות}}.",
|
||||
"content.virus_detected": "הקובץ הזה סומן כחשוד והוסר.",
|
||||
"guild.bulk_create.emoji_limit": "הגעת למספר האמוג'י המקסימלי ({limit}).",
|
||||
"guild.bulk_create.sticker_limit": "הגעת למספר המדבקות המקסימלי ({limit}).",
|
||||
"guild.bulk_create.unknown_error": "שגיאה לא ידועה.",
|
||||
"guild.default_category_text": "ערוצי טקסט",
|
||||
"guild.default_category_voice": "ערוצים קוליים",
|
||||
"guild.default_channel_text": "כללי",
|
||||
"guild.default_channel_voice": "כללי"
|
||||
"billing.eu_withdrawal_waiver_checkout": "אם אני צרכן באיחוד האירופי/EEA, אני מסכים במפורש לכך שתוכן דיגיטלי של {product_name} {premium_tier_name} יסופק באופן מיידי ומאשר שאני מאבד את זכות הביטול הסטטוטורית שלי מרגע שהגישה ניתנת. אין בכך כדי לפגוע בזכויות צרכניות מחייבות אחרות. ראו [תנאי השירות]({terms_url}).",
|
||||
"bulk_message_deletion.complete": "סיימנו למחוק את ההודעות שלך. הסרנו {message_count, plural, =0 {0 הודעות} one {הודעה אחת} two {שתי הודעות} other {# הודעות}} מתוך {channel_count, plural, =0 {0 מקומות} one {מקום אחד} two {שני מקומות} other {# מקומות}}.",
|
||||
"content.virus_detected": "הקובץ הזה סומן כחשוד והוסר.",
|
||||
"guild.bulk_create.emoji_limit": "הגעת למספר האמוג'י המקסימלי ({limit}).",
|
||||
"guild.bulk_create.sticker_limit": "הגעת למספר המדבקות המקסימלי ({limit}).",
|
||||
"guild.bulk_create.unknown_error": "שגיאה לא ידועה.",
|
||||
"guild.default_category_text": "ערוצי טקסט",
|
||||
"guild.default_category_voice": "ערוצים קוליים",
|
||||
"guild.default_channel_text": "כללי",
|
||||
"guild.default_channel_voice": "כללי"
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"billing.eu_withdrawal_waiver_checkout": "अगर मैं EU/EEA का उपभोक्ता हूँ, तो मैं {product_name} {premium_tier_name} डिजिटल कंटेंट को तुरंत उपलब्ध कराने के लिए स्पष्ट रूप से सहमति देता हूँ और स्वीकार करता हूँ कि एक्सेस मिलने के बाद मेरा वैधानिक वापसी का अधिकार समाप्त हो जाता है। इससे अन्य अनिवार्य उपभोक्ता अधिकार प्रभावित नहीं होते। [सेवा की शर्तें]({terms_url}) देखें।",
|
||||
"bulk_message_deletion.complete": "हमने आपके संदेश हटाने का काम पूरा कर लिया है। हमने {channel_count, plural, =0 {0 जगहों से} one {# जगह से} other {# जगहों से}} {message_count, plural, =0 {0 संदेश} one {# संदेश} other {# संदेश}} हटा दिए।",
|
||||
"content.virus_detected": "वह फाइल संभावित रूप से असुरक्षित पाई गई और उसे हटा दिया गया है।",
|
||||
"guild.bulk_create.emoji_limit": "अधिकतम इमोजी की सीमा पहुँच गई ({limit})।",
|
||||
"guild.bulk_create.sticker_limit": "अधिकतम स्टिकर की संख्या पूरी हो गई ({limit})।",
|
||||
"guild.bulk_create.unknown_error": "अज्ञात त्रुटि।",
|
||||
"guild.default_category_text": "टेक्स्ट चैनल",
|
||||
"guild.default_category_voice": "वॉइस चैनल",
|
||||
"guild.default_channel_text": "जनरल",
|
||||
"guild.default_channel_voice": "जनरल"
|
||||
"billing.eu_withdrawal_waiver_checkout": "अगर मैं EU/EEA का उपभोक्ता हूँ, तो मैं {product_name} {premium_tier_name} डिजिटल कंटेंट को तुरंत उपलब्ध कराने के लिए स्पष्ट रूप से सहमति देता हूँ और स्वीकार करता हूँ कि एक्सेस मिलने के बाद मेरा वैधानिक वापसी का अधिकार समाप्त हो जाता है। इससे अन्य अनिवार्य उपभोक्ता अधिकार प्रभावित नहीं होते। [सेवा की शर्तें]({terms_url}) देखें।",
|
||||
"bulk_message_deletion.complete": "हमने आपके संदेश हटाने का काम पूरा कर लिया है। हमने {channel_count, plural, =0 {0 जगहों से} one {# जगह से} other {# जगहों से}} {message_count, plural, =0 {0 संदेश} one {# संदेश} other {# संदेश}} हटा दिए।",
|
||||
"content.virus_detected": "वह फाइल संभावित रूप से असुरक्षित पाई गई और उसे हटा दिया गया है।",
|
||||
"guild.bulk_create.emoji_limit": "अधिकतम इमोजी की सीमा पहुँच गई ({limit})।",
|
||||
"guild.bulk_create.sticker_limit": "अधिकतम स्टिकर की संख्या पूरी हो गई ({limit})।",
|
||||
"guild.bulk_create.unknown_error": "अज्ञात त्रुटि।",
|
||||
"guild.default_category_text": "टेक्स्ट चैनल",
|
||||
"guild.default_category_voice": "वॉइस चैनल",
|
||||
"guild.default_channel_text": "जनरल",
|
||||
"guild.default_channel_voice": "जनरल"
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"billing.eu_withdrawal_waiver_checkout": "Ako si potrošač iz EU/EEA, izričito pristaješ na trenutačnu isporuku digitalnog sadržaja {product_name} pretplate {premium_tier_name} i prihvaćaš da gubiš zakonsko pravo na odustanak čim ti se omogući pristup. To ne utječe na ostala obvezna potrošačka prava. Pogledaj [Uvjete pružanja usluge]({terms_url}).",
|
||||
"bulk_message_deletion.complete": "Završili smo s brisanjem tvojih poruka. Uklonili smo {message_count, plural, =0 {0 poruka} one {# poruku} few {# poruke} other {# poruka}} iz {channel_count, plural, =0 {0 mjesta} one {# mjesta} few {# mjesta} other {# mjesta}}.",
|
||||
"content.virus_detected": "Ta je datoteka označena kao potencijalno nesigurna i uklonjena je.",
|
||||
"guild.bulk_create.emoji_limit": "Dostignut maksimalan broj emojija ({limit}).",
|
||||
"guild.bulk_create.sticker_limit": "Dostignut maksimalan broj naljepnica ({limit}).",
|
||||
"guild.bulk_create.unknown_error": "Nešto je pošlo po zlu.",
|
||||
"guild.default_category_text": "Tekstualni kanali",
|
||||
"guild.default_category_voice": "Glasovni kanali",
|
||||
"guild.default_channel_text": "općenito",
|
||||
"guild.default_channel_voice": "Općenito"
|
||||
"billing.eu_withdrawal_waiver_checkout": "Ako si potrošač iz EU/EEA, izričito pristaješ na trenutačnu isporuku digitalnog sadržaja {product_name} pretplate {premium_tier_name} i prihvaćaš da gubiš zakonsko pravo na odustanak čim ti se omogući pristup. To ne utječe na ostala obvezna potrošačka prava. Pogledaj [Uvjete pružanja usluge]({terms_url}).",
|
||||
"bulk_message_deletion.complete": "Završili smo s brisanjem tvojih poruka. Uklonili smo {message_count, plural, =0 {0 poruka} one {# poruku} few {# poruke} other {# poruka}} iz {channel_count, plural, =0 {0 mjesta} one {# mjesta} few {# mjesta} other {# mjesta}}.",
|
||||
"content.virus_detected": "Ta je datoteka označena kao potencijalno nesigurna i uklonjena je.",
|
||||
"guild.bulk_create.emoji_limit": "Dostignut maksimalan broj emojija ({limit}).",
|
||||
"guild.bulk_create.sticker_limit": "Dostignut maksimalan broj naljepnica ({limit}).",
|
||||
"guild.bulk_create.unknown_error": "Nešto je pošlo po zlu.",
|
||||
"guild.default_category_text": "Tekstualni kanali",
|
||||
"guild.default_category_voice": "Glasovni kanali",
|
||||
"guild.default_channel_text": "općenito",
|
||||
"guild.default_channel_voice": "Općenito"
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"billing.eu_withdrawal_waiver_checkout": "Ha EU/EGT-fogyasztó vagyok, kifejezetten hozzájárulok ahhoz, hogy a {product_name} {premium_tier_name} digitális tartalmat azonnal biztosítsák, és tudomásul veszem, hogy a hozzáférés megadásával elveszítem a törvényes elállási jogomat. Ez nem érinti a többi kötelező fogyasztói jogot. Lásd a [Felhasználási feltételeket]({terms_url}).",
|
||||
"bulk_message_deletion.complete": "Befejeztük az üzeneteid törlését. Eltávolítottunk {message_count, plural, =0 {0 üzenetet} one {# üzenetet} other {# üzenetet}} {channel_count, plural, =0 {0 helyről} one {# helyről} other {# helyről}}.",
|
||||
"content.virus_detected": "A fájl potenciálisan veszélyesnek minősült, ezért eltávolítottuk.",
|
||||
"guild.bulk_create.emoji_limit": "A maximális emoji szám elérve ({limit}).",
|
||||
"guild.bulk_create.sticker_limit": "A maximális matricák száma elérve ({limit}).",
|
||||
"guild.bulk_create.unknown_error": "Ismeretlen hiba.",
|
||||
"guild.default_category_text": "Szöveges Csatornák",
|
||||
"guild.default_category_voice": "Hangcsatornák",
|
||||
"guild.default_channel_text": "általános",
|
||||
"guild.default_channel_voice": "Általános"
|
||||
"billing.eu_withdrawal_waiver_checkout": "Ha EU/EGT-fogyasztó vagyok, kifejezetten hozzájárulok ahhoz, hogy a {product_name} {premium_tier_name} digitális tartalmat azonnal biztosítsák, és tudomásul veszem, hogy a hozzáférés megadásával elveszítem a törvényes elállási jogomat. Ez nem érinti a többi kötelező fogyasztói jogot. Lásd a [Felhasználási feltételeket]({terms_url}).",
|
||||
"bulk_message_deletion.complete": "Befejeztük az üzeneteid törlését. Eltávolítottunk {message_count, plural, =0 {0 üzenetet} one {# üzenetet} other {# üzenetet}} {channel_count, plural, =0 {0 helyről} one {# helyről} other {# helyről}}.",
|
||||
"content.virus_detected": "A fájl potenciálisan veszélyesnek minősült, ezért eltávolítottuk.",
|
||||
"guild.bulk_create.emoji_limit": "A maximális emoji szám elérve ({limit}).",
|
||||
"guild.bulk_create.sticker_limit": "A maximális matricák száma elérve ({limit}).",
|
||||
"guild.bulk_create.unknown_error": "Ismeretlen hiba.",
|
||||
"guild.default_category_text": "Szöveges Csatornák",
|
||||
"guild.default_category_voice": "Hangcsatornák",
|
||||
"guild.default_channel_text": "általános",
|
||||
"guild.default_channel_voice": "Általános"
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"billing.eu_withdrawal_waiver_checkout": "Jika saya konsumen EU/EEA, saya setuju konten digital {product_name} paket {premium_tier_name} diberikan segera dan mengakui bahwa saya kehilangan hak pembatalan begitu akses diberikan. Ini tidak memengaruhi hak konsumen wajib lainnya. Lihat [Ketentuan Layanan]({terms_url}).",
|
||||
"bulk_message_deletion.complete": "Kami sudah selesai menghapus pesanmu. Kami menghapus {message_count, plural, =0 {0 pesan} other {# pesan}} dari {channel_count, plural, =0 {0 tempat} other {# tempat}}.",
|
||||
"content.virus_detected": "File itu ditandai sebagai tidak aman dan berbahaya dan sudah dihapus.",
|
||||
"guild.bulk_create.emoji_limit": "Maksimal emoji telah mencapai ({limit}).",
|
||||
"guild.bulk_create.sticker_limit": "Maksimal stiker sudah mencapai ({limit}).",
|
||||
"guild.bulk_create.unknown_error": "Kesalahan tidak diketahui.",
|
||||
"guild.default_category_text": "Saluran teks",
|
||||
"guild.default_category_voice": "Saluran suara",
|
||||
"guild.default_channel_text": "umum",
|
||||
"guild.default_channel_voice": "Umum"
|
||||
"billing.eu_withdrawal_waiver_checkout": "Jika saya konsumen EU/EEA, saya setuju konten digital {product_name} paket {premium_tier_name} diberikan segera dan mengakui bahwa saya kehilangan hak pembatalan begitu akses diberikan. Ini tidak memengaruhi hak konsumen wajib lainnya. Lihat [Ketentuan Layanan]({terms_url}).",
|
||||
"bulk_message_deletion.complete": "Kami sudah selesai menghapus pesanmu. Kami menghapus {message_count, plural, =0 {0 pesan} other {# pesan}} dari {channel_count, plural, =0 {0 tempat} other {# tempat}}.",
|
||||
"content.virus_detected": "File itu ditandai sebagai tidak aman dan berbahaya dan sudah dihapus.",
|
||||
"guild.bulk_create.emoji_limit": "Maksimal emoji telah mencapai ({limit}).",
|
||||
"guild.bulk_create.sticker_limit": "Maksimal stiker sudah mencapai ({limit}).",
|
||||
"guild.bulk_create.unknown_error": "Kesalahan tidak diketahui.",
|
||||
"guild.default_category_text": "Saluran teks",
|
||||
"guild.default_category_voice": "Saluran suara",
|
||||
"guild.default_channel_text": "umum",
|
||||
"guild.default_channel_voice": "Umum"
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"billing.eu_withdrawal_waiver_checkout": "Se sono un consumatore UE/SEE, acconsento espressamente alla fornitura immediata del contenuto digitale {product_name} {premium_tier_name} e riconosco di perdere il mio diritto di recesso legale una volta che l'accesso viene fornito. Ciò non pregiudica altri diritti dei consumatori previsti dalla legge. Vedi i [Termini di servizio]({terms_url}).",
|
||||
"bulk_message_deletion.complete": "Abbiamo finito di eliminare i tuoi messaggi. Abbiamo rimosso {message_count, plural, =0 {0 messaggi} one {# messaggio} other {# messaggi}} da {channel_count, plural, =0 {0 posti} one {# posto} other {# posti}}.",
|
||||
"content.virus_detected": "Il file è stato segnalato come potenzialmente pericoloso ed è stato rimosso.",
|
||||
"guild.bulk_create.emoji_limit": "Raggiunto il numero massimo di emoji ({limit}).",
|
||||
"guild.bulk_create.sticker_limit": "Raggiunto il numero massimo di sticker ({limit}).",
|
||||
"guild.bulk_create.unknown_error": "Si è verificato un errore sconosciuto.",
|
||||
"guild.default_category_text": "Canali testuali",
|
||||
"guild.default_category_voice": "Canali vocali",
|
||||
"guild.default_channel_text": "generale",
|
||||
"guild.default_channel_voice": "Generale"
|
||||
"billing.eu_withdrawal_waiver_checkout": "Se sono un consumatore UE/SEE, acconsento espressamente alla fornitura immediata del contenuto digitale {product_name} {premium_tier_name} e riconosco di perdere il mio diritto di recesso legale una volta che l'accesso viene fornito. Ciò non pregiudica altri diritti dei consumatori previsti dalla legge. Vedi i [Termini di servizio]({terms_url}).",
|
||||
"bulk_message_deletion.complete": "Abbiamo finito di eliminare i tuoi messaggi. Abbiamo rimosso {message_count, plural, =0 {0 messaggi} one {# messaggio} other {# messaggi}} da {channel_count, plural, =0 {0 posti} one {# posto} other {# posti}}.",
|
||||
"content.virus_detected": "Il file è stato segnalato come potenzialmente pericoloso ed è stato rimosso.",
|
||||
"guild.bulk_create.emoji_limit": "Raggiunto il numero massimo di emoji ({limit}).",
|
||||
"guild.bulk_create.sticker_limit": "Raggiunto il numero massimo di sticker ({limit}).",
|
||||
"guild.bulk_create.unknown_error": "Si è verificato un errore sconosciuto.",
|
||||
"guild.default_category_text": "Canali testuali",
|
||||
"guild.default_category_voice": "Canali vocali",
|
||||
"guild.default_channel_text": "generale",
|
||||
"guild.default_channel_voice": "Generale"
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"billing.eu_withdrawal_waiver_checkout": "私が EU/EEA の消費者である場合、{product_name} {premium_tier_name} のデジタルコンテンツが直ちに提供されることに明示的に同意し、アクセスが提供された時点で法定のクーリングオフ権を失うことを承諾します。これはその他の強制的な消費者権利に影響を与えません。[利用規約]({terms_url})をご確認ください。",
|
||||
"bulk_message_deletion.complete": "メッセージの削除が完了しました。{channel_count, plural, =0 {0 件の場所から} other {# 件の場所から}}{message_count, plural, =0 {0 件のメッセージを削除しました} other {# 件のメッセージを削除しました}}。",
|
||||
"content.virus_detected": "そのファイルは安全でないと判断されたため、削除されました。",
|
||||
"guild.bulk_create.emoji_limit": "最大絵文字数に達しました({limit})。",
|
||||
"guild.bulk_create.sticker_limit": "ステッカーの最大数に達しました ({limit})。",
|
||||
"guild.bulk_create.unknown_error": "不明なエラーが発生しました。",
|
||||
"guild.default_category_text": "テキストチャンネル",
|
||||
"guild.default_category_voice": "ボイスチャンネル",
|
||||
"guild.default_channel_text": "一般",
|
||||
"guild.default_channel_voice": "一般"
|
||||
"billing.eu_withdrawal_waiver_checkout": "私が EU/EEA の消費者である場合、{product_name} {premium_tier_name} のデジタルコンテンツが直ちに提供されることに明示的に同意し、アクセスが提供された時点で法定のクーリングオフ権を失うことを承諾します。これはその他の強制的な消費者権利に影響を与えません。[利用規約]({terms_url})をご確認ください。",
|
||||
"bulk_message_deletion.complete": "メッセージの削除が完了しました。{channel_count, plural, =0 {0 件の場所から} other {# 件の場所から}}{message_count, plural, =0 {0 件のメッセージを削除しました} other {# 件のメッセージを削除しました}}。",
|
||||
"content.virus_detected": "そのファイルは安全でないと判断されたため、削除されました。",
|
||||
"guild.bulk_create.emoji_limit": "最大絵文字数に達しました({limit})。",
|
||||
"guild.bulk_create.sticker_limit": "ステッカーの最大数に達しました ({limit})。",
|
||||
"guild.bulk_create.unknown_error": "不明なエラーが発生しました。",
|
||||
"guild.default_category_text": "テキストチャンネル",
|
||||
"guild.default_category_voice": "ボイスチャンネル",
|
||||
"guild.default_channel_text": "一般",
|
||||
"guild.default_channel_voice": "一般"
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"billing.eu_withdrawal_waiver_checkout": "EU/EEA 소비자로서 {product_name} {premium_tier_name} 디지털 콘텐츠를 즉시 제공받는 데 명시적으로 동의하며, 액세스가 제공된 후에는 법정 철회권을 상실함을 인정합니다. 이는 다른 필수 소비자 권리에 영향을 미치지 않습니다. [서비스 약관]({terms_url})을 확인하세요.",
|
||||
"bulk_message_deletion.complete": "메시지 삭제를 완료했습니다. {channel_count, plural, =0 {0 곳에서} other {# 곳에서}} {message_count, plural, =0 {0 개의 메시지를 삭제했습니다} other {# 개의 메시지를 삭제했습니다}}.",
|
||||
"content.virus_detected": "해당 파일은 잠재적으로 안전하지 않은 것으로 확인되어 삭제되었어요.",
|
||||
"guild.bulk_create.emoji_limit": "이모지 최대 개수에 도달했습니다({limit}).",
|
||||
"guild.bulk_create.sticker_limit": "최대 스티커 개수에 도달했습니다({limit}).",
|
||||
"guild.bulk_create.unknown_error": "알 수 없는 오류가 발생했습니다.",
|
||||
"guild.default_category_text": "텍스트 채널",
|
||||
"guild.default_category_voice": "음성 채널",
|
||||
"guild.default_channel_text": "일반",
|
||||
"guild.default_channel_voice": "일반"
|
||||
"billing.eu_withdrawal_waiver_checkout": "EU/EEA 소비자로서 {product_name} {premium_tier_name} 디지털 콘텐츠를 즉시 제공받는 데 명시적으로 동의하며, 액세스가 제공된 후에는 법정 철회권을 상실함을 인정합니다. 이는 다른 필수 소비자 권리에 영향을 미치지 않습니다. [서비스 약관]({terms_url})을 확인하세요.",
|
||||
"bulk_message_deletion.complete": "메시지 삭제를 완료했습니다. {channel_count, plural, =0 {0 곳에서} other {# 곳에서}} {message_count, plural, =0 {0 개의 메시지를 삭제했습니다} other {# 개의 메시지를 삭제했습니다}}.",
|
||||
"content.virus_detected": "해당 파일은 잠재적으로 안전하지 않은 것으로 확인되어 삭제되었어요.",
|
||||
"guild.bulk_create.emoji_limit": "이모지 최대 개수에 도달했습니다({limit}).",
|
||||
"guild.bulk_create.sticker_limit": "최대 스티커 개수에 도달했습니다({limit}).",
|
||||
"guild.bulk_create.unknown_error": "알 수 없는 오류가 발생했습니다.",
|
||||
"guild.default_category_text": "텍스트 채널",
|
||||
"guild.default_category_voice": "음성 채널",
|
||||
"guild.default_channel_text": "일반",
|
||||
"guild.default_channel_voice": "일반"
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"billing.eu_withdrawal_waiver_checkout": "Jei esu ES/EEE vartotojas, aiškiai sutinku, kad {product_name} {premium_tier_name} skaitmeninis turinys būtų suteiktas nedelsiant ir pripažįstu, kad, suteikus prieigą, netenku teisės atsisakyti sutarties. Tai neturi įtakos kitoms privalomoms vartotojų teisėms. Žr. [Paslaugų teikimo sąlygas]({terms_url}).",
|
||||
"bulk_message_deletion.complete": "Baigėme trinti tavo žinutes. Pašalinome {message_count, plural, =0 {0 žinučių} one {# žinutę} few {# žinutes} many {# žinutės} other {# žinučių}} iš {channel_count, plural, =0 {0 vietų} one {# vietos} few {# vietų} many {# vietos} other {# vietų}}.",
|
||||
"content.virus_detected": "Failas buvo pažymėtas kaip galimai nesaugus ir pašalintas.",
|
||||
"guild.bulk_create.emoji_limit": "Pasiektas didžiausias leistinų emoji skaičius ({limit}).",
|
||||
"guild.bulk_create.sticker_limit": "Pasiektas maksimalus lipdukų skaičius ({limit}).",
|
||||
"guild.bulk_create.unknown_error": "Nežinoma klaida.",
|
||||
"guild.default_category_text": "Teksto kanalai",
|
||||
"guild.default_category_voice": "Balso kanalai",
|
||||
"guild.default_channel_text": "bendras",
|
||||
"guild.default_channel_voice": "Bendras"
|
||||
"billing.eu_withdrawal_waiver_checkout": "Jei esu ES/EEE vartotojas, aiškiai sutinku, kad {product_name} {premium_tier_name} skaitmeninis turinys būtų suteiktas nedelsiant ir pripažįstu, kad, suteikus prieigą, netenku teisės atsisakyti sutarties. Tai neturi įtakos kitoms privalomoms vartotojų teisėms. Žr. [Paslaugų teikimo sąlygas]({terms_url}).",
|
||||
"bulk_message_deletion.complete": "Baigėme trinti tavo žinutes. Pašalinome {message_count, plural, =0 {0 žinučių} one {# žinutę} few {# žinutes} many {# žinutės} other {# žinučių}} iš {channel_count, plural, =0 {0 vietų} one {# vietos} few {# vietų} many {# vietos} other {# vietų}}.",
|
||||
"content.virus_detected": "Failas buvo pažymėtas kaip galimai nesaugus ir pašalintas.",
|
||||
"guild.bulk_create.emoji_limit": "Pasiektas didžiausias leistinų emoji skaičius ({limit}).",
|
||||
"guild.bulk_create.sticker_limit": "Pasiektas maksimalus lipdukų skaičius ({limit}).",
|
||||
"guild.bulk_create.unknown_error": "Nežinoma klaida.",
|
||||
"guild.default_category_text": "Teksto kanalai",
|
||||
"guild.default_category_voice": "Balso kanalai",
|
||||
"guild.default_channel_text": "bendras",
|
||||
"guild.default_channel_voice": "Bendras"
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"billing.eu_withdrawal_waiver_checkout": "Als ik een EU/EER-consument ben, geef ik uitdrukkelijk toestemming dat de digitale inhoud van {product_name} {premium_tier_name} onmiddellijk wordt geleverd en erken ik dat ik mijn wettelijke herroepingsrecht verlies zodra toegang wordt verleend. Dit heeft geen invloed op andere dwingende consumentenrechten. Zie de [Servicevoorwaarden]({terms_url}).",
|
||||
"bulk_message_deletion.complete": "We zijn klaar met het verwijderen van je berichten. We hebben {message_count, plural, =0 {0 berichten} one {# bericht} other {# berichten}} verwijderd uit {channel_count, plural, =0 {0 plekken} one {# plek} other {# plekken}}.",
|
||||
"content.virus_detected": "Dit bestand is gemarkeerd als mogelijk onveilig en is verwijderd.",
|
||||
"guild.bulk_create.emoji_limit": "Maximaal aantal emoji bereikt ({limit}).",
|
||||
"guild.bulk_create.sticker_limit": "Maximaal aantal stickers bereikt ({limit}).",
|
||||
"guild.bulk_create.unknown_error": "Onbekende fout.",
|
||||
"guild.default_category_text": "Tekstkanalen",
|
||||
"guild.default_category_voice": "Spraakkanalen",
|
||||
"guild.default_channel_text": "algemeen",
|
||||
"guild.default_channel_voice": "Algemeen"
|
||||
"billing.eu_withdrawal_waiver_checkout": "Als ik een EU/EER-consument ben, geef ik uitdrukkelijk toestemming dat de digitale inhoud van {product_name} {premium_tier_name} onmiddellijk wordt geleverd en erken ik dat ik mijn wettelijke herroepingsrecht verlies zodra toegang wordt verleend. Dit heeft geen invloed op andere dwingende consumentenrechten. Zie de [Servicevoorwaarden]({terms_url}).",
|
||||
"bulk_message_deletion.complete": "We zijn klaar met het verwijderen van je berichten. We hebben {message_count, plural, =0 {0 berichten} one {# bericht} other {# berichten}} verwijderd uit {channel_count, plural, =0 {0 plekken} one {# plek} other {# plekken}}.",
|
||||
"content.virus_detected": "Dit bestand is gemarkeerd als mogelijk onveilig en is verwijderd.",
|
||||
"guild.bulk_create.emoji_limit": "Maximaal aantal emoji bereikt ({limit}).",
|
||||
"guild.bulk_create.sticker_limit": "Maximaal aantal stickers bereikt ({limit}).",
|
||||
"guild.bulk_create.unknown_error": "Onbekende fout.",
|
||||
"guild.default_category_text": "Tekstkanalen",
|
||||
"guild.default_category_voice": "Spraakkanalen",
|
||||
"guild.default_channel_text": "algemeen",
|
||||
"guild.default_channel_voice": "Algemeen"
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"billing.eu_withdrawal_waiver_checkout": "Hvis jeg er en EU/EØS-forbruker, samtykker jeg uttrykkelig til at {product_name} {premium_tier_name} digitalt innhold leveres umiddelbart, og erkjenner at jeg mister min lovbestemte angrerett når tilgangen er gitt. Dette påvirker ikke andre lovpålagte forbrukerrettigheter. Se [tjenestevilkårene]({terms_url}).",
|
||||
"bulk_message_deletion.complete": "Vi er ferdige med å slette meldingene dine. Vi fjernet {message_count, plural, =0 {0 meldinger} one {# melding} other {# meldinger}} fra {channel_count, plural, =0 {0 steder} one {# sted} other {# steder}}.",
|
||||
"content.virus_detected": "Filen ble flagget som potensielt utrygg og er fjernet.",
|
||||
"guild.bulk_create.emoji_limit": "Maksimalt antall emojier nådd ({limit}).",
|
||||
"guild.bulk_create.sticker_limit": "Maksimalt antall klistremerker nådd ({limit}).",
|
||||
"guild.bulk_create.unknown_error": "Ukjent feil.",
|
||||
"guild.default_category_text": "Tekstkanaler",
|
||||
"guild.default_category_voice": "Talekanaler",
|
||||
"guild.default_channel_text": "generelt",
|
||||
"guild.default_channel_voice": "Generelt"
|
||||
"billing.eu_withdrawal_waiver_checkout": "Hvis jeg er en EU/EØS-forbruker, samtykker jeg uttrykkelig til at {product_name} {premium_tier_name} digitalt innhold leveres umiddelbart, og erkjenner at jeg mister min lovbestemte angrerett når tilgangen er gitt. Dette påvirker ikke andre lovpålagte forbrukerrettigheter. Se [tjenestevilkårene]({terms_url}).",
|
||||
"bulk_message_deletion.complete": "Vi er ferdige med å slette meldingene dine. Vi fjernet {message_count, plural, =0 {0 meldinger} one {# melding} other {# meldinger}} fra {channel_count, plural, =0 {0 steder} one {# sted} other {# steder}}.",
|
||||
"content.virus_detected": "Filen ble flagget som potensielt utrygg og er fjernet.",
|
||||
"guild.bulk_create.emoji_limit": "Maksimalt antall emojier nådd ({limit}).",
|
||||
"guild.bulk_create.sticker_limit": "Maksimalt antall klistremerker nådd ({limit}).",
|
||||
"guild.bulk_create.unknown_error": "Ukjent feil.",
|
||||
"guild.default_category_text": "Tekstkanaler",
|
||||
"guild.default_category_voice": "Talekanaler",
|
||||
"guild.default_channel_text": "generelt",
|
||||
"guild.default_channel_voice": "Generelt"
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"billing.eu_withdrawal_waiver_checkout": "Jako konsument z UE/EOG zgadzam się na natychmiastowe dostarczenie treści cyfrowych {product_name} {premium_tier_name} i przyjmuję do wiadomości, że po uzyskaniu dostępu tracę ustawowe prawo do odstąpienia od umowy. Nie wpływa to na inne obowiązkowe prawa konsumenta. Zobacz [Warunki użytkowania]({terms_url}).",
|
||||
"bulk_message_deletion.complete": "Zakończyliśmy usuwanie Twoich wiadomości. Usunęliśmy {message_count, plural, =0 {0 wiadomości} one {# wiadomość} few {# wiadomości} many {# wiadomości} other {# wiadomości}} z {channel_count, plural, =0 {0 miejsc} one {# miejsca} few {# miejsc} many {# miejsc} other {# miejsc}}.",
|
||||
"content.virus_detected": "Ten plik został oznaczony jako potencjalnie niebezpieczny i został usunięty.",
|
||||
"guild.bulk_create.emoji_limit": "Osiągnięto maksymalną liczbę emoji ({limit}).",
|
||||
"guild.bulk_create.sticker_limit": "Osiągnięto maksymalną liczbę naklejek ({limit}).",
|
||||
"guild.bulk_create.unknown_error": "Wystąpił nieznany błąd.",
|
||||
"guild.default_category_text": "Kanały tekstowe",
|
||||
"guild.default_category_voice": "Kanały głosowe",
|
||||
"guild.default_channel_text": "Ogólny",
|
||||
"guild.default_channel_voice": "Ogólny"
|
||||
"billing.eu_withdrawal_waiver_checkout": "Jako konsument z UE/EOG zgadzam się na natychmiastowe dostarczenie treści cyfrowych {product_name} {premium_tier_name} i przyjmuję do wiadomości, że po uzyskaniu dostępu tracę ustawowe prawo do odstąpienia od umowy. Nie wpływa to na inne obowiązkowe prawa konsumenta. Zobacz [Warunki użytkowania]({terms_url}).",
|
||||
"bulk_message_deletion.complete": "Zakończyliśmy usuwanie Twoich wiadomości. Usunęliśmy {message_count, plural, =0 {0 wiadomości} one {# wiadomość} few {# wiadomości} many {# wiadomości} other {# wiadomości}} z {channel_count, plural, =0 {0 miejsc} one {# miejsca} few {# miejsc} many {# miejsc} other {# miejsc}}.",
|
||||
"content.virus_detected": "Ten plik został oznaczony jako potencjalnie niebezpieczny i został usunięty.",
|
||||
"guild.bulk_create.emoji_limit": "Osiągnięto maksymalną liczbę emoji ({limit}).",
|
||||
"guild.bulk_create.sticker_limit": "Osiągnięto maksymalną liczbę naklejek ({limit}).",
|
||||
"guild.bulk_create.unknown_error": "Wystąpił nieznany błąd.",
|
||||
"guild.default_category_text": "Kanały tekstowe",
|
||||
"guild.default_category_voice": "Kanały głosowe",
|
||||
"guild.default_channel_text": "Ogólny",
|
||||
"guild.default_channel_voice": "Ogólny"
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"billing.eu_withdrawal_waiver_checkout": "Se eu for um consumidor da UE/EEE, consinto expressamente que o conteúdo digital {product_name} {premium_tier_name} seja fornecido imediatamente e reconheço que perco meu direito legal de arrependimento assim que o acesso for concedido. Isso não afeta outros direitos obrigatórios do consumidor. Consulte os [Termos de Serviço]({terms_url}).",
|
||||
"bulk_message_deletion.complete": "Terminamos de excluir suas mensagens. Removemos {message_count, plural, =0 {0 mensagens} one {# mensagem} other {# mensagens}} de {channel_count, plural, =0 {0 lugares} one {# lugar} other {# lugares}}.",
|
||||
"content.virus_detected": "Esse arquivo foi sinalizado como potencialmente inseguro e foi removido.",
|
||||
"guild.bulk_create.emoji_limit": "Número máximo de emojis atingido ({limit}).",
|
||||
"guild.bulk_create.sticker_limit": "Número máximo de adesivos atingido ({limit}).",
|
||||
"guild.bulk_create.unknown_error": "Erro desconhecido.",
|
||||
"guild.default_category_text": "Canais de texto",
|
||||
"guild.default_category_voice": "Canais de voz",
|
||||
"guild.default_channel_text": "geral",
|
||||
"guild.default_channel_voice": "Geral"
|
||||
"billing.eu_withdrawal_waiver_checkout": "Se eu for um consumidor da UE/EEE, consinto expressamente que o conteúdo digital {product_name} {premium_tier_name} seja fornecido imediatamente e reconheço que perco meu direito legal de arrependimento assim que o acesso for concedido. Isso não afeta outros direitos obrigatórios do consumidor. Consulte os [Termos de Serviço]({terms_url}).",
|
||||
"bulk_message_deletion.complete": "Terminamos de excluir suas mensagens. Removemos {message_count, plural, =0 {0 mensagens} one {# mensagem} other {# mensagens}} de {channel_count, plural, =0 {0 lugares} one {# lugar} other {# lugares}}.",
|
||||
"content.virus_detected": "Esse arquivo foi sinalizado como potencialmente inseguro e foi removido.",
|
||||
"guild.bulk_create.emoji_limit": "Número máximo de emojis atingido ({limit}).",
|
||||
"guild.bulk_create.sticker_limit": "Número máximo de adesivos atingido ({limit}).",
|
||||
"guild.bulk_create.unknown_error": "Erro desconhecido.",
|
||||
"guild.default_category_text": "Canais de texto",
|
||||
"guild.default_category_voice": "Canais de voz",
|
||||
"guild.default_channel_text": "geral",
|
||||
"guild.default_channel_voice": "Geral"
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"billing.eu_withdrawal_waiver_checkout": "Dacă sunt un consumator din UE/SEE, îmi exprim consimțământul expres ca conținutul digital {product_name} {premium_tier_name} să fie furnizat imediat și recunosc că pierd dreptul legal de retragere odată ce accesul este acordat. Acest lucru nu afectează alte drepturi obligatorii ale consumatorilor. Vezi [Termenii și condițiile]({terms_url}).",
|
||||
"bulk_message_deletion.complete": "Am terminat de șters mesajele tale. Am eliminat {message_count, plural, =0 {0 de mesaje} one {# mesaj} few {# mesaje} other {# de mesaje}} din {channel_count, plural, =0 {0 de locuri} one {# loc} few {# locuri} other {# de locuri}}.",
|
||||
"content.virus_detected": "Acest fișier a fost marcat ca potențial nesigur și a fost eliminat.",
|
||||
"guild.bulk_create.emoji_limit": "S-a atins limita maximă de emojiuri ({limit}).",
|
||||
"guild.bulk_create.sticker_limit": "Numărul maxim de stickere a fost atins ({limit}).",
|
||||
"guild.bulk_create.unknown_error": "A apărut o eroare necunoscută.",
|
||||
"guild.default_category_text": "Canale text",
|
||||
"guild.default_category_voice": "Canale de voce",
|
||||
"guild.default_channel_text": "general",
|
||||
"guild.default_channel_voice": "General"
|
||||
"billing.eu_withdrawal_waiver_checkout": "Dacă sunt un consumator din UE/SEE, îmi exprim consimțământul expres ca conținutul digital {product_name} {premium_tier_name} să fie furnizat imediat și recunosc că pierd dreptul legal de retragere odată ce accesul este acordat. Acest lucru nu afectează alte drepturi obligatorii ale consumatorilor. Vezi [Termenii și condițiile]({terms_url}).",
|
||||
"bulk_message_deletion.complete": "Am terminat de șters mesajele tale. Am eliminat {message_count, plural, =0 {0 de mesaje} one {# mesaj} few {# mesaje} other {# de mesaje}} din {channel_count, plural, =0 {0 de locuri} one {# loc} few {# locuri} other {# de locuri}}.",
|
||||
"content.virus_detected": "Acest fișier a fost marcat ca potențial nesigur și a fost eliminat.",
|
||||
"guild.bulk_create.emoji_limit": "S-a atins limita maximă de emojiuri ({limit}).",
|
||||
"guild.bulk_create.sticker_limit": "Numărul maxim de stickere a fost atins ({limit}).",
|
||||
"guild.bulk_create.unknown_error": "A apărut o eroare necunoscută.",
|
||||
"guild.default_category_text": "Canale text",
|
||||
"guild.default_category_voice": "Canale de voce",
|
||||
"guild.default_channel_text": "general",
|
||||
"guild.default_channel_voice": "General"
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"billing.eu_withdrawal_waiver_checkout": "Если ты являешься потребителем из ЕС/ЕЭЗ, ты даёшь явное согласие на немедленное предоставление цифрового контента {product_name} {premium_tier_name} и понимаешь, что теряешь законное право на отмены после получения доступа. Это не затрагивает другие обязательные права потребителя. См. [Условия предоставления услуг]({terms_url}).",
|
||||
"bulk_message_deletion.complete": "Мы завершили удаление ваших сообщений. Удалено {message_count, plural, =0 {0 сообщений} one {# сообщение} few {# сообщения} many {# сообщений} other {# сообщений}} из {channel_count, plural, =0 {0 мест} one {# места} few {# мест} many {# мест} other {# мест}}.",
|
||||
"content.virus_detected": "Этот файл был отмечен как потенциально опасный и удалён.",
|
||||
"guild.bulk_create.emoji_limit": "Достигнут максимальный лимит эмодзи ({limit}).",
|
||||
"guild.bulk_create.sticker_limit": "Достигнут максимальный лимит стикеров ({limit}).",
|
||||
"guild.bulk_create.unknown_error": "Произошла неизвестная ошибка.",
|
||||
"guild.default_category_text": "Текстовые каналы",
|
||||
"guild.default_category_voice": "Голосовые каналы",
|
||||
"guild.default_channel_text": "общий",
|
||||
"guild.default_channel_voice": "Общий"
|
||||
"billing.eu_withdrawal_waiver_checkout": "Если ты являешься потребителем из ЕС/ЕЭЗ, ты даёшь явное согласие на немедленное предоставление цифрового контента {product_name} {premium_tier_name} и понимаешь, что теряешь законное право на отмены после получения доступа. Это не затрагивает другие обязательные права потребителя. См. [Условия предоставления услуг]({terms_url}).",
|
||||
"bulk_message_deletion.complete": "Мы завершили удаление ваших сообщений. Удалено {message_count, plural, =0 {0 сообщений} one {# сообщение} few {# сообщения} many {# сообщений} other {# сообщений}} из {channel_count, plural, =0 {0 мест} one {# места} few {# мест} many {# мест} other {# мест}}.",
|
||||
"content.virus_detected": "Этот файл был отмечен как потенциально опасный и удалён.",
|
||||
"guild.bulk_create.emoji_limit": "Достигнут максимальный лимит эмодзи ({limit}).",
|
||||
"guild.bulk_create.sticker_limit": "Достигнут максимальный лимит стикеров ({limit}).",
|
||||
"guild.bulk_create.unknown_error": "Произошла неизвестная ошибка.",
|
||||
"guild.default_category_text": "Текстовые каналы",
|
||||
"guild.default_category_voice": "Голосовые каналы",
|
||||
"guild.default_channel_text": "общий",
|
||||
"guild.default_channel_voice": "Общий"
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"billing.eu_withdrawal_waiver_checkout": "Om jag är en EU/EES-konsument samtycker jag uttryckligen till att {product_name} {premium_tier_name} digitalt innehåll tillhandahålls omedelbart och bekräftar att jag förlorar min lagstadgade ångerrätt när åtkomst ges. Detta påverkar inte andra tvingande konsumenträttigheter. Se [användarvillkoren]({terms_url}).",
|
||||
"bulk_message_deletion.complete": "Vi har raderat dina meddelanden. Vi tog bort {message_count, plural, =0 {0 meddelanden} one {# meddelande} other {# meddelanden}} från {channel_count, plural, =0 {0 platser} one {# plats} other {# platser}}.",
|
||||
"content.virus_detected": "Filen flaggades som potentiellt osäker och har tagits bort.",
|
||||
"guild.bulk_create.emoji_limit": "Maximalt antal emojis nått ({limit}).",
|
||||
"guild.bulk_create.sticker_limit": "Maximalt antal klistermärken nått ({limit}).",
|
||||
"guild.bulk_create.unknown_error": "Okänt fel.",
|
||||
"guild.default_category_text": "Textkanaler",
|
||||
"guild.default_category_voice": "Röstkanaler",
|
||||
"guild.default_channel_text": "allmänt",
|
||||
"guild.default_channel_voice": "Allmänt"
|
||||
"billing.eu_withdrawal_waiver_checkout": "Om jag är en EU/EES-konsument samtycker jag uttryckligen till att {product_name} {premium_tier_name} digitalt innehåll tillhandahålls omedelbart och bekräftar att jag förlorar min lagstadgade ångerrätt när åtkomst ges. Detta påverkar inte andra tvingande konsumenträttigheter. Se [användarvillkoren]({terms_url}).",
|
||||
"bulk_message_deletion.complete": "Vi har raderat dina meddelanden. Vi tog bort {message_count, plural, =0 {0 meddelanden} one {# meddelande} other {# meddelanden}} från {channel_count, plural, =0 {0 platser} one {# plats} other {# platser}}.",
|
||||
"content.virus_detected": "Filen flaggades som potentiellt osäker och har tagits bort.",
|
||||
"guild.bulk_create.emoji_limit": "Maximalt antal emojis nått ({limit}).",
|
||||
"guild.bulk_create.sticker_limit": "Maximalt antal klistermärken nått ({limit}).",
|
||||
"guild.bulk_create.unknown_error": "Okänt fel.",
|
||||
"guild.default_category_text": "Textkanaler",
|
||||
"guild.default_category_voice": "Röstkanaler",
|
||||
"guild.default_channel_text": "allmänt",
|
||||
"guild.default_channel_voice": "Allmänt"
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"billing.eu_withdrawal_waiver_checkout": "หากฉันเป็นผู้บริโภคใน EU/EEA ฉันยินยอมให้มีการให้บริการเนื้อหาดิจิทัล {product_name} {premium_tier_name} ทันที และรับทราบว่าฉันสูญเสียสิทธิ์ในการถอนตัวตามกฎหมายเมื่อมีการให้สิทธิ์การเข้าถึงแล้ว ซึ่งไม่กระทบต่อสิทธิ์ผู้บริโภคอื่นๆ ที่จำเป็น ดู[ข้อกำหนดในการให้บริการ]({terms_url})",
|
||||
"bulk_message_deletion.complete": "ลบข้อความของคุณเรียบร้อยแล้ว เราลบ {message_count, plural, =0 {0 ข้อความ} other {# ข้อความ}} จาก {channel_count, plural, =0 {0 ที่} other {# ที่}}",
|
||||
"content.virus_detected": "ไฟล์นั้นถูกตรวจพบว่าอาจไม่ปลอดภัยและถูกลบออกแล้ว",
|
||||
"guild.bulk_create.emoji_limit": "จำนวนอีโมจิสูงสุดที่อนุญาตคือ {limit}",
|
||||
"guild.bulk_create.sticker_limit": "จำนวนสติกเกอร์สูงสุดที่สามารถเพิ่มได้คือ {limit}",
|
||||
"guild.bulk_create.unknown_error": "เกิดข้อผิดพลาดที่ไม่ทราบสาเหตุ",
|
||||
"guild.default_category_text": "ช่องข้อความ",
|
||||
"guild.default_category_voice": "ช่องเสียง",
|
||||
"guild.default_channel_text": "ทั่วไป",
|
||||
"guild.default_channel_voice": "ทั่วไป"
|
||||
"billing.eu_withdrawal_waiver_checkout": "หากฉันเป็นผู้บริโภคใน EU/EEA ฉันยินยอมให้มีการให้บริการเนื้อหาดิจิทัล {product_name} {premium_tier_name} ทันที และรับทราบว่าฉันสูญเสียสิทธิ์ในการถอนตัวตามกฎหมายเมื่อมีการให้สิทธิ์การเข้าถึงแล้ว ซึ่งไม่กระทบต่อสิทธิ์ผู้บริโภคอื่นๆ ที่จำเป็น ดู[ข้อกำหนดในการให้บริการ]({terms_url})",
|
||||
"bulk_message_deletion.complete": "ลบข้อความของคุณเรียบร้อยแล้ว เราลบ {message_count, plural, =0 {0 ข้อความ} other {# ข้อความ}} จาก {channel_count, plural, =0 {0 ที่} other {# ที่}}",
|
||||
"content.virus_detected": "ไฟล์นั้นถูกตรวจพบว่าอาจไม่ปลอดภัยและถูกลบออกแล้ว",
|
||||
"guild.bulk_create.emoji_limit": "จำนวนอีโมจิสูงสุดที่อนุญาตคือ {limit}",
|
||||
"guild.bulk_create.sticker_limit": "จำนวนสติกเกอร์สูงสุดที่สามารถเพิ่มได้คือ {limit}",
|
||||
"guild.bulk_create.unknown_error": "เกิดข้อผิดพลาดที่ไม่ทราบสาเหตุ",
|
||||
"guild.default_category_text": "ช่องข้อความ",
|
||||
"guild.default_category_voice": "ช่องเสียง",
|
||||
"guild.default_channel_text": "ทั่วไป",
|
||||
"guild.default_channel_voice": "ทั่วไป"
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"billing.eu_withdrawal_waiver_checkout": "AB/EEA tüketicisiysem, {product_name} {premium_tier_name} dijital içeriğinin hemen sağlanmasına açıkça onay veriyorum ve erişim sağlandıktan sonra yasal cayma hakkımı kaybettiğimi kabul ediyorum. Bu, diğer zorunlu tüketici haklarını etkilemez. [Hizmet Şartları]({terms_url}) sayfasına bakın.",
|
||||
"bulk_message_deletion.complete": "Mesajlarını silme işlemini tamamladık. {channel_count, plural, =0 {0 yerden} one {# yerden} other {# yerden}} {message_count, plural, =0 {0 mesaj} one {# mesaj} other {# mesaj}} kaldırdık.",
|
||||
"content.virus_detected": "Bu dosya potansiyel olarak güvensiz olarak işaretlendi ve kaldırıldı.",
|
||||
"guild.bulk_create.emoji_limit": "Maksimum emoji sayısına ulaşıldı ({limit}).",
|
||||
"guild.bulk_create.sticker_limit": "Maksimum çıkartma sayısına ulaşıldı ({limit}).",
|
||||
"guild.bulk_create.unknown_error": "Bilinmeyen bir hata oluştu.",
|
||||
"guild.default_category_text": "Metin kanalları",
|
||||
"guild.default_category_voice": "Sesli kanallar",
|
||||
"guild.default_channel_text": "genel",
|
||||
"guild.default_channel_voice": "Genel"
|
||||
"billing.eu_withdrawal_waiver_checkout": "AB/EEA tüketicisiysem, {product_name} {premium_tier_name} dijital içeriğinin hemen sağlanmasına açıkça onay veriyorum ve erişim sağlandıktan sonra yasal cayma hakkımı kaybettiğimi kabul ediyorum. Bu, diğer zorunlu tüketici haklarını etkilemez. [Hizmet Şartları]({terms_url}) sayfasına bakın.",
|
||||
"bulk_message_deletion.complete": "Mesajlarını silme işlemini tamamladık. {channel_count, plural, =0 {0 yerden} one {# yerden} other {# yerden}} {message_count, plural, =0 {0 mesaj} one {# mesaj} other {# mesaj}} kaldırdık.",
|
||||
"content.virus_detected": "Bu dosya potansiyel olarak güvensiz olarak işaretlendi ve kaldırıldı.",
|
||||
"guild.bulk_create.emoji_limit": "Maksimum emoji sayısına ulaşıldı ({limit}).",
|
||||
"guild.bulk_create.sticker_limit": "Maksimum çıkartma sayısına ulaşıldı ({limit}).",
|
||||
"guild.bulk_create.unknown_error": "Bilinmeyen bir hata oluştu.",
|
||||
"guild.default_category_text": "Metin kanalları",
|
||||
"guild.default_category_voice": "Sesli kanallar",
|
||||
"guild.default_channel_text": "genel",
|
||||
"guild.default_channel_voice": "Genel"
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"billing.eu_withdrawal_waiver_checkout": "Якщо я є споживачем з ЄС/ЄЕЗ, я надаю явну згоду на негайне надання цифрового контенту {product_name} {premium_tier_name} і визнаю, що втрачаю своє законне право на відмову після отримання доступу. Це не впливає на інші обов'язкові права споживача. Див. [Умови надання послуг]({terms_url}).",
|
||||
"bulk_message_deletion.complete": "Ми завершили видалення твоїх повідомлень. Видалено {message_count, plural, =0 {0 повідомлень} one {# повідомлення} few {# повідомлення} many {# повідомлень} other {# повідомлень}} з {channel_count, plural, =0 {0 місць} one {# місця} few {# місць} many {# місць} other {# місць}}.",
|
||||
"content.virus_detected": "Цей файл позначено як потенційно небезпечний, і його вилучено.",
|
||||
"guild.bulk_create.emoji_limit": "Досягнуто максимальної кількості емодзі ({limit}).",
|
||||
"guild.bulk_create.sticker_limit": "Досягнуто максимальну кількість стікерів ({limit}).",
|
||||
"guild.bulk_create.unknown_error": "Невідома помилка.",
|
||||
"guild.default_category_text": "Текстові канали",
|
||||
"guild.default_category_voice": "Голосові канали",
|
||||
"guild.default_channel_text": "загальний",
|
||||
"guild.default_channel_voice": "Загальний"
|
||||
"billing.eu_withdrawal_waiver_checkout": "Якщо я є споживачем з ЄС/ЄЕЗ, я надаю явну згоду на негайне надання цифрового контенту {product_name} {premium_tier_name} і визнаю, що втрачаю своє законне право на відмову після отримання доступу. Це не впливає на інші обов'язкові права споживача. Див. [Умови надання послуг]({terms_url}).",
|
||||
"bulk_message_deletion.complete": "Ми завершили видалення твоїх повідомлень. Видалено {message_count, plural, =0 {0 повідомлень} one {# повідомлення} few {# повідомлення} many {# повідомлень} other {# повідомлень}} з {channel_count, plural, =0 {0 місць} one {# місця} few {# місць} many {# місць} other {# місць}}.",
|
||||
"content.virus_detected": "Цей файл позначено як потенційно небезпечний, і його вилучено.",
|
||||
"guild.bulk_create.emoji_limit": "Досягнуто максимальної кількості емодзі ({limit}).",
|
||||
"guild.bulk_create.sticker_limit": "Досягнуто максимальну кількість стікерів ({limit}).",
|
||||
"guild.bulk_create.unknown_error": "Невідома помилка.",
|
||||
"guild.default_category_text": "Текстові канали",
|
||||
"guild.default_category_voice": "Голосові канали",
|
||||
"guild.default_channel_text": "загальний",
|
||||
"guild.default_channel_voice": "Загальний"
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"billing.eu_withdrawal_waiver_checkout": "Nếu tôi là người tiêu dùng EU/EEA, tôi đồng ý rõ ràng rằng nội dung kỹ thuật số {product_name} {premium_tier_name} được cung cấp ngay lập tức và thừa nhận rằng tôi mất quyền hủy bỏ theo luật định sau khi có quyền truy cập. Điều này không ảnh hưởng đến các quyền lợi người tiêu dùng bắt buộc khác. Xem [Điều khoản dịch vụ]({terms_url}).",
|
||||
"bulk_message_deletion.complete": "Chúng tôi đã xóa xong tin nhắn của bạn. Đã xóa {message_count, plural, =0 {0 tin nhắn} other {# tin nhắn}} khỏi {channel_count, plural, =0 {0 nơi} other {# nơi}}.",
|
||||
"content.virus_detected": "Tệp đó bị gắn cờ là có khả năng không an toàn và đã bị xóa.",
|
||||
"guild.bulk_create.emoji_limit": "Đã đạt đến số lượng biểu tượng cảm xúc tối đa ({limit}).",
|
||||
"guild.bulk_create.sticker_limit": "Đã đạt đến số lượng tối đa nhãn dán ({limit}).",
|
||||
"guild.bulk_create.unknown_error": "Đã xảy ra lỗi không xác định.",
|
||||
"guild.default_category_text": "Kênh văn bản",
|
||||
"guild.default_category_voice": "Kênh thoại",
|
||||
"guild.default_channel_text": "chung",
|
||||
"guild.default_channel_voice": "Chung"
|
||||
"billing.eu_withdrawal_waiver_checkout": "Nếu tôi là người tiêu dùng EU/EEA, tôi đồng ý rõ ràng rằng nội dung kỹ thuật số {product_name} {premium_tier_name} được cung cấp ngay lập tức và thừa nhận rằng tôi mất quyền hủy bỏ theo luật định sau khi có quyền truy cập. Điều này không ảnh hưởng đến các quyền lợi người tiêu dùng bắt buộc khác. Xem [Điều khoản dịch vụ]({terms_url}).",
|
||||
"bulk_message_deletion.complete": "Chúng tôi đã xóa xong tin nhắn của bạn. Đã xóa {message_count, plural, =0 {0 tin nhắn} other {# tin nhắn}} khỏi {channel_count, plural, =0 {0 nơi} other {# nơi}}.",
|
||||
"content.virus_detected": "Tệp đó bị gắn cờ là có khả năng không an toàn và đã bị xóa.",
|
||||
"guild.bulk_create.emoji_limit": "Đã đạt đến số lượng biểu tượng cảm xúc tối đa ({limit}).",
|
||||
"guild.bulk_create.sticker_limit": "Đã đạt đến số lượng tối đa nhãn dán ({limit}).",
|
||||
"guild.bulk_create.unknown_error": "Đã xảy ra lỗi không xác định.",
|
||||
"guild.default_category_text": "Kênh văn bản",
|
||||
"guild.default_category_voice": "Kênh thoại",
|
||||
"guild.default_channel_text": "chung",
|
||||
"guild.default_channel_voice": "Chung"
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"billing.eu_withdrawal_waiver_checkout": "如果我是欧盟/欧洲经济区消费者,我明确同意立即提供 {product_name} {premium_tier_name} 数字内容,并知晓一旦获得访问权限,我将失去法定撤销权。这不影响其他强制性消费者权利。请参阅[服务条款]({terms_url})。",
|
||||
"bulk_message_deletion.complete": "我们已删除你的消息。从 {channel_count, plural, =0 {0 个位置} other {# 个位置}} 中移除了 {message_count, plural, =0 {0 条消息} other {# 条消息}}。",
|
||||
"content.virus_detected": "该文件被标记为可能存在风险,已被移除。",
|
||||
"guild.bulk_create.emoji_limit": "已达到最大表情符号数量 ({limit})。",
|
||||
"guild.bulk_create.sticker_limit": "已达到最大贴纸数量 ({limit})。",
|
||||
"guild.bulk_create.unknown_error": "发生未知错误。",
|
||||
"guild.default_category_text": "文字频道",
|
||||
"guild.default_category_voice": "语音频道",
|
||||
"guild.default_channel_text": "综合",
|
||||
"guild.default_channel_voice": "综合"
|
||||
"billing.eu_withdrawal_waiver_checkout": "如果我是欧盟/欧洲经济区消费者,我明确同意立即提供 {product_name} {premium_tier_name} 数字内容,并知晓一旦获得访问权限,我将失去法定撤销权。这不影响其他强制性消费者权利。请参阅[服务条款]({terms_url})。",
|
||||
"bulk_message_deletion.complete": "我们已删除你的消息。从 {channel_count, plural, =0 {0 个位置} other {# 个位置}} 中移除了 {message_count, plural, =0 {0 条消息} other {# 条消息}}。",
|
||||
"content.virus_detected": "该文件被标记为可能存在风险,已被移除。",
|
||||
"guild.bulk_create.emoji_limit": "已达到最大表情符号数量 ({limit})。",
|
||||
"guild.bulk_create.sticker_limit": "已达到最大贴纸数量 ({limit})。",
|
||||
"guild.bulk_create.unknown_error": "发生未知错误。",
|
||||
"guild.default_category_text": "文字频道",
|
||||
"guild.default_category_voice": "语音频道",
|
||||
"guild.default_channel_text": "综合",
|
||||
"guild.default_channel_voice": "综合"
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"billing.eu_withdrawal_waiver_checkout": "若我是歐盟/歐洲經濟區消費者,我明確同意立即提供 {product_name} {premium_tier_name} 數位內容,並了解一旦取得存取權,即喪失法定撤回權。這不影響其他強制性消費者權益。請參閱[服務條款]({terms_url})。",
|
||||
"bulk_message_deletion.complete": "我們已刪除你的訊息。從 {channel_count, plural, =0 {0 個位置} other {# 個位置}} 中移除了 {message_count, plural, =0 {0 則訊息} other {# 則訊息}}。",
|
||||
"content.virus_detected": "該檔案已被標記為可能不安全,並已移除。",
|
||||
"guild.bulk_create.emoji_limit": "已達最大表情符號數量限制 ({limit})。",
|
||||
"guild.bulk_create.sticker_limit": "已達最大貼圖數量 ({limit})。",
|
||||
"guild.bulk_create.unknown_error": "發生了未知的錯誤。",
|
||||
"guild.default_category_text": "文字頻道",
|
||||
"guild.default_category_voice": "語音頻道",
|
||||
"guild.default_channel_text": "一般",
|
||||
"guild.default_channel_voice": "一般"
|
||||
"billing.eu_withdrawal_waiver_checkout": "若我是歐盟/歐洲經濟區消費者,我明確同意立即提供 {product_name} {premium_tier_name} 數位內容,並了解一旦取得存取權,即喪失法定撤回權。這不影響其他強制性消費者權益。請參閱[服務條款]({terms_url})。",
|
||||
"bulk_message_deletion.complete": "我們已刪除你的訊息。從 {channel_count, plural, =0 {0 個位置} other {# 個位置}} 中移除了 {message_count, plural, =0 {0 則訊息} other {# 則訊息}}。",
|
||||
"content.virus_detected": "該檔案已被標記為可能不安全,並已移除。",
|
||||
"guild.bulk_create.emoji_limit": "已達最大表情符號數量限制 ({limit})。",
|
||||
"guild.bulk_create.sticker_limit": "已達最大貼圖數量 ({limit})。",
|
||||
"guild.bulk_create.unknown_error": "發生了未知的錯誤。",
|
||||
"guild.default_category_text": "文字頻道",
|
||||
"guild.default_category_voice": "語音頻道",
|
||||
"guild.default_channel_text": "一般",
|
||||
"guild.default_channel_voice": "一般"
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ describe('assertNoUndefinedParams', () => {
|
||||
created_at: new Date(0),
|
||||
blob: Buffer.from('x'),
|
||||
mention_users: [1n, 2n, 3n],
|
||||
nsfw_emojis: new Set(['a', 'b']),
|
||||
mention_roles: new Set(['a', 'b']),
|
||||
reactions: new Map([['a', 1]]),
|
||||
embeds: {title: 'a', fields: [{name: 'n', value: 'v'}], footer: {text: null}},
|
||||
}),
|
||||
@@ -44,7 +44,7 @@ describe('assertNoUndefinedParams', () => {
|
||||
});
|
||||
|
||||
it('reports the dotted path of an undefined set member', () => {
|
||||
expect(guardError({nsfw_emojis: new Set(['a', undefined])})).toBe(messageFor(':nsfw_emojis{set:1}'));
|
||||
expect(guardError({mention_roles: new Set(['a', undefined])})).toBe(messageFor(':mention_roles{set:1}'));
|
||||
});
|
||||
|
||||
it('reports the dotted path of an undefined map key', () => {
|
||||
|
||||
@@ -240,7 +240,6 @@ export interface GuildEmojiRow {
|
||||
name: string;
|
||||
creator_id: UserID;
|
||||
animated: boolean;
|
||||
nsfw: boolean | null;
|
||||
version: number;
|
||||
}
|
||||
|
||||
@@ -250,7 +249,6 @@ export const GUILD_EMOJI_COLUMNS = [
|
||||
'name',
|
||||
'creator_id',
|
||||
'animated',
|
||||
'nsfw',
|
||||
'version',
|
||||
] as const satisfies ReadonlyArray<keyof GuildEmojiRow>;
|
||||
export const GUILD_EMOJI_BY_EMOJI_ID_COLUMNS = [
|
||||
@@ -259,7 +257,6 @@ export const GUILD_EMOJI_BY_EMOJI_ID_COLUMNS = [
|
||||
'name',
|
||||
'creator_id',
|
||||
'animated',
|
||||
'nsfw',
|
||||
] as const satisfies ReadonlyArray<keyof GuildEmojiRow>;
|
||||
|
||||
export interface GuildStickerRow {
|
||||
@@ -268,7 +265,6 @@ export interface GuildStickerRow {
|
||||
name: string;
|
||||
description: Nullish<string>;
|
||||
animated: boolean;
|
||||
nsfw: boolean | null;
|
||||
tags: Nullish<Array<string>>;
|
||||
creator_id: UserID;
|
||||
version: number;
|
||||
@@ -280,7 +276,6 @@ export const GUILD_STICKER_COLUMNS = [
|
||||
'name',
|
||||
'description',
|
||||
'animated',
|
||||
'nsfw',
|
||||
'tags',
|
||||
'creator_id',
|
||||
'version',
|
||||
@@ -291,7 +286,6 @@ export const GUILD_STICKER_BY_STICKER_ID_COLUMNS = [
|
||||
'name',
|
||||
'description',
|
||||
'animated',
|
||||
'nsfw',
|
||||
'tags',
|
||||
'creator_id',
|
||||
] as const satisfies ReadonlyArray<keyof GuildStickerRow>;
|
||||
|
||||
@@ -96,7 +96,6 @@ export interface MessageStickerItem {
|
||||
sticker_id: StickerID;
|
||||
name: string;
|
||||
animated?: boolean;
|
||||
nsfw?: boolean;
|
||||
}
|
||||
|
||||
export interface MessageReference {
|
||||
@@ -148,7 +147,6 @@ export interface MessageRow {
|
||||
message_reference: Nullish<MessageReference>;
|
||||
message_snapshots: Nullish<Array<MessageSnapshot>>;
|
||||
call: Nullish<MessageCall>;
|
||||
nsfw_emojis: Nullish<Set<EmojiID>>;
|
||||
has_reaction: Nullish<boolean>;
|
||||
version: number;
|
||||
}
|
||||
@@ -176,7 +174,6 @@ export const MESSAGE_COLUMNS = [
|
||||
'message_reference',
|
||||
'message_snapshots',
|
||||
'call',
|
||||
'nsfw_emojis',
|
||||
'has_reaction',
|
||||
'version',
|
||||
] as const satisfies ReadonlyArray<keyof MessageRow>;
|
||||
|
||||
@@ -116,7 +116,7 @@ export function mapGuildEmojiToResponse(emoji: GuildEmoji): z.infer<typeof Guild
|
||||
id,
|
||||
name: emoji.name,
|
||||
animated: emoji.isAnimated,
|
||||
nsfw: emoji.isNsfw,
|
||||
nsfw: false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -128,7 +128,7 @@ export function mapGuildStickerToResponse(sticker: GuildSticker): z.infer<typeof
|
||||
description: sticker.description ?? '',
|
||||
tags: sticker.tags,
|
||||
animated: sticker.animated,
|
||||
nsfw: sticker.isNsfw,
|
||||
nsfw: false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -180,7 +180,7 @@ function mapEmojiWithUser(
|
||||
id,
|
||||
name: emoji.name,
|
||||
animated: emoji.isAnimated,
|
||||
nsfw: emoji.isNsfw,
|
||||
nsfw: false,
|
||||
user: userPartial,
|
||||
};
|
||||
}
|
||||
@@ -207,7 +207,7 @@ function mapStickerWithUser(
|
||||
description: sticker.description ?? '',
|
||||
tags: sticker.tags,
|
||||
animated: sticker.animated,
|
||||
nsfw: sticker.isNsfw,
|
||||
nsfw: false,
|
||||
user: userPartial,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -152,6 +152,7 @@ export function GuildEmojiController(app: HonoApp) {
|
||||
app.delete(
|
||||
'/guilds/:guild_id/emojis/:emoji_id',
|
||||
RateLimitMiddleware(RateLimitConfigs.GUILD_EMOJI_DELETE),
|
||||
RateLimitMiddleware(RateLimitConfigs.GUILD_EMOJI_DELETE_DAILY),
|
||||
LoginRequired,
|
||||
Validator('param', GuildIdEmojiIdParam),
|
||||
Validator('query', PurgeQuery),
|
||||
|
||||
@@ -165,6 +165,7 @@ export function GuildStickerController(app: HonoApp) {
|
||||
app.delete(
|
||||
'/guilds/:guild_id/stickers/:sticker_id',
|
||||
RateLimitMiddleware(RateLimitConfigs.GUILD_STICKER_DELETE),
|
||||
RateLimitMiddleware(RateLimitConfigs.GUILD_STICKER_DELETE_DAILY),
|
||||
LoginRequired,
|
||||
Validator('param', GuildIdStickerIdParam),
|
||||
Validator('query', PurgeQuery),
|
||||
|
||||
@@ -109,12 +109,7 @@ export class EmojiService {
|
||||
if (allEmojis.length >= maxEmojis) {
|
||||
throw new MaxGuildEmojisError(maxEmojis);
|
||||
}
|
||||
const {
|
||||
animated,
|
||||
imageBuffer,
|
||||
contentType,
|
||||
nsfw: isNsfw,
|
||||
} = await this.avatarService.processEmoji({
|
||||
const {animated, imageBuffer, contentType} = await this.avatarService.processEmoji({
|
||||
errorPath: 'image',
|
||||
base64Image: image,
|
||||
});
|
||||
@@ -131,7 +126,6 @@ export class EmojiService {
|
||||
name,
|
||||
creator_id: user.id,
|
||||
animated,
|
||||
nsfw: isNsfw,
|
||||
version: 1,
|
||||
});
|
||||
const updatedEmojis = [...allEmojis, emoji];
|
||||
@@ -184,7 +178,6 @@ export class EmojiService {
|
||||
name: sourceEmoji.name,
|
||||
creator_id: user.id,
|
||||
animated: sourceEmoji.isAnimated,
|
||||
nsfw: sourceEmoji.hasNsfwClassification ? sourceEmoji.isNsfw : null,
|
||||
version: 1,
|
||||
});
|
||||
const updatedEmojis = [...allEmojis, emoji];
|
||||
@@ -247,12 +240,7 @@ export class EmojiService {
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const {
|
||||
animated,
|
||||
imageBuffer,
|
||||
contentType,
|
||||
nsfw: isNsfw,
|
||||
} = await this.avatarService.processEmoji({
|
||||
const {animated, imageBuffer, contentType} = await this.avatarService.processEmoji({
|
||||
errorPath: `emojis[${success.length + failed.length}].image`,
|
||||
base64Image: emojiData.image,
|
||||
});
|
||||
@@ -268,7 +256,6 @@ export class EmojiService {
|
||||
emoji_id: emojiId,
|
||||
name: emojiData.name,
|
||||
animated,
|
||||
nsfw: isNsfw,
|
||||
creator_id: user.id,
|
||||
version: 1,
|
||||
});
|
||||
|
||||
@@ -126,11 +126,7 @@ export class StickerService {
|
||||
if (stickerCount >= maxStickers) {
|
||||
throw new MaxGuildStickersStaticError(maxStickers);
|
||||
}
|
||||
const {
|
||||
animated,
|
||||
imageBuffer,
|
||||
nsfw: isNsfw,
|
||||
} = await this.avatarService.processSticker({
|
||||
const {animated, imageBuffer} = await this.avatarService.processSticker({
|
||||
errorPath: 'image',
|
||||
base64Image: image,
|
||||
});
|
||||
@@ -142,7 +138,6 @@ export class StickerService {
|
||||
name,
|
||||
description: description ?? null,
|
||||
animated,
|
||||
nsfw: isNsfw,
|
||||
tags,
|
||||
creator_id: user.id,
|
||||
version: 1,
|
||||
@@ -198,7 +193,6 @@ export class StickerService {
|
||||
name: sourceSticker.name,
|
||||
description: sourceSticker.description,
|
||||
animated: sourceSticker.animated,
|
||||
nsfw: sourceSticker.hasNsfwClassification ? sourceSticker.isNsfw : null,
|
||||
tags: sourceSticker.tags,
|
||||
creator_id: user.id,
|
||||
version: 1,
|
||||
@@ -275,11 +269,7 @@ export class StickerService {
|
||||
contentModerationService.scanText(stickerData.name, bulkStickerModCtx);
|
||||
contentModerationService.scanText(stickerData.description, bulkStickerModCtx);
|
||||
contentModerationService.scanText(stickerData.tags.join(' '), bulkStickerModCtx);
|
||||
const {
|
||||
animated,
|
||||
imageBuffer,
|
||||
nsfw: isNsfw,
|
||||
} = await this.avatarService.processSticker({
|
||||
const {animated, imageBuffer} = await this.avatarService.processSticker({
|
||||
errorPath: `stickers[${success.length + failed.length}].image`,
|
||||
base64Image: stickerData.image,
|
||||
});
|
||||
@@ -292,7 +282,6 @@ export class StickerService {
|
||||
description: stickerData.description ?? null,
|
||||
tags: stickerData.tags,
|
||||
animated,
|
||||
nsfw: isNsfw,
|
||||
creator_id: user.id,
|
||||
version: 1,
|
||||
});
|
||||
|
||||
@@ -204,8 +204,6 @@ export class AvatarService {
|
||||
animated: boolean;
|
||||
format: string;
|
||||
contentType: string;
|
||||
nsfw: boolean;
|
||||
nsfwProbability?: number;
|
||||
}> {
|
||||
const {errorPath, base64Image} = params;
|
||||
const base64Data = base64Image.includes(',') ? base64Image.split(',')[1] : base64Image;
|
||||
@@ -226,7 +224,7 @@ export class AvatarService {
|
||||
type: 'base64',
|
||||
base64: base64Data,
|
||||
version: 2,
|
||||
nsfw: 'flag',
|
||||
nsfw: 'allow',
|
||||
}),
|
||||
kind: 'emoji',
|
||||
errorPath,
|
||||
@@ -237,8 +235,6 @@ export class AvatarService {
|
||||
animated,
|
||||
format: metadata.format,
|
||||
contentType: metadata.content_type,
|
||||
nsfw: metadata.nsfw,
|
||||
nsfwProbability: metadata.nsfw_probability,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -275,8 +271,6 @@ export class AvatarService {
|
||||
animated: boolean;
|
||||
format: string;
|
||||
contentType: string;
|
||||
nsfw: boolean;
|
||||
nsfwProbability?: number;
|
||||
}> {
|
||||
const {errorPath, base64Image} = params;
|
||||
const base64Data = base64Image.includes(',') ? base64Image.split(',')[1] : base64Image;
|
||||
@@ -297,7 +291,7 @@ export class AvatarService {
|
||||
type: 'base64',
|
||||
base64: base64Data,
|
||||
version: 2,
|
||||
nsfw: 'flag',
|
||||
nsfw: 'allow',
|
||||
}),
|
||||
kind: 'sticker',
|
||||
errorPath,
|
||||
@@ -308,8 +302,6 @@ export class AvatarService {
|
||||
animated,
|
||||
format: metadata.format,
|
||||
contentType: metadata.content_type,
|
||||
nsfw: metadata.nsfw,
|
||||
nsfwProbability: metadata.nsfw_probability,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -348,7 +340,7 @@ export class AvatarService {
|
||||
bucket: Config.s3.buckets.cdn,
|
||||
key: `stickers/${stickerId}`,
|
||||
version: 2,
|
||||
nsfw: 'block',
|
||||
nsfw: 'allow',
|
||||
});
|
||||
return metadata?.animated ?? null;
|
||||
} catch (_error) {
|
||||
|
||||
@@ -9,8 +9,6 @@ export class GuildEmoji {
|
||||
readonly name: string;
|
||||
readonly creatorId: UserID;
|
||||
readonly isAnimated: boolean;
|
||||
readonly isNsfw: boolean;
|
||||
readonly hasNsfwClassification: boolean;
|
||||
readonly version: number;
|
||||
|
||||
constructor(row: GuildEmojiRow) {
|
||||
@@ -19,8 +17,6 @@ export class GuildEmoji {
|
||||
this.name = row.name;
|
||||
this.creatorId = row.creator_id;
|
||||
this.isAnimated = row.animated ?? false;
|
||||
this.isNsfw = row.nsfw ?? false;
|
||||
this.hasNsfwClassification = row.nsfw != null;
|
||||
this.version = row.version;
|
||||
}
|
||||
|
||||
@@ -31,7 +27,6 @@ export class GuildEmoji {
|
||||
name: this.name,
|
||||
creator_id: this.creatorId,
|
||||
animated: this.isAnimated,
|
||||
nsfw: this.hasNsfwClassification ? this.isNsfw : null,
|
||||
version: this.version,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -9,8 +9,6 @@ export class GuildSticker {
|
||||
readonly name: string;
|
||||
readonly description: string | null;
|
||||
readonly animated: boolean;
|
||||
readonly isNsfw: boolean;
|
||||
readonly hasNsfwClassification: boolean;
|
||||
readonly tags: Array<string>;
|
||||
readonly creatorId: UserID;
|
||||
readonly version: number;
|
||||
@@ -21,8 +19,6 @@ export class GuildSticker {
|
||||
this.name = row.name;
|
||||
this.description = row.description ?? null;
|
||||
this.animated = row.animated;
|
||||
this.isNsfw = row.nsfw ?? false;
|
||||
this.hasNsfwClassification = row.nsfw !== null && row.nsfw !== undefined;
|
||||
this.tags = row.tags ?? [];
|
||||
this.creatorId = row.creator_id;
|
||||
this.version = row.version;
|
||||
@@ -35,7 +31,6 @@ export class GuildSticker {
|
||||
name: this.name,
|
||||
description: this.description,
|
||||
animated: this.animated,
|
||||
nsfw: this.hasNsfwClassification ? this.isNsfw : null,
|
||||
tags: this.tags.length > 0 ? this.tags : null,
|
||||
creator_id: this.creatorId,
|
||||
version: this.version,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {MessageTypeValue} from '@fluxer/constants/src/ChannelConstants';
|
||||
import type {ChannelID, EmojiID, MessageID, RoleID, UserID, WebhookID} from '../BrandedTypes';
|
||||
import type {ChannelID, MessageID, RoleID, UserID, WebhookID} from '../BrandedTypes';
|
||||
import type {MessageRow} from '../database/types/MessageTypes';
|
||||
import {Attachment} from './Attachment';
|
||||
import {CallInfo} from './CallInfo';
|
||||
@@ -33,7 +33,6 @@ export class Message {
|
||||
readonly reference: MessageRef | null;
|
||||
readonly messageSnapshots: Array<MessageSnapshot>;
|
||||
readonly call: CallInfo | null;
|
||||
readonly nsfwEmojis: Set<EmojiID>;
|
||||
readonly hasReaction: boolean | null;
|
||||
readonly version: number;
|
||||
|
||||
@@ -66,7 +65,6 @@ export class Message {
|
||||
this.reference = row.message_reference ? new MessageRef(row.message_reference) : null;
|
||||
this.messageSnapshots = (row.message_snapshots ?? []).map((snapshot) => new MessageSnapshot(snapshot));
|
||||
this.call = row.call ? new CallInfo(row.call) : null;
|
||||
this.nsfwEmojis = row.nsfw_emojis ?? new Set();
|
||||
this.hasReaction = row.has_reaction ?? null;
|
||||
this.version = row.version;
|
||||
}
|
||||
@@ -96,7 +94,6 @@ export class Message {
|
||||
message_snapshots:
|
||||
this.messageSnapshots.length > 0 ? this.messageSnapshots.map((snapshot) => snapshot.toMessageSnapshot()) : null,
|
||||
call: this.call?.toMessageCall() ?? null,
|
||||
nsfw_emojis: this.nsfwEmojis.size > 0 ? this.nsfwEmojis : null,
|
||||
has_reaction: this.hasReaction ?? null,
|
||||
version: this.version,
|
||||
};
|
||||
|
||||
@@ -7,13 +7,11 @@ export class StickerItem {
|
||||
readonly id: StickerID;
|
||||
readonly name: string;
|
||||
readonly animated: boolean;
|
||||
readonly nsfw: boolean;
|
||||
|
||||
constructor(sticker: MessageStickerItem) {
|
||||
this.id = sticker.sticker_id;
|
||||
this.name = sticker.name;
|
||||
this.animated = sticker.animated ?? false;
|
||||
this.nsfw = sticker.nsfw ?? false;
|
||||
}
|
||||
|
||||
toMessageStickerItem(): MessageStickerItem {
|
||||
@@ -21,7 +19,6 @@ export class StickerItem {
|
||||
sticker_id: this.id,
|
||||
name: this.name,
|
||||
animated: this.animated,
|
||||
...(this.nsfw ? {nsfw: true} : {}),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,32 +5,15 @@ import {createStickerID} from '../../BrandedTypes';
|
||||
import {StickerItem} from '../StickerItem';
|
||||
|
||||
describe('StickerItem', () => {
|
||||
it('omits nsfw when it is false', () => {
|
||||
it('defaults animated to false when it is omitted', () => {
|
||||
const sticker = new StickerItem({
|
||||
sticker_id: createStickerID(1n),
|
||||
name: 'party-parrot',
|
||||
animated: false,
|
||||
});
|
||||
const serialized = sticker.toMessageStickerItem();
|
||||
expect(serialized).toEqual({
|
||||
sticker_id: createStickerID(1n),
|
||||
name: 'party-parrot',
|
||||
animated: false,
|
||||
});
|
||||
expect(serialized).not.toHaveProperty('nsfw');
|
||||
});
|
||||
it('preserves nsfw when it is true', () => {
|
||||
const sticker = new StickerItem({
|
||||
sticker_id: createStickerID(2n),
|
||||
name: 'spicy-parrot',
|
||||
animated: true,
|
||||
nsfw: true,
|
||||
});
|
||||
expect(sticker.toMessageStickerItem()).toEqual({
|
||||
sticker_id: createStickerID(2n),
|
||||
name: 'spicy-parrot',
|
||||
animated: true,
|
||||
nsfw: true,
|
||||
sticker_id: createStickerID(1n),
|
||||
name: 'party-parrot',
|
||||
animated: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18400,6 +18400,79 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/users/@me/mfa/backup-codes/challenge/regenerate": {
|
||||
"post": {
|
||||
"operationId": "regenerate_backup_codes_challenge",
|
||||
"summary": "Regenerate backup codes with a verified challenge",
|
||||
"tags": ["Users"],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/MfaBackupCodesResponse"}}}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request - The request was malformed or contained invalid data",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized - Authentication is required or the token is invalid",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
},
|
||||
"403": {
|
||||
"description": "Forbidden - You do not have permission to perform this action",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
},
|
||||
"429": {
|
||||
"description": "Too Many Requests - You are being rate limited",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"code": {"type": "string", "enum": ["RATE_LIMITED"]},
|
||||
"message": {"type": "string"},
|
||||
"retry_after": {"type": "number", "description": "Seconds to wait before retrying"},
|
||||
"global": {"type": "boolean", "description": "Whether this is a global rate limit"}
|
||||
},
|
||||
"required": ["code", "message", "retry_after"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"headers": {
|
||||
"Retry-After": {
|
||||
"description": "Number of seconds to wait before retrying (only on 429)",
|
||||
"schema": {"type": "integer"}
|
||||
},
|
||||
"X-RateLimit-Limit": {
|
||||
"description": "The number of requests that can be made in the current window",
|
||||
"schema": {"type": "integer"}
|
||||
},
|
||||
"X-RateLimit-Remaining": {
|
||||
"description": "The number of remaining requests that can be made",
|
||||
"schema": {"type": "integer"}
|
||||
},
|
||||
"X-RateLimit-Reset": {
|
||||
"description": "Unix timestamp when the rate limit resets",
|
||||
"schema": {"type": "integer"}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error - An unexpected error occurred",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
}
|
||||
},
|
||||
"x-mint": {"metadata": {"title": "Regenerate backup codes with a verified challenge"}},
|
||||
"description": "Replaces the account backup codes using the proof token from a verified backup codes challenge. Old codes are invalidated.",
|
||||
"security": [{"sessionToken": []}],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {"schema": {"$ref": "#/components/schemas/MfaBackupCodesChallengeRegenerateRequest"}}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/users/@me/mfa/backup-codes/challenge/resend": {
|
||||
"post": {
|
||||
"operationId": "resend_backup_codes_challenge",
|
||||
@@ -18478,7 +18551,9 @@
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/MfaBackupCodesResponse"}}}
|
||||
"content": {
|
||||
"application/json": {"schema": {"$ref": "#/components/schemas/MfaBackupCodesChallengeVerifyResponse"}}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request - The request was malformed or contained invalid data",
|
||||
@@ -18533,7 +18608,7 @@
|
||||
}
|
||||
},
|
||||
"x-mint": {"metadata": {"title": "Verify backup codes challenge code"}},
|
||||
"description": "Verifies the email code sent during a backup codes challenge and returns the existing backup codes. The ticket is consumed on success.",
|
||||
"description": "Verifies the email code sent during a backup codes challenge and returns the existing backup codes along with a proof token. The code is consumed on success and the proof token authorizes regeneration on the same ticket.",
|
||||
"security": [{"sessionToken": []}],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
@@ -23477,7 +23552,6 @@
|
||||
"NOT_FRIENDS_WITH_USER",
|
||||
"NOT_OWNER_OF_ADMIN_API_KEY",
|
||||
"NSFW_CONTENT_AGE_RESTRICTED",
|
||||
"NSFW_EMOJI_STICKER_BLOCKED",
|
||||
"PASSKEY_AUTHENTICATION_FAILED",
|
||||
"PASSKEYS_DISABLED",
|
||||
"PHONE_ADD_NOT_ELIGIBLE",
|
||||
@@ -23786,7 +23860,6 @@
|
||||
"You're not friends with this user.",
|
||||
"You don't have permission to modify this admin API key.",
|
||||
"NSFW content is age restricted.",
|
||||
"This emoji or sticker is classified as NSFW and cannot be used in this context.",
|
||||
"Passkey authentication failed.",
|
||||
"Passkeys are disabled.",
|
||||
"You are not eligible to add a phone number to your account.",
|
||||
@@ -24003,7 +24076,6 @@
|
||||
"maximum": 2147483647,
|
||||
"x-bitflagValues": [
|
||||
{"name": "STAFF", "value": "1", "description": "User is a staff member"},
|
||||
{"name": "CTP_MEMBER", "value": "2", "description": "User is a CTP member"},
|
||||
{"name": "PARTNER", "value": "4", "description": "User is a partner"},
|
||||
{"name": "BUG_HUNTER", "value": "8", "description": "User is a bug hunter"},
|
||||
{"name": "FRIENDLY_BOT", "value": "16", "description": "Bot accepts friend requests from users"},
|
||||
@@ -24737,11 +24809,6 @@
|
||||
],
|
||||
"description": "The stickers sent with the message"
|
||||
},
|
||||
"nsfw_emojis": {
|
||||
"type": "array",
|
||||
"items": {"$ref": "#/components/schemas/SnowflakeType"},
|
||||
"description": "IDs of custom emojis in this message that are classified as NSFW"
|
||||
},
|
||||
"reactions": {
|
||||
"anyOf": [
|
||||
{"type": "array", "items": {"$ref": "#/components/schemas/MessageReactionResponse"}},
|
||||
@@ -24915,11 +24982,6 @@
|
||||
],
|
||||
"description": "The stickers sent with the message"
|
||||
},
|
||||
"nsfw_emojis": {
|
||||
"type": "array",
|
||||
"items": {"$ref": "#/components/schemas/SnowflakeType"},
|
||||
"description": "IDs of custom emojis in this message that are classified as NSFW"
|
||||
},
|
||||
"reactions": {
|
||||
"anyOf": [
|
||||
{"type": "array", "items": {"$ref": "#/components/schemas/MessageReactionResponse"}},
|
||||
@@ -25409,8 +25471,7 @@
|
||||
"description": "The unique identifier of the sticker"
|
||||
},
|
||||
"name": {"type": "string", "description": "The name of the sticker"},
|
||||
"animated": {"type": "boolean", "description": "Whether the sticker is animated"},
|
||||
"nsfw": {"type": "boolean", "description": "Whether this sticker is classified as NSFW"}
|
||||
"animated": {"type": "boolean", "description": "Whether the sticker is animated"}
|
||||
},
|
||||
"required": ["id", "name", "animated"]
|
||||
},
|
||||
@@ -26975,11 +27036,6 @@
|
||||
],
|
||||
"description": "The stickers sent with the message"
|
||||
},
|
||||
"nsfw_emojis": {
|
||||
"type": "array",
|
||||
"items": {"$ref": "#/components/schemas/SnowflakeType"},
|
||||
"description": "IDs of custom emojis in this message that are classified as NSFW"
|
||||
},
|
||||
"message_reference": {
|
||||
"anyOf": [
|
||||
{
|
||||
@@ -27869,7 +27925,10 @@
|
||||
},
|
||||
"name": {"type": "string", "description": "The name of the emoji"},
|
||||
"animated": {"type": "boolean", "description": "Whether this emoji is animated"},
|
||||
"nsfw": {"type": "boolean", "description": "Whether this emoji is classified as NSFW"}
|
||||
"nsfw": {
|
||||
"type": "boolean",
|
||||
"description": "Deprecated; always false. Retained for compatibility with older clients"
|
||||
}
|
||||
},
|
||||
"required": ["id", "name", "animated", "nsfw"]
|
||||
},
|
||||
@@ -27890,7 +27949,10 @@
|
||||
"description": "Autocomplete/suggestion tags for the sticker"
|
||||
},
|
||||
"animated": {"type": "boolean", "description": "Whether this sticker is animated"},
|
||||
"nsfw": {"type": "boolean", "description": "Whether this sticker is classified as NSFW"}
|
||||
"nsfw": {
|
||||
"type": "boolean",
|
||||
"description": "Deprecated; always false. Retained for compatibility with older clients"
|
||||
}
|
||||
},
|
||||
"required": ["id", "name", "description", "tags", "animated", "nsfw"]
|
||||
},
|
||||
@@ -28876,7 +28938,10 @@
|
||||
},
|
||||
"name": {"type": "string", "description": "The name of the emoji"},
|
||||
"animated": {"type": "boolean", "description": "Whether this emoji is animated"},
|
||||
"nsfw": {"type": "boolean", "description": "Whether this emoji is classified as NSFW"},
|
||||
"nsfw": {
|
||||
"type": "boolean",
|
||||
"description": "Deprecated; always false. Retained for compatibility with older clients"
|
||||
},
|
||||
"user": {"$ref": "#/components/schemas/UserPartialResponse"}
|
||||
},
|
||||
"required": ["id", "name", "animated", "nsfw", "user"]
|
||||
@@ -29376,7 +29441,10 @@
|
||||
"description": "Autocomplete/suggestion tags for the sticker"
|
||||
},
|
||||
"animated": {"type": "boolean", "description": "Whether this sticker is animated"},
|
||||
"nsfw": {"type": "boolean", "description": "Whether this sticker is classified as NSFW"},
|
||||
"nsfw": {
|
||||
"type": "boolean",
|
||||
"description": "Deprecated; always false. Retained for compatibility with older clients"
|
||||
},
|
||||
"user": {"$ref": "#/components/schemas/UserPartialResponse"}
|
||||
},
|
||||
"required": ["id", "name", "description", "tags", "animated", "nsfw", "user"]
|
||||
@@ -31429,11 +31497,6 @@
|
||||
],
|
||||
"description": "The stickers sent with the message"
|
||||
},
|
||||
"nsfw_emojis": {
|
||||
"type": "array",
|
||||
"items": {"$ref": "#/components/schemas/SnowflakeType"},
|
||||
"description": "IDs of custom emojis in this message that are classified as NSFW"
|
||||
},
|
||||
"reactions": {
|
||||
"anyOf": [
|
||||
{"type": "array", "items": {"$ref": "#/components/schemas/MessageReactionResponse"}},
|
||||
@@ -33526,11 +33589,41 @@
|
||||
},
|
||||
"required": ["ticket", "code_expires_at", "resend_available_at"]
|
||||
},
|
||||
"MfaBackupCodesChallengeRegenerateRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"ticket": {"type": "string", "description": "Backup codes challenge ticket identifier"},
|
||||
"verification_proof": {"type": "string", "description": "Proof token obtained from verifying the email code"}
|
||||
},
|
||||
"required": ["ticket", "verification_proof"]
|
||||
},
|
||||
"MfaBackupCodesChallengeResendRequest": {
|
||||
"type": "object",
|
||||
"properties": {"ticket": {"type": "string", "description": "Backup codes challenge ticket identifier"}},
|
||||
"required": ["ticket"]
|
||||
},
|
||||
"MfaBackupCodesChallengeVerifyResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"backup_codes": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"code": {"type": "string", "description": "The backup code"},
|
||||
"consumed": {"type": "boolean", "description": "Whether the code has been used"}
|
||||
},
|
||||
"required": ["code", "consumed"]
|
||||
},
|
||||
"description": "List of backup codes"
|
||||
},
|
||||
"verification_proof": {
|
||||
"type": "string",
|
||||
"description": "Proof token authorizing backup code regeneration on this ticket"
|
||||
}
|
||||
},
|
||||
"required": ["backup_codes", "verification_proof"]
|
||||
},
|
||||
"MfaBackupCodesChallengeVerifyRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
@@ -110,7 +110,7 @@ export const GuildRateLimitConfigs = {
|
||||
} as RouteRateLimitConfig,
|
||||
GUILD_EMOJI_CREATE: {
|
||||
bucket: 'guild:emoji:create::guild_id',
|
||||
config: {limit: 20, windowMs: ms('10 seconds')},
|
||||
config: {limit: 10, windowMs: ms('30 seconds')},
|
||||
} as RouteRateLimitConfig,
|
||||
GUILD_EMOJI_BULK_CREATE: {
|
||||
bucket: 'guild:emoji:bulk_create::guild_id',
|
||||
@@ -118,7 +118,7 @@ export const GuildRateLimitConfigs = {
|
||||
} as RouteRateLimitConfig,
|
||||
GUILD_EMOJI_CLONE: {
|
||||
bucket: 'guild:emoji:clone::guild_id',
|
||||
config: {limit: 20, windowMs: ms('10 seconds')},
|
||||
config: {limit: 10, windowMs: ms('30 seconds')},
|
||||
} as RouteRateLimitConfig,
|
||||
GUILD_EMOJI_UPDATE: {
|
||||
bucket: 'guild:emoji:update::guild_id',
|
||||
@@ -126,7 +126,11 @@ export const GuildRateLimitConfigs = {
|
||||
} as RouteRateLimitConfig,
|
||||
GUILD_EMOJI_DELETE: {
|
||||
bucket: 'guild:emoji:delete::guild_id',
|
||||
config: {limit: 20, windowMs: ms('10 seconds')},
|
||||
config: {limit: 10, windowMs: ms('30 seconds')},
|
||||
} as RouteRateLimitConfig,
|
||||
GUILD_EMOJI_DELETE_DAILY: {
|
||||
bucket: 'guild:emoji:delete:daily::guild_id',
|
||||
config: {limit: 300, windowMs: ms('1 day')},
|
||||
} as RouteRateLimitConfig,
|
||||
GUILD_EMOJI_METADATA: {
|
||||
bucket: 'guild:emoji:metadata::user_id',
|
||||
@@ -138,7 +142,7 @@ export const GuildRateLimitConfigs = {
|
||||
} as RouteRateLimitConfig,
|
||||
GUILD_STICKER_CREATE: {
|
||||
bucket: 'guild:sticker:create::guild_id',
|
||||
config: {limit: 20, windowMs: ms('10 seconds')},
|
||||
config: {limit: 10, windowMs: ms('30 seconds')},
|
||||
} as RouteRateLimitConfig,
|
||||
GUILD_STICKER_BULK_CREATE: {
|
||||
bucket: 'guild:sticker:bulk_create::guild_id',
|
||||
@@ -146,7 +150,7 @@ export const GuildRateLimitConfigs = {
|
||||
} as RouteRateLimitConfig,
|
||||
GUILD_STICKER_CLONE: {
|
||||
bucket: 'guild:sticker:clone::guild_id',
|
||||
config: {limit: 20, windowMs: ms('10 seconds')},
|
||||
config: {limit: 10, windowMs: ms('30 seconds')},
|
||||
} as RouteRateLimitConfig,
|
||||
GUILD_STICKER_UPDATE: {
|
||||
bucket: 'guild:sticker:update::guild_id',
|
||||
@@ -154,7 +158,11 @@ export const GuildRateLimitConfigs = {
|
||||
} as RouteRateLimitConfig,
|
||||
GUILD_STICKER_DELETE: {
|
||||
bucket: 'guild:sticker:delete::guild_id',
|
||||
config: {limit: 20, windowMs: ms('10 seconds')},
|
||||
config: {limit: 10, windowMs: ms('30 seconds')},
|
||||
} as RouteRateLimitConfig,
|
||||
GUILD_STICKER_DELETE_DAILY: {
|
||||
bucket: 'guild:sticker:delete:daily::guild_id',
|
||||
config: {limit: 300, windowMs: ms('1 day')},
|
||||
} as RouteRateLimitConfig,
|
||||
GUILD_STICKER_METADATA: {
|
||||
bucket: 'guild:sticker:metadata::user_id',
|
||||
|
||||
@@ -204,6 +204,10 @@ export const UserRateLimitConfigs = {
|
||||
bucket: 'user:mfa:backup_codes_challenge:verify',
|
||||
config: {limit: 20, windowMs: ms('1 minute')},
|
||||
} as RouteRateLimitConfig,
|
||||
USER_MFA_BACKUP_CODES_CHALLENGE_REGENERATE: {
|
||||
bucket: 'user:mfa:backup_codes_challenge:regenerate',
|
||||
config: {limit: 6, windowMs: ms('1 minute')},
|
||||
} as RouteRateLimitConfig,
|
||||
USER_PUSH_SUBSCRIBE: {
|
||||
bucket: 'user:push:subscribe',
|
||||
config: {limit: 20, windowMs: ms('1 minute')},
|
||||
|
||||
@@ -307,7 +307,6 @@ export class RpcService {
|
||||
name: sticker.name,
|
||||
description: sticker.description,
|
||||
animated,
|
||||
nsfw: sticker.isNsfw,
|
||||
tags: sticker.tags,
|
||||
creator_id: sticker.creatorId,
|
||||
version: sticker.version,
|
||||
|
||||
@@ -84,7 +84,6 @@ describe('MessageSearchSerializer', () => {
|
||||
message_reference: null,
|
||||
message_snapshots: [],
|
||||
call: null,
|
||||
nsfw_emojis: null,
|
||||
has_reaction: null,
|
||||
version: 1,
|
||||
};
|
||||
@@ -176,7 +175,6 @@ describe('MessageSearchSerializer', () => {
|
||||
},
|
||||
],
|
||||
call: null,
|
||||
nsfw_emojis: null,
|
||||
has_reaction: null,
|
||||
version: 1,
|
||||
};
|
||||
@@ -255,7 +253,6 @@ describe('MessageSearchSerializer', () => {
|
||||
},
|
||||
],
|
||||
call: null,
|
||||
nsfw_emojis: null,
|
||||
has_reaction: null,
|
||||
version: 1,
|
||||
};
|
||||
|
||||
@@ -2261,7 +2261,6 @@ export function TestHarnessController(app: HonoApp) {
|
||||
message_reference: null,
|
||||
message_snapshots: null,
|
||||
call: null,
|
||||
nsfw_emojis: null,
|
||||
has_reaction: false,
|
||||
version: 1,
|
||||
};
|
||||
|
||||
@@ -255,7 +255,6 @@ export class RepositoryBackedMessageResponseDataService extends MessageResponseD
|
||||
embeds: this.mapEmbeds(message.embeds, message),
|
||||
attachments: await this.mapAttachments(message.attachments, message),
|
||||
stickers: this.mapStickers(message.stickers),
|
||||
nsfw_emojis: message.nsfwEmojis.size > 0 ? [...message.nsfwEmojis].map((id) => id.toString()) : undefined,
|
||||
reactions,
|
||||
message_reference: message.reference
|
||||
? {
|
||||
@@ -481,7 +480,6 @@ export class RepositoryBackedMessageResponseDataService extends MessageResponseD
|
||||
id: sticker.id.toString(),
|
||||
name: sticker.name,
|
||||
animated: sticker.animated,
|
||||
nsfw: sticker.nsfw,
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
@@ -275,7 +275,7 @@ export function createPremiumClearPatch(): Partial<UserRow> {
|
||||
return mapExpiredPremiumFields(() => null) as Partial<UserRow>;
|
||||
}
|
||||
|
||||
const PROFILE_SUBSTRING_EXEMPT_FLAGS = UserFlags.STAFF | UserFlags.CTP_MEMBER;
|
||||
const PROFILE_SUBSTRING_EXEMPT_FLAGS = UserFlags.STAFF;
|
||||
|
||||
export function isProfileSubstringExempt(user: Pick<PremiumCheckable, 'flags'>): boolean {
|
||||
return (user.flags & PROFILE_SUBSTRING_EXEMPT_FLAGS) !== 0n;
|
||||
|
||||
@@ -5,9 +5,11 @@ import {
|
||||
DisableTotpRequest,
|
||||
EnableMfaTotpRequest,
|
||||
InboundSmsChallengeStartResponse,
|
||||
MfaBackupCodesChallengeRegenerateRequest,
|
||||
MfaBackupCodesChallengeResendRequest,
|
||||
MfaBackupCodesChallengeStartResponse,
|
||||
MfaBackupCodesChallengeVerifyRequest,
|
||||
MfaBackupCodesChallengeVerifyResponse,
|
||||
MfaBackupCodesRequest,
|
||||
MfaBackupCodesResponse,
|
||||
PhoneSendVerificationRequest,
|
||||
@@ -168,12 +170,12 @@ export function UserAuthController(app: HonoApp) {
|
||||
OpenAPI({
|
||||
operationId: 'verify_backup_codes_challenge',
|
||||
summary: 'Verify backup codes challenge code',
|
||||
responseSchema: MfaBackupCodesResponse,
|
||||
responseSchema: MfaBackupCodesChallengeVerifyResponse,
|
||||
statusCode: 200,
|
||||
security: ['bearerToken', 'sessionToken'],
|
||||
tags: ['Users'],
|
||||
description:
|
||||
'Verifies the email code sent during a backup codes challenge and returns the existing backup codes. The ticket is consumed on success.',
|
||||
'Verifies the email code sent during a backup codes challenge and returns the existing backup codes along with a proof token. The code is consumed on success and the proof token authorizes regeneration on the same ticket.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
@@ -181,6 +183,30 @@ export function UserAuthController(app: HonoApp) {
|
||||
return ctx.json(await ctx.get('mfaBackupCodesChallengeService').verify(user, body.ticket, body.code));
|
||||
},
|
||||
);
|
||||
app.post(
|
||||
'/users/@me/mfa/backup-codes/challenge/regenerate',
|
||||
RateLimitMiddleware(RateLimitConfigs.USER_MFA_BACKUP_CODES_CHALLENGE_REGENERATE),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
Validator('json', MfaBackupCodesChallengeRegenerateRequest),
|
||||
OpenAPI({
|
||||
operationId: 'regenerate_backup_codes_challenge',
|
||||
summary: 'Regenerate backup codes with a verified challenge',
|
||||
responseSchema: MfaBackupCodesResponse,
|
||||
statusCode: 200,
|
||||
security: ['bearerToken', 'sessionToken'],
|
||||
tags: ['Users'],
|
||||
description:
|
||||
'Replaces the account backup codes using the proof token from a verified backup codes challenge. Old codes are invalidated.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const body = ctx.req.valid('json');
|
||||
return ctx.json(
|
||||
await ctx.get('mfaBackupCodesChallengeService').regenerate(user, body.ticket, body.verification_proof),
|
||||
);
|
||||
},
|
||||
);
|
||||
app.post(
|
||||
'/users/@me/phone/send-verification',
|
||||
RateLimitMiddleware(RateLimitConfigs.PHONE_SEND_VERIFICATION),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {randomUUID} from 'node:crypto';
|
||||
import {randomUUID, timingSafeEqual} from 'node:crypto';
|
||||
import {
|
||||
assertChangeCooldown,
|
||||
checkChangeRateLimit,
|
||||
@@ -10,17 +10,23 @@ import {UserAuthenticatorTypes} from '@fluxer/constants/src/UserConstants';
|
||||
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
|
||||
import {MfaNotEnabledError} from '@fluxer/errors/src/domains/auth/MfaNotEnabledError';
|
||||
import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError';
|
||||
import type {MfaBackupCodesResponse} from '@fluxer/schema/src/domains/auth/AuthSchemas';
|
||||
import type {
|
||||
MfaBackupCodesChallengeVerifyResponse,
|
||||
MfaBackupCodesResponse,
|
||||
} from '@fluxer/schema/src/domains/auth/AuthSchemas';
|
||||
import {ms} from 'itty-time';
|
||||
import type {ApiContext} from '../../ApiContext';
|
||||
import {requireEmailVerified} from '../../auth/EmailVerificationUtils';
|
||||
import type {MfaBackupCode} from '../../models/MfaBackupCode';
|
||||
import type {User} from '../../models/User';
|
||||
import {regenerateMfaBackupCodes} from './UserAuth';
|
||||
|
||||
interface MfaBackupCodesChallengeTicket {
|
||||
user_id: string;
|
||||
code: string;
|
||||
code: string | null;
|
||||
code_sent_at: number;
|
||||
code_expires_at: number;
|
||||
verification_proof: string | null;
|
||||
}
|
||||
|
||||
interface StartMfaBackupCodesChallengeResult {
|
||||
@@ -37,6 +43,15 @@ function normalizeVerificationCode(code: string): string {
|
||||
return code.trim().toUpperCase().replaceAll('-', '');
|
||||
}
|
||||
|
||||
function constantTimeEquals(a: string, b: string): boolean {
|
||||
const bufferA = Buffer.from(a);
|
||||
const bufferB = Buffer.from(b);
|
||||
if (bufferA.length !== bufferB.length) {
|
||||
return false;
|
||||
}
|
||||
return timingSafeEqual(bufferA, bufferB);
|
||||
}
|
||||
|
||||
export class MfaBackupCodesChallengeService {
|
||||
private readonly CODE_TTL_MS = ms('10 minutes');
|
||||
private readonly TICKET_TTL_MS = ms('30 minutes');
|
||||
@@ -68,6 +83,7 @@ export class MfaBackupCodesChallengeService {
|
||||
code,
|
||||
code_sent_at: now,
|
||||
code_expires_at: codeExpiresAt,
|
||||
verification_proof: null,
|
||||
});
|
||||
return {
|
||||
ticket,
|
||||
@@ -97,25 +113,54 @@ export class MfaBackupCodesChallengeService {
|
||||
code,
|
||||
code_sent_at: now,
|
||||
code_expires_at: codeExpiresAt,
|
||||
verification_proof: null,
|
||||
});
|
||||
}
|
||||
|
||||
async verify(user: User, ticket: string, code: string): Promise<MfaBackupCodesResponse> {
|
||||
const {cache, rateLimit, users} = this.apiContext.services;
|
||||
async verify(user: User, ticket: string, code: string): Promise<MfaBackupCodesChallengeVerifyResponse> {
|
||||
const {rateLimit, users} = this.apiContext.services;
|
||||
const state = await this.getTicketForUser(ticket, user);
|
||||
await checkChangeRateLimit(rateLimit, {
|
||||
identifier: `mfa_backup_codes_challenge:verify:${ticket}`,
|
||||
maxAttempts: 5,
|
||||
windowMs: ms('15 minutes'),
|
||||
});
|
||||
if (!state.code) {
|
||||
throw InputValidationError.fromCode('code', ValidationErrorCodes.VERIFICATION_CODE_NOT_ISSUED);
|
||||
}
|
||||
if (state.code_expires_at < Date.now()) {
|
||||
throw InputValidationError.fromCode('code', ValidationErrorCodes.VERIFICATION_CODE_EXPIRED);
|
||||
}
|
||||
if (normalizeVerificationCode(state.code) !== normalizeVerificationCode(code)) {
|
||||
throw InputValidationError.fromCode('code', ValidationErrorCodes.INVALID_VERIFICATION_CODE);
|
||||
}
|
||||
await cache.delete(getChallengeCacheKey(ticket));
|
||||
const verificationProof = randomUUID();
|
||||
await this.storeTicket(ticket, {...state, code: null, verification_proof: verificationProof});
|
||||
const backupCodes = await users.listMfaBackupCodes(user.id);
|
||||
return {...this.toResponse(backupCodes), verification_proof: verificationProof};
|
||||
}
|
||||
|
||||
async regenerate(user: User, ticket: string, verificationProof: string): Promise<MfaBackupCodesResponse> {
|
||||
const {rateLimit} = this.apiContext.services;
|
||||
const state = await this.getTicketForUser(ticket, user);
|
||||
await checkChangeRateLimit(rateLimit, {
|
||||
identifier: `mfa_backup_codes_challenge:regenerate:${ticket}`,
|
||||
maxAttempts: 5,
|
||||
windowMs: ms('15 minutes'),
|
||||
});
|
||||
if (!user.totpSecret || !user.authenticatorTypes.has(UserAuthenticatorTypes.TOTP)) {
|
||||
throw new MfaNotEnabledError();
|
||||
}
|
||||
if (!state.verification_proof) {
|
||||
throw InputValidationError.fromCode('verification_proof', ValidationErrorCodes.INVALID_OR_EXPIRED_TICKET);
|
||||
}
|
||||
if (!constantTimeEquals(state.verification_proof, verificationProof)) {
|
||||
throw InputValidationError.fromCode('verification_proof', ValidationErrorCodes.INVALID_PROOF_TOKEN);
|
||||
}
|
||||
return this.toResponse(await regenerateMfaBackupCodes(this.apiContext, user));
|
||||
}
|
||||
|
||||
private toResponse(backupCodes: Array<MfaBackupCode>): MfaBackupCodesResponse {
|
||||
return {
|
||||
backup_codes: backupCodes.map((backupCode) => ({
|
||||
code: backupCode.code,
|
||||
|
||||
@@ -118,7 +118,7 @@ export async function getMfaBackupCodes(
|
||||
return await users.listMfaBackupCodes(user.id);
|
||||
}
|
||||
|
||||
async function regenerateMfaBackupCodes(ctx: ApiContext, user: User): Promise<Array<MfaBackupCode>> {
|
||||
export async function regenerateMfaBackupCodes(ctx: ApiContext, user: User): Promise<Array<MfaBackupCode>> {
|
||||
const {users} = ctx.services;
|
||||
const userId = user.id;
|
||||
const newBackupCodes = AuthUtility.generateBackupCodes(ctx);
|
||||
|
||||
@@ -516,7 +516,6 @@ export class UserChannelService {
|
||||
message_reference: null,
|
||||
message_snapshots: null,
|
||||
call: null,
|
||||
nsfw_emojis: null,
|
||||
has_reaction: false,
|
||||
version: 1,
|
||||
});
|
||||
|
||||
@@ -70,7 +70,6 @@ function makeMessage(channelId: ChannelID, messageId: MessageID): Message {
|
||||
message_reference: null,
|
||||
message_snapshots: null,
|
||||
call: null,
|
||||
nsfw_emojis: null,
|
||||
has_reaction: null,
|
||||
version: 1,
|
||||
});
|
||||
|
||||
@@ -316,7 +316,6 @@ export async function processUserDeletion(
|
||||
message_reference: null,
|
||||
message_snapshots: null,
|
||||
call: null,
|
||||
nsfw_emojis: null,
|
||||
has_reaction: false,
|
||||
version: 1,
|
||||
});
|
||||
|
||||
@@ -25,6 +25,14 @@ interface ChallengeVerifyResult {
|
||||
code: string;
|
||||
consumed: boolean;
|
||||
}>;
|
||||
verification_proof: string;
|
||||
}
|
||||
|
||||
interface ChallengeRegenerateResult {
|
||||
backup_codes: Array<{
|
||||
code: string;
|
||||
consumed: boolean;
|
||||
}>;
|
||||
}
|
||||
|
||||
interface ValidationErrorBody {
|
||||
@@ -34,8 +42,11 @@ interface ValidationErrorBody {
|
||||
}>;
|
||||
}
|
||||
|
||||
async function enableTotp(harness: ApiTestHarness, account: TestAccount): Promise<Array<string>> {
|
||||
const secret = createTotpSecret();
|
||||
async function enableTotpWithSecret(
|
||||
harness: ApiTestHarness,
|
||||
account: TestAccount,
|
||||
secret: string,
|
||||
): Promise<Array<string>> {
|
||||
const enabled = await createBuilder<BackupCodesResponse>(harness, account.token)
|
||||
.post('/users/@me/mfa/totp/enable')
|
||||
.body({secret, code: totpCodeNow(secret), password: account.password})
|
||||
@@ -43,6 +54,10 @@ async function enableTotp(harness: ApiTestHarness, account: TestAccount): Promis
|
||||
return enabled.backup_codes.map((backupCode) => backupCode.code);
|
||||
}
|
||||
|
||||
async function enableTotp(harness: ApiTestHarness, account: TestAccount): Promise<Array<string>> {
|
||||
return enableTotpWithSecret(harness, account, createTotpSecret());
|
||||
}
|
||||
|
||||
async function createTotpAccount(harness: ApiTestHarness): Promise<{
|
||||
account: TestAccount;
|
||||
backupCodes: Array<string>;
|
||||
@@ -71,6 +86,18 @@ async function verifyChallenge(
|
||||
.execute();
|
||||
}
|
||||
|
||||
async function regenerateWithChallenge(
|
||||
harness: ApiTestHarness,
|
||||
token: string,
|
||||
ticket: string,
|
||||
verificationProof: string,
|
||||
): Promise<ChallengeRegenerateResult> {
|
||||
return createBuilder<ChallengeRegenerateResult>(harness, token)
|
||||
.post('/users/@me/mfa/backup-codes/challenge/regenerate')
|
||||
.body({ticket, verification_proof: verificationProof})
|
||||
.execute();
|
||||
}
|
||||
|
||||
async function getChallengeCode(harness: ApiTestHarness, email: string): Promise<string> {
|
||||
const emails = await listTestEmails(harness, {recipient: email});
|
||||
const record = findLastTestEmail(emails, 'mfa_backup_codes_view');
|
||||
@@ -184,17 +211,18 @@ describe('MfaBackupCodesChallengeFlow', () => {
|
||||
const result = await verifyChallenge(harness, account.token, startResult.ticket, mangled);
|
||||
expect(result.backup_codes.map((backupCode) => backupCode.code).sort()).toEqual([...backupCodes].sort());
|
||||
});
|
||||
test('rejects reusing a ticket that was already verified', async () => {
|
||||
test('consumes the emailed code so it cannot be verified twice', async () => {
|
||||
const {account} = await createTotpAccount(harness);
|
||||
const startResult = await startChallenge(harness, account.token);
|
||||
const code = await getChallengeCode(harness, account.email);
|
||||
await verifyChallenge(harness, account.token, startResult.ticket, code);
|
||||
const first = await verifyChallenge(harness, account.token, startResult.ticket, code);
|
||||
expect(first.verification_proof).toBeTruthy();
|
||||
const {json} = await createBuilder(harness, account.token)
|
||||
.post('/users/@me/mfa/backup-codes/challenge/verify')
|
||||
.body({ticket: startResult.ticket, code})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST, 'INVALID_FORM_BODY')
|
||||
.executeWithResponse();
|
||||
expect((json as ValidationErrorBody).errors?.[0]?.code).toBe('INVALID_OR_EXPIRED_TICKET');
|
||||
expect((json as ValidationErrorBody).errors?.[0]?.code).toBe('VERIFICATION_CODE_NOT_ISSUED');
|
||||
});
|
||||
test('rejects an unknown ticket', async () => {
|
||||
const {account} = await createTotpAccount(harness);
|
||||
@@ -227,4 +255,107 @@ describe('MfaBackupCodesChallengeFlow', () => {
|
||||
.execute();
|
||||
});
|
||||
});
|
||||
describe('regenerate', () => {
|
||||
test('replaces the backup codes using the verification proof', async () => {
|
||||
const {account, backupCodes} = await createTotpAccount(harness);
|
||||
const startResult = await startChallenge(harness, account.token);
|
||||
const code = await getChallengeCode(harness, account.email);
|
||||
const verified = await verifyChallenge(harness, account.token, startResult.ticket, code);
|
||||
const regenerated = await regenerateWithChallenge(
|
||||
harness,
|
||||
account.token,
|
||||
startResult.ticket,
|
||||
verified.verification_proof,
|
||||
);
|
||||
expect(regenerated.backup_codes.length).toBe(backupCodes.length);
|
||||
const before = new Set(backupCodes);
|
||||
expect(regenerated.backup_codes.every((backupCode) => !before.has(backupCode.code))).toBe(true);
|
||||
for (const backupCode of regenerated.backup_codes) {
|
||||
expect(backupCode.consumed).toBe(false);
|
||||
}
|
||||
});
|
||||
test('rejects an incorrect verification proof', async () => {
|
||||
const {account, backupCodes} = await createTotpAccount(harness);
|
||||
const startResult = await startChallenge(harness, account.token);
|
||||
const code = await getChallengeCode(harness, account.email);
|
||||
const verified = await verifyChallenge(harness, account.token, startResult.ticket, code);
|
||||
const wrongProof = `${verified.verification_proof.slice(0, -1)}${verified.verification_proof.endsWith('a') ? 'b' : 'a'}`;
|
||||
const {json} = await createBuilder(harness, account.token)
|
||||
.post('/users/@me/mfa/backup-codes/challenge/regenerate')
|
||||
.body({ticket: startResult.ticket, verification_proof: wrongProof})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST, 'INVALID_FORM_BODY')
|
||||
.executeWithResponse();
|
||||
expect((json as ValidationErrorBody).errors?.[0]?.code).toBe('INVALID_PROOF_TOKEN');
|
||||
const reverify = await createBuilder(harness, account.token)
|
||||
.post('/users/@me/mfa/backup-codes/challenge/verify')
|
||||
.body({ticket: startResult.ticket, code})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST, 'INVALID_FORM_BODY')
|
||||
.executeWithResponse();
|
||||
expect((reverify.json as ValidationErrorBody).errors?.[0]?.code).toBe('VERIFICATION_CODE_NOT_ISSUED');
|
||||
const regenerated = await regenerateWithChallenge(
|
||||
harness,
|
||||
account.token,
|
||||
startResult.ticket,
|
||||
verified.verification_proof,
|
||||
);
|
||||
expect(regenerated.backup_codes.map((backupCode) => backupCode.code).sort()).not.toEqual([...backupCodes].sort());
|
||||
});
|
||||
test('rejects a ticket that has not been verified yet', async () => {
|
||||
const {account} = await createTotpAccount(harness);
|
||||
const startResult = await startChallenge(harness, account.token);
|
||||
const {json} = await createBuilder(harness, account.token)
|
||||
.post('/users/@me/mfa/backup-codes/challenge/regenerate')
|
||||
.body({ticket: startResult.ticket, verification_proof: 'not-a-real-proof'})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST, 'INVALID_FORM_BODY')
|
||||
.executeWithResponse();
|
||||
expect((json as ValidationErrorBody).errors?.[0]?.code).toBe('INVALID_OR_EXPIRED_TICKET');
|
||||
});
|
||||
test('rejects a verified ticket belonging to another user', async () => {
|
||||
const owner = await createTotpAccount(harness);
|
||||
const other = await createTotpAccount(harness);
|
||||
const startResult = await startChallenge(harness, owner.account.token);
|
||||
const code = await getChallengeCode(harness, owner.account.email);
|
||||
const verified = await verifyChallenge(harness, owner.account.token, startResult.ticket, code);
|
||||
const {json} = await createBuilder(harness, other.account.token)
|
||||
.post('/users/@me/mfa/backup-codes/challenge/regenerate')
|
||||
.body({ticket: startResult.ticket, verification_proof: verified.verification_proof})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST, 'INVALID_FORM_BODY')
|
||||
.executeWithResponse();
|
||||
expect((json as ValidationErrorBody).errors?.[0]?.code).toBe('INVALID_OR_EXPIRED_TICKET');
|
||||
});
|
||||
test('rejects an unknown ticket', async () => {
|
||||
const {account} = await createTotpAccount(harness);
|
||||
const {json} = await createBuilder(harness, account.token)
|
||||
.post('/users/@me/mfa/backup-codes/challenge/regenerate')
|
||||
.body({ticket: 'nonexistent-ticket', verification_proof: 'whatever'})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST, 'INVALID_FORM_BODY')
|
||||
.executeWithResponse();
|
||||
expect((json as ValidationErrorBody).errors?.[0]?.code).toBe('INVALID_OR_EXPIRED_TICKET');
|
||||
});
|
||||
test('rejects a proof once TOTP has been disabled', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const secret = createTotpSecret();
|
||||
const backupCodes = await enableTotpWithSecret(harness, account, secret);
|
||||
const startResult = await startChallenge(harness, account.token);
|
||||
const code = await getChallengeCode(harness, account.email);
|
||||
const verified = await verifyChallenge(harness, account.token, startResult.ticket, code);
|
||||
await createBuilder(harness, account.token)
|
||||
.post('/users/@me/mfa/totp/disable')
|
||||
.body({code: backupCodes[0]!, mfa_method: 'totp', mfa_code: totpCodeNow(secret)})
|
||||
.expect(HTTP_STATUS.NO_CONTENT)
|
||||
.execute();
|
||||
await createBuilder(harness, account.token)
|
||||
.post('/users/@me/mfa/backup-codes/challenge/regenerate')
|
||||
.body({ticket: startResult.ticket, verification_proof: verified.verification_proof})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST, 'TWO_FACTOR_REQUIRED')
|
||||
.execute();
|
||||
});
|
||||
test('requires authentication', async () => {
|
||||
await createBuilderWithoutAuth(harness)
|
||||
.post('/users/@me/mfa/backup-codes/challenge/regenerate')
|
||||
.body({ticket: 'some-ticket', verification_proof: 'some-proof'})
|
||||
.expect(HTTP_STATUS.UNAUTHORIZED)
|
||||
.execute();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -85,12 +85,6 @@ describe('User flags in responses', () => {
|
||||
const {json} = await fetchUserMe(harness, account.token);
|
||||
expect(json.flags & PublicUserFlags.SPAMMER).toBe(PublicUserFlags.SPAMMER);
|
||||
});
|
||||
test('GET /users/@me includes CTP_MEMBER when set', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
await setUserFlags(harness, account.userId, UserFlags.CTP_MEMBER);
|
||||
const {json} = await fetchUserMe(harness, account.token);
|
||||
expect(json.flags & PublicUserFlags.CTP_MEMBER).toBe(PublicUserFlags.CTP_MEMBER);
|
||||
});
|
||||
test('PATCH /users/@me does not leak internal flags', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
await setUserFlags(harness, account.userId, UserFlags.STAFF | UserFlags.SPAMMER | UserFlags.HIGH_GLOBAL_RATE_LIMIT);
|
||||
|
||||
@@ -26,8 +26,13 @@ const DLQ_SUBJECT_PREFIX = 'dlq.';
|
||||
const DLQ_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000;
|
||||
const STREAM_MAX_MSGS = 2_000_000;
|
||||
const STREAM_MAX_BYTES = 8 * 1024 * 1024 * 1024;
|
||||
const STREAM_MIN_BYTES = 64 * 1024 * 1024;
|
||||
const STREAM_MAX_MSGS_PER_SUBJECT = 250_000;
|
||||
const STREAM_STORE_ERR_CODE = 10077;
|
||||
const DLQ_MAX_BYTES = 64 * 1024 * 1024;
|
||||
const DLQ_MIN_BYTES = 8 * 1024 * 1024;
|
||||
const STREAM_FULL_ERR_CODES = new Set([10023, 10077]);
|
||||
const STREAM_NO_STORAGE_ERR_CODE = 10047;
|
||||
const STREAM_NAME_IN_USE_ERR_CODE = 10058;
|
||||
|
||||
const STREAM_LIMITS = {
|
||||
max_msgs: STREAM_MAX_MSGS,
|
||||
@@ -37,12 +42,19 @@ const STREAM_LIMITS = {
|
||||
discard_new_per_subject: true,
|
||||
} satisfies Partial<StreamConfig>;
|
||||
|
||||
function jsErrorCode(error: unknown): number | null {
|
||||
if (!(error instanceof NatsError)) {
|
||||
return null;
|
||||
}
|
||||
return error.jsError()?.err_code ?? null;
|
||||
}
|
||||
|
||||
function describeStreamRejection(error: unknown): string | null {
|
||||
if (!(error instanceof NatsError)) {
|
||||
return null;
|
||||
}
|
||||
const apiError = error.jsError();
|
||||
if (apiError?.err_code !== STREAM_STORE_ERR_CODE) {
|
||||
if (apiError === null || !STREAM_FULL_ERR_CODES.has(apiError.err_code ?? 0)) {
|
||||
return null;
|
||||
}
|
||||
return apiError.description ?? 'stream rejected the publish';
|
||||
@@ -63,45 +75,116 @@ export class JetStreamWorkerQueue {
|
||||
return;
|
||||
}
|
||||
const jsm = await this.connectionManager.getJetStreamManager();
|
||||
let existingConfig: StreamConfig | null = null;
|
||||
try {
|
||||
existingConfig = (await jsm.streams.info(STREAM_NAME)).config;
|
||||
} catch {
|
||||
existingConfig = null;
|
||||
}
|
||||
const existingConfig = await this.readStreamConfig(jsm, STREAM_NAME);
|
||||
if (existingConfig === null) {
|
||||
await jsm.streams.add({
|
||||
name: STREAM_NAME,
|
||||
subjects: [`${SUBJECT_PREFIX}>`],
|
||||
retention: RetentionPolicy.Workqueue,
|
||||
storage: StorageType.File,
|
||||
max_age: nanos(MAX_AGE_MS),
|
||||
duplicate_window: nanos(2 * 60 * 1000),
|
||||
num_replicas: 1,
|
||||
...STREAM_LIMITS,
|
||||
});
|
||||
await this.addStream(jsm);
|
||||
} else if (!this.hasStreamLimits(existingConfig)) {
|
||||
await this.applyStreamLimits(jsm);
|
||||
await this.applyStreamLimits(jsm, existingConfig);
|
||||
}
|
||||
this.streamReady = true;
|
||||
}
|
||||
|
||||
private async readStreamConfig(jsm: JetStreamManager, name: string): Promise<StreamConfig | null> {
|
||||
try {
|
||||
return (await jsm.streams.info(name)).config;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async fitToStorageBudget(
|
||||
startBytes: number,
|
||||
minBytes: number,
|
||||
apply: (maxBytes: number) => Promise<unknown>,
|
||||
): Promise<number> {
|
||||
let maxBytes = startBytes;
|
||||
for (;;) {
|
||||
try {
|
||||
await apply(maxBytes);
|
||||
return maxBytes;
|
||||
} catch (error) {
|
||||
if (jsErrorCode(error) !== STREAM_NO_STORAGE_ERR_CODE || maxBytes <= minBytes) {
|
||||
throw error;
|
||||
}
|
||||
maxBytes = Math.floor(maxBytes / 2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async adoptConcurrentStream(jsm: JetStreamManager, name: string, error: unknown): Promise<boolean> {
|
||||
if (jsErrorCode(error) !== STREAM_NAME_IN_USE_ERR_CODE) {
|
||||
return false;
|
||||
}
|
||||
const config = await this.readStreamConfig(jsm, name);
|
||||
if (config === null) {
|
||||
return false;
|
||||
}
|
||||
Logger.info({stream: name, max_bytes: config.max_bytes}, 'Stream was created concurrently, adopting it');
|
||||
return true;
|
||||
}
|
||||
|
||||
private async addStream(jsm: JetStreamManager): Promise<void> {
|
||||
let maxBytes: number;
|
||||
try {
|
||||
maxBytes = await this.fitToStorageBudget(STREAM_MAX_BYTES, STREAM_MIN_BYTES, (bytes) =>
|
||||
jsm.streams.add({
|
||||
name: STREAM_NAME,
|
||||
subjects: [`${SUBJECT_PREFIX}>`],
|
||||
retention: RetentionPolicy.Workqueue,
|
||||
storage: StorageType.File,
|
||||
max_age: nanos(MAX_AGE_MS),
|
||||
duplicate_window: nanos(2 * 60 * 1000),
|
||||
num_replicas: 1,
|
||||
...STREAM_LIMITS,
|
||||
max_bytes: bytes,
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
if (await this.adoptConcurrentStream(jsm, STREAM_NAME, error)) {
|
||||
return;
|
||||
}
|
||||
if (jsErrorCode(error) === STREAM_NO_STORAGE_ERR_CODE) {
|
||||
Logger.error(
|
||||
{err: error, stream: STREAM_NAME, max_bytes: STREAM_MIN_BYTES},
|
||||
'Jobs stream does not fit the JetStream storage budget at its smallest size, free disk space on the NATS store',
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
if (maxBytes !== STREAM_MAX_BYTES) {
|
||||
Logger.warn(
|
||||
{stream: STREAM_NAME, max_bytes: maxBytes},
|
||||
'Created the jobs stream below its target size to fit the JetStream storage budget',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private hasStreamLimits(config: StreamConfig): boolean {
|
||||
return (
|
||||
config.max_msgs === STREAM_MAX_MSGS &&
|
||||
config.max_bytes === STREAM_MAX_BYTES &&
|
||||
config.max_bytes > 0 &&
|
||||
config.max_msgs_per_subject === STREAM_MAX_MSGS_PER_SUBJECT &&
|
||||
config.discard === DiscardPolicy.New &&
|
||||
config.discard_new_per_subject
|
||||
);
|
||||
}
|
||||
|
||||
private async applyStreamLimits(jsm: JetStreamManager): Promise<void> {
|
||||
private async applyStreamLimits(jsm: JetStreamManager, existingConfig: StreamConfig): Promise<void> {
|
||||
const startBytes = existingConfig.max_bytes > 0 ? existingConfig.max_bytes : STREAM_MAX_BYTES;
|
||||
try {
|
||||
await jsm.streams.update(STREAM_NAME, {...STREAM_LIMITS});
|
||||
Logger.info({stream: STREAM_NAME, ...STREAM_LIMITS}, 'Applied jobs stream limits');
|
||||
const maxBytes = await this.fitToStorageBudget(startBytes, STREAM_MIN_BYTES, (bytes) =>
|
||||
jsm.streams.update(STREAM_NAME, {...STREAM_LIMITS, max_bytes: bytes}),
|
||||
);
|
||||
Logger.info({stream: STREAM_NAME, ...STREAM_LIMITS, max_bytes: maxBytes}, 'Applied jobs stream limits');
|
||||
} catch (error) {
|
||||
Logger.error({err: error, stream: STREAM_NAME}, 'Failed to apply jobs stream limits, stream stays unbounded');
|
||||
if (jsErrorCode(error) !== STREAM_NO_STORAGE_ERR_CODE) {
|
||||
Logger.error({err: error, stream: STREAM_NAME}, 'Failed to apply jobs stream limits');
|
||||
return;
|
||||
}
|
||||
Logger.warn(
|
||||
{err: error, stream: STREAM_NAME, max_bytes: existingConfig.max_bytes},
|
||||
'JetStream has no room to bound the jobs stream, it keeps the limits it already has',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,21 +193,45 @@ export class JetStreamWorkerQueue {
|
||||
return;
|
||||
}
|
||||
const jsm = await this.connectionManager.getJetStreamManager();
|
||||
try {
|
||||
await jsm.streams.info(DLQ_STREAM_NAME);
|
||||
} catch {
|
||||
await jsm.streams.add({
|
||||
name: DLQ_STREAM_NAME,
|
||||
subjects: [`${DLQ_SUBJECT_PREFIX}>`],
|
||||
retention: RetentionPolicy.Limits,
|
||||
storage: StorageType.File,
|
||||
max_age: nanos(DLQ_MAX_AGE_MS),
|
||||
num_replicas: 1,
|
||||
});
|
||||
if ((await this.readStreamConfig(jsm, DLQ_STREAM_NAME)) === null) {
|
||||
if (!(await this.addDlqStream(jsm))) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
this.dlqStreamReady = true;
|
||||
}
|
||||
|
||||
private async addDlqStream(jsm: JetStreamManager): Promise<boolean> {
|
||||
try {
|
||||
const maxBytes = await this.fitToStorageBudget(DLQ_MAX_BYTES, DLQ_MIN_BYTES, (bytes) =>
|
||||
jsm.streams.add({
|
||||
name: DLQ_STREAM_NAME,
|
||||
subjects: [`${DLQ_SUBJECT_PREFIX}>`],
|
||||
retention: RetentionPolicy.Limits,
|
||||
storage: StorageType.File,
|
||||
max_age: nanos(DLQ_MAX_AGE_MS),
|
||||
num_replicas: 1,
|
||||
max_bytes: bytes,
|
||||
discard: DiscardPolicy.Old,
|
||||
}),
|
||||
);
|
||||
Logger.info({stream: DLQ_STREAM_NAME, max_bytes: maxBytes}, 'Dead-letter stream created');
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (await this.adoptConcurrentStream(jsm, DLQ_STREAM_NAME, error)) {
|
||||
return true;
|
||||
}
|
||||
if (jsErrorCode(error) !== STREAM_NO_STORAGE_ERR_CODE) {
|
||||
throw error;
|
||||
}
|
||||
Logger.warn(
|
||||
{err: error, stream: DLQ_STREAM_NAME},
|
||||
'JetStream has no room for the dead-letter stream, failed jobs stay in the jobs stream until they expire',
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async ensureConsumers(lanes: ReadonlyArray<WorkerLaneDefinition>): Promise<void> {
|
||||
if (this.consumersReady) {
|
||||
return;
|
||||
|
||||
@@ -7,9 +7,12 @@ import {JetStreamWorkerQueue} from '../JetStreamWorkerQueue';
|
||||
import {WORKER_LANES} from '../WorkerLaneConfig';
|
||||
import {WorkerQueueOverflowError} from '../WorkerQueueOverflowError';
|
||||
|
||||
const GIB = 1024 * 1024 * 1024;
|
||||
const MIB = 1024 * 1024;
|
||||
|
||||
const EXPECTED_LIMITS = {
|
||||
max_msgs: 2_000_000,
|
||||
max_bytes: 8 * 1024 * 1024 * 1024,
|
||||
max_bytes: 8 * GIB,
|
||||
max_msgs_per_subject: 250_000,
|
||||
discard: DiscardPolicy.New,
|
||||
discard_new_per_subject: true,
|
||||
@@ -38,6 +41,22 @@ function streamLimitError(description: string): NatsError {
|
||||
return error;
|
||||
}
|
||||
|
||||
function serverResourceError(): NatsError {
|
||||
const error = new NatsError('503', '503');
|
||||
error.api_error = {code: 503, err_code: 10023, description: 'insufficient resources'};
|
||||
return error;
|
||||
}
|
||||
|
||||
function noStorageError(): NatsError {
|
||||
const error = new NatsError('503', '503');
|
||||
error.api_error = {code: 503, err_code: 10047, description: 'insufficient storage resources available'};
|
||||
return error;
|
||||
}
|
||||
|
||||
function storageBudget(budget: number): (config: Partial<StreamConfig>) => Error | null {
|
||||
return (config) => ((config.max_bytes ?? 0) > budget ? noStorageError() : null);
|
||||
}
|
||||
|
||||
function boundedPublisher(maxPerSubject: number): (subject: string) => {seq: number} {
|
||||
const counts = new Map<string, number>();
|
||||
let seq = 0;
|
||||
@@ -54,15 +73,19 @@ function boundedPublisher(maxPerSubject: number): (subject: string) => {seq: num
|
||||
|
||||
function createQueue(params: {
|
||||
existing?: StreamConfig | null;
|
||||
dlqExists?: boolean;
|
||||
reject?: (config: Partial<StreamConfig>) => Error | null;
|
||||
updateError?: Error;
|
||||
publish?: (subject: string) => {seq: number};
|
||||
}): {
|
||||
queue: JetStreamWorkerQueue;
|
||||
added: Array<Partial<StreamConfig>>;
|
||||
dlqAdded: Array<Partial<StreamConfig>>;
|
||||
updated: Array<Partial<StreamConfig>>;
|
||||
consumerAdds: Array<ConsumerAddConfig>;
|
||||
} {
|
||||
const added: Array<Partial<StreamConfig>> = [];
|
||||
const dlqAdded: Array<Partial<StreamConfig>> = [];
|
||||
const updated: Array<Partial<StreamConfig>> = [];
|
||||
const consumerAdds: Array<ConsumerAddConfig> = [];
|
||||
const connectionManager = {
|
||||
@@ -77,14 +100,23 @@ function createQueue(params: {
|
||||
info: () => Promise.reject(new Error('consumer not found')),
|
||||
},
|
||||
streams: {
|
||||
info: () => {
|
||||
info: (name: string) => {
|
||||
if (name === 'JOBS_DLQ') {
|
||||
return params.dlqExists
|
||||
? Promise.resolve({config: {name} as StreamConfig})
|
||||
: Promise.reject(new Error('stream not found'));
|
||||
}
|
||||
if (!params.existing) {
|
||||
return Promise.reject(new Error('stream not found'));
|
||||
}
|
||||
return Promise.resolve({config: params.existing});
|
||||
},
|
||||
add: (config: Partial<StreamConfig>) => {
|
||||
added.push(config);
|
||||
(config.name === 'JOBS_DLQ' ? dlqAdded : added).push(config);
|
||||
const rejection = params.reject?.(config) ?? null;
|
||||
if (rejection !== null) {
|
||||
return Promise.reject(rejection);
|
||||
}
|
||||
return Promise.resolve({config});
|
||||
},
|
||||
update: (_name: string, config: Partial<StreamConfig>) => {
|
||||
@@ -92,6 +124,10 @@ function createQueue(params: {
|
||||
return Promise.reject(params.updateError);
|
||||
}
|
||||
updated.push(config);
|
||||
const rejection = params.reject?.(config) ?? null;
|
||||
if (rejection !== null) {
|
||||
return Promise.reject(rejection);
|
||||
}
|
||||
return Promise.resolve({config});
|
||||
},
|
||||
},
|
||||
@@ -103,7 +139,7 @@ function createQueue(params: {
|
||||
},
|
||||
}),
|
||||
} as unknown as JetStreamConnectionManager;
|
||||
return {queue: new JetStreamWorkerQueue(connectionManager), added, updated, consumerAdds};
|
||||
return {queue: new JetStreamWorkerQueue(connectionManager), added, dlqAdded, updated, consumerAdds};
|
||||
}
|
||||
|
||||
describe('jobs stream limits', () => {
|
||||
@@ -116,6 +152,28 @@ describe('jobs stream limits', () => {
|
||||
expect(added[0]).toMatchObject({retention: RetentionPolicy.Workqueue, storage: StorageType.File});
|
||||
});
|
||||
|
||||
it('lowers the new stream until the server storage budget accepts it', async () => {
|
||||
const {queue, added} = createQueue({existing: null, reject: storageBudget(2 * GIB)});
|
||||
await queue.ensureStream();
|
||||
expect(added.map((config) => config.max_bytes)).toEqual([8 * GIB, 4 * GIB, 2 * GIB]);
|
||||
expect(added[2]).toMatchObject({...EXPECTED_LIMITS, max_bytes: 2 * GIB});
|
||||
expect(added[2]).toMatchObject({retention: RetentionPolicy.Workqueue, storage: StorageType.File});
|
||||
});
|
||||
|
||||
it('fails the boot when even the smallest jobs stream does not fit', async () => {
|
||||
const {queue, added} = createQueue({existing: null, reject: storageBudget(0)});
|
||||
await expect(queue.ensureStream()).rejects.toBeInstanceOf(NatsError);
|
||||
expect(added).toHaveLength(8);
|
||||
expect(added[7]?.max_bytes).toBe(64 * MIB);
|
||||
});
|
||||
|
||||
it('rethrows stream creation failures that are not storage rejections', async () => {
|
||||
const failure = new Error('stream name already in use');
|
||||
const {queue, added} = createQueue({existing: null, reject: () => failure});
|
||||
await expect(queue.ensureStream()).rejects.toBe(failure);
|
||||
expect(added).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('applies the limits to an existing unbounded stream', async () => {
|
||||
const {queue, added, updated} = createQueue({existing: LEGACY_CONFIG});
|
||||
await queue.ensureStream();
|
||||
@@ -124,6 +182,12 @@ describe('jobs stream limits', () => {
|
||||
expect(updated[0]).toEqual(EXPECTED_LIMITS);
|
||||
});
|
||||
|
||||
it('lowers the limit update until the server storage budget accepts it', async () => {
|
||||
const {queue, updated} = createQueue({existing: LEGACY_CONFIG, reject: storageBudget(1 * GIB)});
|
||||
await queue.ensureStream();
|
||||
expect(updated.map((config) => config.max_bytes)).toEqual([8 * GIB, 4 * GIB, 2 * GIB, 1 * GIB]);
|
||||
});
|
||||
|
||||
it('leaves an already bounded stream alone', async () => {
|
||||
const {queue, added, updated} = createQueue({existing: {...LEGACY_CONFIG, ...EXPECTED_LIMITS}});
|
||||
await queue.ensureStream();
|
||||
@@ -131,10 +195,39 @@ describe('jobs stream limits', () => {
|
||||
expect(updated).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('leaves a stream that was lowered to fit the server alone', async () => {
|
||||
const {queue, added, updated} = createQueue({
|
||||
existing: {...LEGACY_CONFIG, ...EXPECTED_LIMITS, max_bytes: 1536 * MIB},
|
||||
});
|
||||
await queue.ensureStream();
|
||||
expect(added).toHaveLength(0);
|
||||
expect(updated).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('keeps startup alive when the limit update is rejected', async () => {
|
||||
const {queue} = createQueue({existing: LEGACY_CONFIG, updateError: new Error('stream update rejected')});
|
||||
await expect(queue.ensureStream()).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('keeps startup alive when no limit update fits the server', async () => {
|
||||
const {queue, updated} = createQueue({existing: LEGACY_CONFIG, reject: storageBudget(0)});
|
||||
await expect(queue.ensureStream()).resolves.toBeUndefined();
|
||||
expect(updated).toHaveLength(8);
|
||||
});
|
||||
});
|
||||
|
||||
describe('dead-letter stream', () => {
|
||||
it('keeps startup alive when the dead-letter stream does not fit', async () => {
|
||||
const {queue, dlqAdded} = createQueue({dlqExists: false, reject: () => noStorageError()});
|
||||
await expect(queue.ensureDlqStream()).resolves.toBeUndefined();
|
||||
expect(dlqAdded).toHaveLength(4);
|
||||
});
|
||||
|
||||
it('rethrows dead-letter creation failures that are not storage rejections', async () => {
|
||||
const failure = new Error('no responders');
|
||||
const {queue} = createQueue({dlqExists: false, reject: () => failure});
|
||||
await expect(queue.ensureDlqStream()).rejects.toBe(failure);
|
||||
});
|
||||
});
|
||||
|
||||
describe('jobs stream enqueue shedding', () => {
|
||||
@@ -152,6 +245,16 @@ describe('jobs stream enqueue shedding', () => {
|
||||
await expect(queue.enqueue('handleMentions', {})).resolves.toBe('2');
|
||||
});
|
||||
|
||||
it('sheds enqueues the server refuses for lack of resources', async () => {
|
||||
const {queue} = createQueue({
|
||||
existing: LEGACY_CONFIG,
|
||||
publish: () => {
|
||||
throw serverResourceError();
|
||||
},
|
||||
});
|
||||
await expect(queue.enqueue('handleMentions', {})).rejects.toBeInstanceOf(WorkerQueueOverflowError);
|
||||
});
|
||||
|
||||
it('rethrows publish failures that are not stream limits', async () => {
|
||||
const failure = new Error('no responders');
|
||||
const {queue} = createQueue({
|
||||
|
||||
@@ -16,6 +16,7 @@ import {NullSpaceDropIndicator} from '@app/features/app/components/layout/NullSp
|
||||
import {ScrollIndicatorOverlay} from '@app/features/app/components/layout/ScrollIndicatorOverlay';
|
||||
import {type DragItem, DragItemType, type DropResult} from '@app/features/app/components/layout/types/DndTypes';
|
||||
import {
|
||||
shouldShowCategoryWhenHidingMutedChannels,
|
||||
shouldShowChannelInCollapsedCategory,
|
||||
shouldShowChannelWhenHidingMutedChannels,
|
||||
} from '@app/features/app/components/layout/utils/ChannelListVisibility';
|
||||
@@ -471,8 +472,10 @@ export const ChannelListContent = observer(({guild, scrollY}: {guild: Guild; scr
|
||||
if (
|
||||
hideMutedChannels &&
|
||||
group.category &&
|
||||
filteredTextChannels.length === 0 &&
|
||||
filteredVoiceChannels.length === 0
|
||||
!shouldShowCategoryWhenHidingMutedChannels({
|
||||
hasChannels: group.textChannels.length > 0 || group.voiceChannels.length > 0,
|
||||
hasVisibleChannels: filteredTextChannels.length > 0 || filteredVoiceChannels.length > 0,
|
||||
})
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {describe, expect, it} from 'vitest';
|
||||
import {shouldShowChannelInCollapsedCategory, shouldShowChannelWhenHidingMutedChannels} from './ChannelListVisibility';
|
||||
import {
|
||||
shouldShowCategoryWhenHidingMutedChannels,
|
||||
shouldShowChannelInCollapsedCategory,
|
||||
shouldShowChannelWhenHidingMutedChannels,
|
||||
} from './ChannelListVisibility';
|
||||
|
||||
describe('shouldShowChannelWhenHidingMutedChannels', () => {
|
||||
it('keeps muted channels with visible unread state so mentions are findable', () => {
|
||||
@@ -57,6 +61,35 @@ describe('shouldShowChannelWhenHidingMutedChannels', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('shouldShowCategoryWhenHidingMutedChannels', () => {
|
||||
it('keeps a category that has no channels at all', () => {
|
||||
expect(
|
||||
shouldShowCategoryWhenHidingMutedChannels({
|
||||
hasChannels: false,
|
||||
hasVisibleChannels: false,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('hides a category whose channels were all filtered out as muted', () => {
|
||||
expect(
|
||||
shouldShowCategoryWhenHidingMutedChannels({
|
||||
hasChannels: true,
|
||||
hasVisibleChannels: false,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps a category that still has visible channels', () => {
|
||||
expect(
|
||||
shouldShowCategoryWhenHidingMutedChannels({
|
||||
hasChannels: true,
|
||||
hasVisibleChannels: true,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('shouldShowChannelInCollapsedCategory', () => {
|
||||
it('keeps visible unread channels reachable in collapsed categories', () => {
|
||||
expect(
|
||||
|
||||
@@ -16,6 +16,18 @@ export function shouldShowChannelWhenHidingMutedChannels({
|
||||
return isSelected || isConnected || !isMuted || hasVisibleUnread;
|
||||
}
|
||||
|
||||
export interface HiddenMutedCategoryVisibilityInput {
|
||||
hasChannels: boolean;
|
||||
hasVisibleChannels: boolean;
|
||||
}
|
||||
|
||||
export function shouldShowCategoryWhenHidingMutedChannels({
|
||||
hasChannels,
|
||||
hasVisibleChannels,
|
||||
}: HiddenMutedCategoryVisibilityInput): boolean {
|
||||
return !hasChannels || hasVisibleChannels;
|
||||
}
|
||||
|
||||
export interface CollapsedCategoryChannelVisibilityInput {
|
||||
isCategoryMuted: boolean;
|
||||
isSelected: boolean;
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {isKeyboardActivationKey} from '@app/features/input/utils/KeyboardUtils';
|
||||
import {useMatureMedia} from '@app/features/messaging/hooks/useMatureMedia';
|
||||
import styles from '@app/features/theme/styles/MatureBlur.module.css';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
import {useLingui} from '@lingui/react/macro';
|
||||
import {clsx} from 'clsx';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import type {ReactElement} from 'react';
|
||||
import React, {useCallback} from 'react';
|
||||
|
||||
const REVEAL_SENSITIVE_EMOJI_DESCRIPTOR = msg({
|
||||
message: 'Reveal sensitive emoji',
|
||||
comment: 'Accessible label for a blurred emoji button. Activating it reveals the emoji when allowed.',
|
||||
});
|
||||
|
||||
interface MatureEmojiWrapperProps {
|
||||
mature: boolean;
|
||||
channelId: string | undefined;
|
||||
children: ReactElement<{className?: string}>;
|
||||
}
|
||||
|
||||
export const MatureEmojiWrapper = observer(
|
||||
({mature, channelId, children}: MatureEmojiWrapperProps): ReactElement | null => {
|
||||
const {i18n} = useLingui();
|
||||
const {shouldBlur, shouldBlock, canReveal, reveal} = useMatureMedia(mature, channelId);
|
||||
const handleReveal = useCallback(
|
||||
(e: React.MouseEvent | React.KeyboardEvent) => {
|
||||
if (shouldBlur && canReveal) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
reveal();
|
||||
}
|
||||
},
|
||||
[shouldBlur, canReveal, reveal],
|
||||
);
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (!isKeyboardActivationKey(e.key)) {
|
||||
return;
|
||||
}
|
||||
handleReveal(e);
|
||||
},
|
||||
[handleReveal],
|
||||
);
|
||||
if (shouldBlock) {
|
||||
return null;
|
||||
}
|
||||
if (shouldBlur) {
|
||||
if (!canReveal) {
|
||||
return (
|
||||
<span className={styles.matureBlurContainer} data-flx="app.mature-emoji-wrapper.mature-blur-container">
|
||||
{React.cloneElement(children, {
|
||||
className: clsx(children.props.className, styles.matureBlurred),
|
||||
})}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span
|
||||
className={styles.matureBlurContainer}
|
||||
onClick={handleReveal}
|
||||
onKeyDown={handleKeyDown}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={i18n._(REVEAL_SENSITIVE_EMOJI_DESCRIPTOR)}
|
||||
data-flx="app.mature-emoji-wrapper.mature-blur-container.reveal"
|
||||
>
|
||||
{React.cloneElement(children, {
|
||||
className: clsx(children.props.className, styles.matureBlurred),
|
||||
})}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return children;
|
||||
},
|
||||
);
|
||||
@@ -163,6 +163,7 @@ export const Endpoints = {
|
||||
USER_MFA_BACKUP_CODES_CHALLENGE_START: '/users/@me/mfa/backup-codes/challenge',
|
||||
USER_MFA_BACKUP_CODES_CHALLENGE_RESEND: '/users/@me/mfa/backup-codes/challenge/resend',
|
||||
USER_MFA_BACKUP_CODES_CHALLENGE_VERIFY: '/users/@me/mfa/backup-codes/challenge/verify',
|
||||
USER_MFA_BACKUP_CODES_CHALLENGE_REGENERATE: '/users/@me/mfa/backup-codes/challenge/regenerate',
|
||||
USER_MFA_TOTP_DISABLE: '/users/@me/mfa/totp/disable',
|
||||
USER_MFA_TOTP_ENABLE: '/users/@me/mfa/totp/enable',
|
||||
USER_AUTHORIZED_IPS: '/users/@me/authorized-ips',
|
||||
|
||||
@@ -86,6 +86,12 @@ export function useConnectionNotice(): ConnectionNotice | null {
|
||||
}, BOOT_NOTICE_DELAY_MS);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [booting]);
|
||||
useEffect(() => {
|
||||
if (!reconnecting) {
|
||||
return;
|
||||
}
|
||||
StatusPage.refreshForConnectionIssue();
|
||||
}, [reconnecting]);
|
||||
if (Nagbar.forceHideConnectionNotice) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -18,6 +18,25 @@ interface BackupCodesChallengeResponse {
|
||||
resend_available_at: string | null;
|
||||
}
|
||||
|
||||
interface BackupCodesChallengeVerifyResponse extends BackupCodesResponse {
|
||||
verification_proof: string;
|
||||
}
|
||||
|
||||
export interface BackupCodesChallenge {
|
||||
ticket: string;
|
||||
verificationProof: string;
|
||||
}
|
||||
|
||||
const CHALLENGE_CREDENTIAL_PATHS = new Set(['ticket', 'verification_proof']);
|
||||
|
||||
export function isExpiredBackupCodesChallengeError(error: unknown): boolean {
|
||||
if (!error || typeof error !== 'object' || !('body' in error)) {
|
||||
return false;
|
||||
}
|
||||
const body = (error as {body?: {errors?: Array<{path?: string}>}}).body;
|
||||
return body?.errors?.some((validationError) => CHALLENGE_CREDENTIAL_PATHS.has(validationError.path ?? '')) ?? false;
|
||||
}
|
||||
|
||||
type BackupCodeMode = 'fetch' | 'regenerate';
|
||||
|
||||
function backupCodeMode(regenerate: boolean): BackupCodeMode {
|
||||
@@ -53,9 +72,23 @@ async function requestBackupCodesChallengeResend(ticket: string): Promise<void>
|
||||
await http.post(Endpoints.USER_MFA_BACKUP_CODES_CHALLENGE_RESEND, {body: {ticket}});
|
||||
}
|
||||
|
||||
async function requestBackupCodesChallengeVerify(ticket: string, code: string): Promise<Array<BackupCode>> {
|
||||
const response = await http.post<BackupCodesResponse>(Endpoints.USER_MFA_BACKUP_CODES_CHALLENGE_VERIFY, {
|
||||
body: {ticket, code},
|
||||
async function requestBackupCodesChallengeVerify(
|
||||
ticket: string,
|
||||
code: string,
|
||||
): Promise<BackupCodesChallengeVerifyResponse> {
|
||||
const response = await http.post<BackupCodesChallengeVerifyResponse>(
|
||||
Endpoints.USER_MFA_BACKUP_CODES_CHALLENGE_VERIFY,
|
||||
{body: {ticket, code}},
|
||||
);
|
||||
return response.body;
|
||||
}
|
||||
|
||||
async function requestBackupCodesChallengeRegenerate(
|
||||
ticket: string,
|
||||
verificationProof: string,
|
||||
): Promise<Array<BackupCode>> {
|
||||
const response = await http.post<BackupCodesResponse>(Endpoints.USER_MFA_BACKUP_CODES_CHALLENGE_REGENERATE, {
|
||||
body: {ticket, verification_proof: verificationProof},
|
||||
});
|
||||
return response.body.backup_codes;
|
||||
}
|
||||
@@ -120,13 +153,27 @@ export async function resendBackupCodesChallengeCode(ticket: string): Promise<vo
|
||||
}
|
||||
}
|
||||
|
||||
export async function verifyBackupCodesChallenge(ticket: string, code: string): Promise<Array<BackupCode>> {
|
||||
export async function verifyBackupCodesChallenge(
|
||||
ticket: string,
|
||||
code: string,
|
||||
): Promise<BackupCodesChallengeVerifyResponse> {
|
||||
try {
|
||||
logger.debug('Verifying MFA backup codes challenge code');
|
||||
const backupCodes = await requestBackupCodesChallengeVerify(ticket, code);
|
||||
const result = await requestBackupCodesChallengeVerify(ticket, code);
|
||||
logger.debug('Successfully verified MFA backup codes challenge code');
|
||||
return backupCodes;
|
||||
return result;
|
||||
} catch (error) {
|
||||
rethrowMfaFailure('Failed to verify MFA backup codes challenge code:', error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function regenerateBackupCodesWithChallenge(challenge: BackupCodesChallenge): Promise<Array<BackupCode>> {
|
||||
try {
|
||||
logger.debug('Regenerating MFA backup codes with a verified challenge');
|
||||
const backupCodes = await requestBackupCodesChallengeRegenerate(challenge.ticket, challenge.verificationProof);
|
||||
logger.debug('Successfully regenerated MFA backup codes with a verified challenge');
|
||||
return backupCodes;
|
||||
} catch (error) {
|
||||
rethrowMfaFailure('Failed to regenerate MFA backup codes with a verified challenge:', error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import * as Modal from '@app/features/app/components/dialogs/Modal';
|
||||
import type {BackupCodesChallenge} from '@app/features/auth/commands/MfaCommands';
|
||||
import styles from '@app/features/auth/components/modals/BackupCodesModal.module.css';
|
||||
import {BackupCodesRegenerateModal} from '@app/features/auth/components/modals/BackupCodesRegenerateModal';
|
||||
import {Button} from '@app/features/ui/button/Button';
|
||||
@@ -22,9 +23,10 @@ const BACKUP_CODES_DESCRIPTOR = msg({
|
||||
interface BackupCodesModalProps {
|
||||
backupCodes: ReadonlyArray<BackupCode>;
|
||||
user: User;
|
||||
challenge?: BackupCodesChallenge;
|
||||
}
|
||||
|
||||
export const BackupCodesModal = observer(({backupCodes, user}: BackupCodesModalProps) => {
|
||||
export const BackupCodesModal = observer(({backupCodes, user, challenge}: BackupCodesModalProps) => {
|
||||
const {i18n} = useLingui();
|
||||
return (
|
||||
<Modal.Root size="small" centered data-flx="auth.backup-codes-modal.modal-root">
|
||||
@@ -101,6 +103,7 @@ export const BackupCodesModal = observer(({backupCodes, user}: BackupCodesModalP
|
||||
modal(() => (
|
||||
<BackupCodesRegenerateModal
|
||||
user={user}
|
||||
challenge={challenge}
|
||||
data-flx="auth.backup-codes-modal.backup-codes-regenerate-modal"
|
||||
/>
|
||||
)),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import * as Modal from '@app/features/app/components/dialogs/Modal';
|
||||
import type {BackupCodesChallenge} from '@app/features/auth/commands/MfaCommands';
|
||||
import * as MfaCommands from '@app/features/auth/commands/MfaCommands';
|
||||
import {BackupCodesModal} from '@app/features/auth/components/modals/BackupCodesModal';
|
||||
import {Button} from '@app/features/ui/button/Button';
|
||||
@@ -9,6 +10,7 @@ import {modal} from '@app/features/ui/commands/ModalCommands';
|
||||
import * as ToastCommands from '@app/features/ui/commands/ToastCommands';
|
||||
import type {User} from '@app/features/user/models/User';
|
||||
import * as FormUtils from '@app/lib/forms';
|
||||
import type {BackupCode} from '@fluxer/schema/src/domains/user/UserResponseSchemas';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
import {Trans, useLingui} from '@lingui/react/macro';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
@@ -25,21 +27,41 @@ const REGENERATE_BACKUP_CODES_DESCRIPTOR = msg({
|
||||
|
||||
interface BackupCodesRegenerateModalProps {
|
||||
user: User;
|
||||
challenge?: BackupCodesChallenge;
|
||||
}
|
||||
|
||||
export const BackupCodesRegenerateModal = observer(({user}: BackupCodesRegenerateModalProps) => {
|
||||
interface RegenerateResult {
|
||||
backupCodes: Array<BackupCode>;
|
||||
challenge?: BackupCodesChallenge;
|
||||
}
|
||||
|
||||
async function regenerateBackupCodes(challenge?: BackupCodesChallenge): Promise<RegenerateResult> {
|
||||
if (challenge) {
|
||||
try {
|
||||
return {backupCodes: await MfaCommands.regenerateBackupCodesWithChallenge(challenge), challenge};
|
||||
} catch (error) {
|
||||
if (!MfaCommands.isExpiredBackupCodesChallengeError(error)) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
return {backupCodes: await MfaCommands.getBackupCodes(true)};
|
||||
}
|
||||
|
||||
export const BackupCodesRegenerateModal = observer(({user, challenge}: BackupCodesRegenerateModalProps) => {
|
||||
const {i18n} = useLingui();
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const handleConfirm = async () => {
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const backupCodes = await MfaCommands.getBackupCodes(true);
|
||||
const result = await regenerateBackupCodes(challenge);
|
||||
ModalCommands.pop();
|
||||
ModalCommands.update('backup-codes', () =>
|
||||
modal(() => (
|
||||
<BackupCodesModal
|
||||
backupCodes={backupCodes}
|
||||
backupCodes={result.backupCodes}
|
||||
user={user}
|
||||
challenge={result.challenge}
|
||||
data-flx="auth.backup-codes-regenerate-modal.handle-confirm.backup-codes-modal"
|
||||
/>
|
||||
)),
|
||||
|
||||
@@ -99,13 +99,15 @@ export const BackupCodesViewModal = observer(({user}: BackupCodesViewModalProps)
|
||||
setSubmitting(true);
|
||||
setCodeError(null);
|
||||
try {
|
||||
const backupCodes = await MfaCommands.verifyBackupCodesChallenge(ticket, code);
|
||||
const result = await MfaCommands.verifyBackupCodesChallenge(ticket, code);
|
||||
const challenge = {ticket, verificationProof: result.verification_proof};
|
||||
ModalCommands.pop();
|
||||
ModalCommands.pushWithKey(
|
||||
modal(() => (
|
||||
<BackupCodesModal
|
||||
backupCodes={backupCodes}
|
||||
backupCodes={result.backup_codes}
|
||||
user={user}
|
||||
challenge={challenge}
|
||||
data-flx="auth.backup-codes-view-modal.handle-verify.backup-codes-modal"
|
||||
/>
|
||||
)),
|
||||
|
||||
@@ -80,7 +80,7 @@ const MfaScreen = ({challenge, inviteCode, onSuccess, onCancel}: MfaScreenProps)
|
||||
autoCapitalize="none"
|
||||
autoCorrect="off"
|
||||
enterKeyHint="done"
|
||||
inputMode="numeric"
|
||||
inputMode="text"
|
||||
spellCheck={false}
|
||||
autoFocus
|
||||
data-step-focus="true"
|
||||
|
||||
@@ -461,8 +461,7 @@ const StickerItem = observer(({sticker, message, sourceChannel, handleDelete}: S
|
||||
});
|
||||
const stickerRecord = Sticker.getStickerById(sticker.id);
|
||||
const guild = stickerRecord?.guildId ? Guilds.getGuild(stickerRecord.guildId) : null;
|
||||
const isMature = !!stickerRecord?.nsfw || !!sticker.nsfw;
|
||||
const {shouldBlur, shouldBlock, canReveal, reveal} = useMatureMedia(isMature, message.channelId);
|
||||
const {shouldBlur, shouldBlock, canReveal, reveal} = useMatureMedia(false, message.channelId);
|
||||
const tooltipContent = () => (
|
||||
<div className={styles.stickerTooltip} data-flx="channel.message-attachments.tooltip-content.sticker-tooltip">
|
||||
<span className={styles.stickerName} data-flx="channel.message-attachments.tooltip-content.sticker-name">
|
||||
@@ -502,7 +501,6 @@ const StickerItem = observer(({sticker, message, sourceChannel, handleDelete}: S
|
||||
tags: [],
|
||||
url: stickerUrl,
|
||||
animated: sticker.animated,
|
||||
nsfw: Boolean(sticker.nsfw),
|
||||
user: undefined,
|
||||
};
|
||||
ContextMenuCommands.openFromEvent(e, ({onClose}) => (
|
||||
|
||||
@@ -136,11 +136,7 @@ const MessageReactionItem = observer(
|
||||
};
|
||||
const emojiName = getEmojiName(reaction.emoji);
|
||||
const emojiUrl = useEmojiURL({emoji: reaction.emoji, isHovering: isHovering || tooltipHovering});
|
||||
const isMature = !!emojiRecord?.nsfw;
|
||||
const {shouldBlur: reactionShouldBlur, shouldBlock: reactionShouldBlock} = useMatureMedia(
|
||||
isMature,
|
||||
message.channelId,
|
||||
);
|
||||
const {shouldBlur: reactionShouldBlur, shouldBlock: reactionShouldBlock} = useMatureMedia(false, message.channelId);
|
||||
const variants = {
|
||||
up: {y: -20, opacity: 0},
|
||||
down: {y: 20, opacity: 0},
|
||||
|
||||
@@ -437,7 +437,6 @@ export const EmojiListItem: React.FC<{
|
||||
allNamesString: `:${emoji.name}:`,
|
||||
url: emojiUrl,
|
||||
animated: emoji.animated,
|
||||
nsfw: emoji.nsfw,
|
||||
user: emoji.user,
|
||||
};
|
||||
const handleContextMenu = (event: React.MouseEvent) => {
|
||||
|
||||
@@ -125,7 +125,6 @@ export const StickerGridItem = observer(function StickerGridItem({
|
||||
tags: sticker.tags,
|
||||
url: stickerUrl,
|
||||
animated: sticker.animated,
|
||||
nsfw: sticker.nsfw,
|
||||
user: sticker.user,
|
||||
};
|
||||
const handleContextMenu = (event: React.MouseEvent) => {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user