diff --git a/app/(authed)/forums/[slug]/[post].tsx b/app/(authed)/forums/[slug]/[post].tsx index e04f0cc..8d00677 100644 --- a/app/(authed)/forums/[slug]/[post].tsx +++ b/app/(authed)/forums/[slug]/[post].tsx @@ -45,6 +45,7 @@ import { EditModal } from '../../../../components/EditModal'; import { PostAttachments } from '../../../../components/PostAttachments'; import { InlineComposer } from '../../../../components/InlineComposer'; import { type Attachment } from '../../../../lib/uploads'; +import { MAX_COMMENT_BODY_LENGTH } from '../../../../lib/content-limits'; type Post = { title: string; @@ -165,6 +166,9 @@ export default function PostDetail() { setError(null); const t = commentText.trim(); if (!t && commentAttachments.length === 0) return; + if (t.length > MAX_COMMENT_BODY_LENGTH) { + return setError(`Comment must be ${MAX_COMMENT_BODY_LENGTH} characters or fewer.`); + } if (!user || !profile || !slug || !postSlug) return; if (timedOut) return setError('You are timed out and cannot comment.'); if (muted) return setError('You are muted in this forum.'); diff --git a/app/(authed)/forums/new.tsx b/app/(authed)/forums/new.tsx index 4db0286..ee2eac8 100644 --- a/app/(authed)/forums/new.tsx +++ b/app/(authed)/forums/new.tsx @@ -11,6 +11,10 @@ import { FormInput } from '../../../components/FormInput'; import { PrimaryButton } from '../../../components/PrimaryButton'; import { DateTimeInput } from '../../../components/DateTimeInput'; import { UserPicker } from '../../../components/UserPicker'; +import { + MAX_FORUM_DESCRIPTION_LENGTH, + MAX_FORUM_NAME_LENGTH, +} from '../../../lib/content-limits'; function toLocalInputValue(d: Date): string { const pad = (n: number) => String(n).padStart(2, '0'); @@ -50,6 +54,14 @@ export default function NewForum() { const trimmedName = name.trim(); const slug = slugify(trimmedName); if (!trimmedName) return setError('Forum needs a name.'); + if (trimmedName.length > MAX_FORUM_NAME_LENGTH) { + return setError(`Name must be ${MAX_FORUM_NAME_LENGTH} characters or fewer.`); + } + if (description.trim().length > MAX_FORUM_DESCRIPTION_LENGTH) { + return setError( + `Description must be ${MAX_FORUM_DESCRIPTION_LENGTH} characters or fewer.`, + ); + } if (!slug) return setError('Name must contain at least one letter or number.'); if (!closesAt) return setError('Pick a close date and time.'); const closesAtMs = new Date(closesAt).getTime(); @@ -92,13 +104,19 @@ export default function NewForum() { Create a forum Name - + Description (optional) diff --git a/components/AttachmentPicker.tsx b/components/AttachmentPicker.tsx index 99f4614..a394e42 100644 --- a/components/AttachmentPicker.tsx +++ b/components/AttachmentPicker.tsx @@ -21,6 +21,7 @@ import { validateFile, type Attachment, } from '../lib/uploads'; +import { MAX_ATTACHMENTS_PER_ITEM } from '../lib/content-limits'; type PendingUpload = { key: string; @@ -95,7 +96,14 @@ export const AttachmentPicker = forwardRef< const rejected: string[] = []; const accepted: File[] = []; - for (const f of files) { + const remainingSlots = Math.max( + 0, + MAX_ATTACHMENTS_PER_ITEM - attachmentsRef.current.length - pending.length, + ); + if (files.length > remainingSlots) { + rejected.push(`You can attach up to ${MAX_ATTACHMENTS_PER_ITEM} files.`); + } + for (const f of files.slice(0, remainingSlots)) { const err = validateFile(f, { allowedTypes: ALLOWED_UPLOAD_TYPES, maxBytes: MAX_UPLOAD_BYTES, diff --git a/components/AttachmentStrip.tsx b/components/AttachmentStrip.tsx index 7af34d0..9fc0014 100644 --- a/components/AttachmentStrip.tsx +++ b/components/AttachmentStrip.tsx @@ -8,6 +8,7 @@ import { } from 'react-native'; import { COLORS, BODY_FONT } from '../lib/theme'; import { formatSize, type Attachment } from '../lib/uploads'; +import { isSafeExternalUrl } from '../lib/url-safety'; export function AttachmentStrip({ attachments, @@ -34,7 +35,11 @@ function AttachmentTile({ attachment: Attachment; small: boolean; }) { - const open = () => Linking.openURL(attachment.url).catch(() => undefined); + const open = () => { + if (isSafeExternalUrl(attachment.url)) { + Linking.openURL(attachment.url).catch(() => undefined); + } + }; if (attachment.kind === 'image') { const dimension = small ? 96 : 160; diff --git a/components/CommentItem.tsx b/components/CommentItem.tsx index 74f5ee6..a36cf37 100644 --- a/components/CommentItem.tsx +++ b/components/CommentItem.tsx @@ -29,6 +29,7 @@ import { RoleTag, UserRoleTags, useUserRole } from './RoleTag'; import { PostAttachments } from './PostAttachments'; import { InlineComposer } from './InlineComposer'; import { deleteAttachment, type Attachment } from '../lib/uploads'; +import { MAX_COMMENT_BODY_LENGTH } from '../lib/content-limits'; import type { Timestamp } from 'firebase/firestore'; export type Comment = { @@ -113,6 +114,9 @@ export function CommentItem({ setReplyError(null); const t = replyText.trim(); if (!t && replyAttachments.length === 0) return; + if (t.length > MAX_COMMENT_BODY_LENGTH) { + return setReplyError(`Reply must be ${MAX_COMMENT_BODY_LENGTH} characters or fewer.`); + } if (!user || !profile) return setReplyError('You must be signed in.'); const blocked = violatesContentFilter(t); if (blocked) { diff --git a/components/EditModal.tsx b/components/EditModal.tsx index 22e2134..d083fc0 100644 --- a/components/EditModal.tsx +++ b/components/EditModal.tsx @@ -8,6 +8,11 @@ import { COLORS, BODY_FONT, HEADING_FONT } from '../lib/theme'; import { logActivity } from '../lib/moderation'; import { FormInput } from './FormInput'; import { XIcon } from './Icons'; +import { + MAX_COMMENT_BODY_LENGTH, + MAX_POST_BODY_LENGTH, + MAX_POST_TITLE_LENGTH, +} from '../lib/content-limits'; type Common = { visible: boolean; @@ -49,6 +54,14 @@ export function EditModal(props: EditPost | EditComment) { if (!user || !profile) return setError('You must be signed in.'); if (props.kind === 'post' && !title.trim()) return setError('Title is required.'); if (!body.trim()) return setError('Body cannot be empty.'); + if (props.kind === 'post' && title.trim().length > MAX_POST_TITLE_LENGTH) { + return setError(`Title must be ${MAX_POST_TITLE_LENGTH} characters or fewer.`); + } + const maxBodyLength = + props.kind === 'post' ? MAX_POST_BODY_LENGTH : MAX_COMMENT_BODY_LENGTH; + if (body.trim().length > maxBodyLength) { + return setError(`Body must be ${maxBodyLength} characters or fewer.`); + } setBusy(true); try { @@ -101,7 +114,12 @@ export function EditModal(props: EditPost | EditComment) { {props.kind === 'post' && ( <> Title - + )} @@ -109,6 +127,9 @@ export function EditModal(props: EditPost | EditComment) { undefined); + else if (isSafeExternalUrl(url)) Linking.openURL(url).catch(() => undefined); } return ( diff --git a/components/MarkdownBody.tsx b/components/MarkdownBody.tsx index a543b67..58246f0 100644 --- a/components/MarkdownBody.tsx +++ b/components/MarkdownBody.tsx @@ -1,6 +1,7 @@ import { StyleSheet, Linking } from 'react-native'; import Markdown from 'react-native-markdown-display'; import { COLORS, BODY_FONT, HEADING_FONT } from '../lib/theme'; +import { isSafeExternalUrl } from '../lib/url-safety'; @@ -18,6 +19,7 @@ export function MarkdownBody({ { + if (!isSafeExternalUrl(url, true)) return false; Linking.openURL(url).catch(() => undefined); return false; }} diff --git a/components/PostAttachments.tsx b/components/PostAttachments.tsx index 3411c2c..1972d6f 100644 --- a/components/PostAttachments.tsx +++ b/components/PostAttachments.tsx @@ -2,6 +2,7 @@ import { useMemo } from 'react'; import { View, Text, StyleSheet, TouchableOpacity, Image, Linking } from 'react-native'; import { COLORS, BODY_FONT } from '../lib/theme'; import { formatSize, type Attachment } from '../lib/uploads'; +import { isSafeExternalUrl } from '../lib/url-safety'; import { ImageCarousel } from './ImageCarousel'; import { LinkPreviewCard } from './LinkPreviewCard'; import { extractUrls } from '../lib/link-previews'; @@ -100,7 +101,9 @@ function FileCard({ onNavigate?.(); return; } - Linking.openURL(attachment.url).catch(() => undefined); + if (isSafeExternalUrl(attachment.url)) { + Linking.openURL(attachment.url).catch(() => undefined); + } } return ( diff --git a/components/PostComposer.tsx b/components/PostComposer.tsx index ea07aa3..90fa4cf 100644 --- a/components/PostComposer.tsx +++ b/components/PostComposer.tsx @@ -20,6 +20,10 @@ import { XIcon } from './Icons'; import { AttachmentPicker, type AttachmentPickerHandle } from './AttachmentPicker'; import { DropZone } from './DropZone'; import { deleteAttachment, type Attachment } from '../lib/uploads'; +import { + MAX_POST_BODY_LENGTH, + MAX_POST_TITLE_LENGTH, +} from '../lib/content-limits'; export function PostComposer({ forumSlug, @@ -45,6 +49,12 @@ export function PostComposer({ const b = body.trim(); if (!t) return setError('Add a title.'); if (!b) return setError('Add some text.'); + if (t.length > MAX_POST_TITLE_LENGTH) { + return setError(`Title must be ${MAX_POST_TITLE_LENGTH} characters or fewer.`); + } + if (b.length > MAX_POST_BODY_LENGTH) { + return setError(`Post must be ${MAX_POST_BODY_LENGTH} characters or fewer.`); + } if (!user || !profile) return setError('You must be signed in.'); const blocked = violatesContentFilter(t) ?? violatesContentFilter(b); if (blocked) { @@ -120,11 +130,17 @@ export function PostComposer({ pickerRef.current?.addFiles(files)}> - + diff --git a/components/ReportModal.tsx b/components/ReportModal.tsx index 51e0239..6928f68 100644 --- a/components/ReportModal.tsx +++ b/components/ReportModal.tsx @@ -164,6 +164,7 @@ export function ReportModal({ placeholder="Optional. Give moderators more context." value={details} onChangeText={setDetails} + maxLength={1000} multiline style={{ height: 110, paddingTop: 14 }} /> diff --git a/firebase.json b/firebase.json index 9f8e016..b19f471 100644 --- a/firebase.json +++ b/firebase.json @@ -21,6 +21,24 @@ "ignore": ["firebase.json", "**/.*", "**/node_modules/**"], "cleanUrls": true, "trailingSlash": false, + "headers": [ + { + "source": "/index.html", + "headers": [ + { "key": "Cache-Control", "value": "no-cache" }, + { "key": "X-Content-Type-Options", "value": "nosniff" }, + { "key": "Referrer-Policy", "value": "strict-origin-when-cross-origin" }, + { "key": "Permissions-Policy", "value": "camera=(), microphone=(), geolocation=()" } + ] + }, + { + "source": "**/*.@(js|css|ttf|woff|woff2|png|jpg|jpeg|gif|webp|ico)", + "headers": [ + { "key": "Cache-Control", "value": "public, max-age=31536000, immutable" }, + { "key": "X-Content-Type-Options", "value": "nosniff" } + ] + } + ], "rewrites": [ { "source": "**", "destination": "/index.html" } ] diff --git a/firestore.rules b/firestore.rules index 442dac2..796e63c 100644 --- a/firestore.rules +++ b/firestore.rules @@ -5,6 +5,38 @@ service cloud.firestore { // ---- helpers --------------------------------------------------------- function isSignedIn() { return request.auth != null; } + function currentProfile() { + return get(/databases/$(database)/documents/users/$(request.auth.uid)).data; + } + + function hasCurrentAuthorIdentity(data) { + return data.authorUid == request.auth.uid && + data.authorUsername == currentProfile().username && + data.authorDisplayName == currentProfile().displayName; + } + + function isBoundedString(value, maxLength) { + return value is string && value.size() > 0 && value.size() <= maxLength; + } + + function isOptionalBoundedString(data, field, maxLength) { + return !data.keys().hasAny([field]) || + (data[field] is string && data[field].size() <= maxLength); + } + + function hasBoundedAttachments(data) { + return !data.keys().hasAny(['attachments']) || + (data.attachments is list && data.attachments.size() <= 10); + } + + function counterMovesAtMostOne(field) { + return resource.data[field] is int && + request.resource.data[field] is int && + request.resource.data[field] >= 0 && + request.resource.data[field] >= resource.data[field] - 1 && + request.resource.data[field] <= resource.data[field] + 1; + } + function forumIsActive(slug) { return get(/databases/$(database)/documents/forums/$(slug)).data.closesAt > request.time; } @@ -44,13 +76,41 @@ service cloud.firestore { // ---- users ----------------------------------------------------------- match /users/{uid} { allow read: if isSignedIn(); - allow create: if isSignedIn() && request.auth.uid == uid; - // username, usernameLower, and email are immutable after creation — - // changing them would bypass the admin allowlist check in isAdmin(). + allow create: if isSignedIn() + && request.auth.uid == uid + && request.resource.data.keys().hasAll([ + 'email', 'username', 'usernameLower', 'displayName', + 'authProvider', 'createdAt' + ]) + && request.resource.data.keys().hasOnly([ + 'email', 'username', 'usernameLower', 'displayName', + 'phoneNumber', 'photoURL', 'authProvider', 'createdAt' + ]) + && request.resource.data.username.matches('^[A-Za-z0-9_.-]{1,20}$') + && request.resource.data.usernameLower == request.resource.data.username.lower() + && isBoundedString(request.resource.data.displayName, 40) + && request.resource.data.email == request.auth.token.email + && request.resource.data.authProvider == 'portal' + && request.resource.data.createdAt == request.time; + // Identity fields are immutable. A user may only update the profile + // fields that the current client actually owns. allow update: if isSignedIn() && request.auth.uid == uid - && !request.resource.data.diff(resource.data).affectedKeys() - .hasAny(['username', 'usernameLower', 'email']); + && request.resource.data.diff(resource.data).affectedKeys() + .hasOnly(['displayName', 'bio', 'photoURL', 'recentForums']) + && isBoundedString(request.resource.data.displayName, 40) + && isOptionalBoundedString(request.resource.data, 'bio', 240) + && ( + !request.resource.data.keys().hasAny(['photoURL']) || + request.resource.data.photoURL == null || + (request.resource.data.photoURL is string && + request.resource.data.photoURL.size() <= 2048) + ) + && ( + !request.resource.data.keys().hasAny(['recentForums']) || + (request.resource.data.recentForums is list && + request.resource.data.recentForums.size() <= 5) + ); // Account deletion — the user can delete their own; admins can delete // anyone (paired with the ban flow so a banned account can be fully // removed after moderation). Both paths always delete the paired @@ -59,15 +119,14 @@ service cloud.firestore { } // ---- usernames index (username->uid lookup) ------- - // Read is public because signup.tsx checks whether a username is taken - // BEFORE creating the auth account (there's no signed-in user yet at - // that point). Usernames are non-sensitive public identifiers (they - // appear in @mentions and profile URLs), so relaxing this to `if true` - // costs nothing security-wise and fixes the "permission-denied on - // signup" error. + // Portal OIDC happens before profile completion, so username availability + // checks never need anonymous access. match /usernames/{name} { - allow read: if true; - allow create: if isSignedIn() && request.resource.data.uid == request.auth.uid; + allow read: if isSignedIn(); + allow create: if isSignedIn() + && name.matches('^[a-z0-9_.-]{1,20}$') + && request.resource.data.keys().hasOnly(['uid']) + && request.resource.data.uid == request.auth.uid; // Owner can release their own username on self-delete. Admins can // release any username as part of the admin-initiated account // deletion of a banned user. @@ -85,8 +144,12 @@ service cloud.firestore { allow create: if isSignedIn() && !userTimedOut() && request.resource.data.reporterUid == request.auth.uid + && request.resource.data.reporterUsername == currentProfile().username && request.resource.data.reportedUid is string - && request.resource.data.reportedUid != request.auth.uid; + && request.resource.data.reportedUid != request.auth.uid + && isBoundedString(request.resource.data.reason, 500) + && request.resource.data.status == 'open' + && request.resource.data.createdAt == request.time; allow update, delete: if isAdmin(); } @@ -95,7 +158,16 @@ service cloud.firestore { // per-forum /mutes subcollection instead. match /timeouts/{uid} { allow read: if isSignedIn(); - allow create, update: if isAdmin(); + allow create, update: if isAdmin() + && request.resource.data.timedOutBy == request.auth.uid + && request.resource.data.timedOutByUsername == + currentProfile().username + && isBoundedString(request.resource.data.reason, 500) + && request.resource.data.createdAt == request.time + && ( + request.resource.data.expiresAt == null || + request.resource.data.expiresAt is timestamp + ); allow delete: if isAdmin(); } @@ -116,14 +188,49 @@ service cloud.firestore { // ---- forums --------------------------------------------------------- match /forums/{slug} { allow read: if isSignedIn(); - allow create: if isSignedIn() && !userTimedOut(); - allow update: if isSignedIn() && ( - request.auth.uid == resource.data.createdBy || - isModOf(slug) || - isAdmin() || - request.resource.data.diff(resource.data).affectedKeys() - .hasOnly(['postCount', 'activeUserCount']) - ); + allow create: if isAdmin() + && !userTimedOut() + && request.resource.data.keys().hasAll([ + 'name', 'slug', 'description', 'closesAt', 'createdAt', + 'createdBy', 'moderatorUids', 'postCount' + ]) + && request.resource.data.keys().hasOnly([ + 'name', 'slug', 'description', 'closesAt', 'createdAt', + 'createdBy', 'moderatorUids', 'postCount' + ]) + && request.resource.data.slug == slug + && request.resource.data.createdBy == request.auth.uid + && request.resource.data.createdAt == request.time + && isBoundedString(request.resource.data.name, 100) + && request.resource.data.description is string + && request.resource.data.description.size() <= 2000 + && request.resource.data.closesAt is timestamp + && request.resource.data.closesAt > request.time + && request.resource.data.moderatorUids is list + && request.resource.data.moderatorUids.size() <= 20 + && request.auth.uid in request.resource.data.moderatorUids + && request.resource.data.postCount == 0; + allow update: if isSignedIn() + && !request.resource.data.diff(resource.data).affectedKeys() + .hasAny(['slug', 'createdBy', 'createdAt']) + && isBoundedString(request.resource.data.name, 100) + && request.resource.data.description is string + && request.resource.data.description.size() <= 2000 + && request.resource.data.moderatorUids is list + && request.resource.data.moderatorUids.size() <= 20 + && ( + isAdmin() || + ( + isModOf(slug) && + request.resource.data.diff(resource.data).affectedKeys() + .hasOnly(['description', 'closesAt']) + ) || + ( + request.resource.data.diff(resource.data).affectedKeys() + .hasOnly(['postCount']) && + request.resource.data.postCount == resource.data.postCount + 1 + ) + ); // Only admins (with an /admins/{uid} doc) can delete a forum, even // a closed (read-only) one. The client cleans up subcollections // first; subcollection delete rules below also allow admins. @@ -133,7 +240,17 @@ service cloud.firestore { // anyone (including other admins). match /mutes/{uid} { allow read: if isSignedIn(); - allow create: if isAdmin() || (isModOf(slug) && !targetIsAdmin(uid)); + allow create: if ( + isAdmin() || + (isModOf(slug) && !targetIsAdmin(uid)) + ) + && request.resource.data.mutedBy == request.auth.uid + && request.resource.data.mutedAt == request.time + && ( + request.resource.data.reason == null || + (request.resource.data.reason is string && + request.resource.data.reason.size() <= 500) + ); allow delete: if isModOf(slug) || isAdmin(); allow update: if false; } @@ -153,7 +270,18 @@ service cloud.firestore { // doc whose parent forum has been removed. match /activity/{eventId} { allow read: if isAdmin() || isModOf(slug); - allow create: if isSignedIn(); + allow create: if isSignedIn() + && request.resource.data.actorUid == request.auth.uid + && request.resource.data.actorUsername == currentProfile().username + && isBoundedString(request.resource.data.type, 40) + && isOptionalBoundedString(request.resource.data, 'details', 500) + && ( + !request.resource.data.type.matches( + '^(post_quarantined|post_unquarantined|user_muted|user_unmuted|user_timed_out|report_resolved)$' + ) || + isModOf(slug) || + isAdmin() + ); allow update: if false; allow delete: if isAdmin() || isModOf(slug); } @@ -167,7 +295,11 @@ service cloud.firestore { || (isSignedIn() && resource.data.reporterUid == request.auth.uid); allow create: if isSignedIn() && !userTimedOut() - && request.resource.data.reporterUid == request.auth.uid; + && request.resource.data.reporterUid == request.auth.uid + && request.resource.data.reporterUsername == currentProfile().username + && isBoundedString(request.resource.data.reason, 40) + && isOptionalBoundedString(request.resource.data, 'details', 1000) + && request.resource.data.status == 'open'; allow update, delete: if isModOf(slug) || isAdmin(); } @@ -178,21 +310,56 @@ service cloud.firestore { allow create: if isSignedIn() && !userTimedOut() && !userMutedIn(slug) - && request.resource.data.authorUid == request.auth.uid + && hasCurrentAuthorIdentity(request.resource.data) + && request.resource.data.slug == postId + && isBoundedString(request.resource.data.title, 160) + && isBoundedString(request.resource.data.body, 20000) + && hasBoundedAttachments(request.resource.data) + && request.resource.data.likeCount == 0 + && request.resource.data.commentCount == 0 + && request.resource.data.nonAuthorCommentCount == 0 + && request.resource.data.reportCount == 0 + && request.resource.data.isQuarantined == false + && request.resource.data.isDeleted == false && forumIsActive(slug); // Author can edit own; mods can edit any (including isQuarantined). // Anyone signed-in can update only the denormalized counters. // isPinned is admin-only — enforced by the outer AND before any branch. - allow update: if isSignedIn() && - (!request.resource.data.diff(resource.data).affectedKeys().hasAny(['isPinned']) || isAdmin()) && ( - (resource.data.authorUid == request.auth.uid && !userTimedOut() && !userMutedIn(slug)) || + allow update: if isSignedIn() + && !request.resource.data.diff(resource.data).affectedKeys() + .hasAny(['slug', 'authorUid', 'authorUsername', 'createdAt']) + && isBoundedString(request.resource.data.title, 160) + && isBoundedString(request.resource.data.body, 20000) + && hasBoundedAttachments(request.resource.data) + && (!request.resource.data.diff(resource.data).affectedKeys().hasAny(['isPinned']) || isAdmin()) && ( + ( + resource.data.authorUid == request.auth.uid && + !userTimedOut() && + !userMutedIn(slug) && + request.resource.data.diff(resource.data).affectedKeys() + .hasOnly(['title', 'body', 'editedAt', 'attachments']) + ) || // Mods can update anyone's post except admins'. - (isModOf(slug) && !targetIsAdmin(resource.data.authorUid)) || + ( + isModOf(slug) && + !targetIsAdmin(resource.data.authorUid) && + request.resource.data.diff(resource.data).affectedKeys() + .hasOnly([ + 'title', 'body', 'editedAt', 'attachments', 'isQuarantined', + 'isDeleted', 'deletedAt', 'deletedBy', 'deletedByUsername' + ]) + ) || isAdmin() || // Counter-only writes (any signed-in user — used for likes/comments). - request.resource.data.diff(resource.data).affectedKeys() - .hasOnly(['likeCount', 'commentCount', 'nonAuthorCommentCount', 'reportCount']) || + ( + request.resource.data.diff(resource.data).affectedKeys() + .hasOnly(['likeCount', 'commentCount', 'nonAuthorCommentCount', 'reportCount']) && + counterMovesAtMostOne('likeCount') && + counterMovesAtMostOne('commentCount') && + counterMovesAtMostOne('nonAuthorCommentCount') && + counterMovesAtMostOne('reportCount') + ) || // Author can soft-delete their own post even if currently muted/ // timed out — you can always remove your own content. (resource.data.authorUid == request.auth.uid && @@ -212,12 +379,33 @@ service cloud.firestore { allow create: if isSignedIn() && !userTimedOut() && !userMutedIn(slug) - && request.resource.data.authorUid == request.auth.uid + && hasCurrentAuthorIdentity(request.resource.data) + && isBoundedString(request.resource.data.body, 10000) + && hasBoundedAttachments(request.resource.data) && forumIsActive(slug); - allow update: if isSignedIn() && ( - (resource.data.authorUid == request.auth.uid && !userTimedOut() && !userMutedIn(slug)) || + allow update: if isSignedIn() + && !request.resource.data.diff(resource.data).affectedKeys() + .hasAny(['authorUid', 'authorUsername', 'createdAt']) + && isBoundedString(request.resource.data.body, 10000) + && hasBoundedAttachments(request.resource.data) + && ( + ( + resource.data.authorUid == request.auth.uid && + !userTimedOut() && + !userMutedIn(slug) && + request.resource.data.diff(resource.data).affectedKeys() + .hasOnly(['body', 'editedAt', 'attachments']) + ) || // Mods can edit any comment except admins'. - (isModOf(slug) && !targetIsAdmin(resource.data.authorUid)) || + ( + isModOf(slug) && + !targetIsAdmin(resource.data.authorUid) && + request.resource.data.diff(resource.data).affectedKeys() + .hasOnly([ + 'body', 'editedAt', 'attachments', 'isDeleted', 'deletedAt', + 'deletedBy', 'deletedByUsername' + ]) + ) || isAdmin() || // Author can soft-delete their own comment regardless of mute/timeout. (resource.data.authorUid == request.auth.uid && diff --git a/lib/admin-tools.ts b/lib/admin-tools.ts index 4f9ba37..5472a9e 100644 --- a/lib/admin-tools.ts +++ b/lib/admin-tools.ts @@ -132,7 +132,7 @@ export function promptModerationReason(action: string, targetUsername: string): window.alert('A reason is required.'); return null; } - return trimmed; + return trimmed.slice(0, 500); } export function describeActionError(scope: string, err: unknown): string { diff --git a/lib/content-limits.ts b/lib/content-limits.ts new file mode 100644 index 0000000..1183369 --- /dev/null +++ b/lib/content-limits.ts @@ -0,0 +1,6 @@ +export const MAX_FORUM_NAME_LENGTH = 100; +export const MAX_FORUM_DESCRIPTION_LENGTH = 2_000; +export const MAX_POST_TITLE_LENGTH = 160; +export const MAX_POST_BODY_LENGTH = 20_000; +export const MAX_COMMENT_BODY_LENGTH = 10_000; +export const MAX_ATTACHMENTS_PER_ITEM = 10; diff --git a/lib/url-safety.ts b/lib/url-safety.ts new file mode 100644 index 0000000..7e80b2a --- /dev/null +++ b/lib/url-safety.ts @@ -0,0 +1,14 @@ +const WEB_PROTOCOLS = new Set(['http:', 'https:']); + +export function isSafeExternalUrl(value: string, allowMailto = false): boolean { + const candidate = value.trim(); + if (!candidate || /[\u0000-\u001f\u007f]/.test(candidate)) return false; + + try { + const parsed = new URL(candidate); + if (WEB_PROTOCOLS.has(parsed.protocol)) return true; + return allowMailto && parsed.protocol === 'mailto:'; + } catch { + return false; + } +} diff --git a/package-lock.json b/package-lock.json index 6421bbc..7e895da 100644 --- a/package-lock.json +++ b/package-lock.json @@ -48,12 +48,12 @@ } }, "node_modules/@babel/code-frame": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", - "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", "license": "MIT", "dependencies": { - "@babel/helper-validator-identifier": "^7.28.5", + "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" }, @@ -62,29 +62,29 @@ } }, "node_modules/@babel/compat-data": { - "version": "7.29.3", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.3.tgz", - "integrity": "sha512-LIVqM46zQWZhj17qA8wb4nW/ixr2y1Nw+r1etiAWgRM6U1IqP+LNhL1yg440jYZR72jCWcWbLWzIosH+uP1fqg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/core": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", - "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.29.0", - "@babel/generator": "^7.29.0", - "@babel/helper-compilation-targets": "^7.28.6", - "@babel/helper-module-transforms": "^7.28.6", - "@babel/helpers": "^7.28.6", - "@babel/parser": "^7.29.0", - "@babel/template": "^7.28.6", - "@babel/traverse": "^7.29.0", - "@babel/types": "^7.29.0", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", @@ -101,13 +101,13 @@ } }, "node_modules/@babel/generator": { - "version": "7.29.1", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", - "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", "license": "MIT", "dependencies": { - "@babel/parser": "^7.29.0", - "@babel/types": "^7.29.0", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" @@ -129,13 +129,13 @@ } }, "node_modules/@babel/helper-compilation-targets": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", - "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", "license": "MIT", "dependencies": { - "@babel/compat-data": "^7.28.6", - "@babel/helper-validator-option": "^7.27.1", + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" @@ -199,9 +199,9 @@ } }, "node_modules/@babel/helper-globals": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", - "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", "license": "MIT", "engines": { "node": ">=6.9.0" @@ -221,27 +221,27 @@ } }, "node_modules/@babel/helper-module-imports": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", - "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", "license": "MIT", "dependencies": { - "@babel/traverse": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-module-transforms": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", - "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", "license": "MIT", "dependencies": { - "@babel/helper-module-imports": "^7.28.6", - "@babel/helper-validator-identifier": "^7.28.5", - "@babel/traverse": "^7.28.6" + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -263,9 +263,9 @@ } }, "node_modules/@babel/helper-plugin-utils": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", - "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", "license": "MIT", "engines": { "node": ">=6.9.0" @@ -319,27 +319,27 @@ } }, "node_modules/@babel/helper-string-parser": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-validator-option": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", - "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", "license": "MIT", "engines": { "node": ">=6.9.0" @@ -360,13 +360,13 @@ } }, "node_modules/@babel/helpers": { - "version": "7.29.2", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", - "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", "license": "MIT", "dependencies": { - "@babel/template": "^7.28.6", - "@babel/types": "^7.29.0" + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -459,12 +459,12 @@ } }, "node_modules/@babel/parser": { - "version": "7.29.3", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.3.tgz", - "integrity": "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", "license": "MIT", "dependencies": { - "@babel/types": "^7.29.0" + "@babel/types": "^7.29.7" }, "bin": { "parser": "bin/babel-parser.js" @@ -1407,16 +1407,16 @@ } }, "node_modules/@babel/plugin-transform-modules-systemjs": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.29.0.tgz", - "integrity": "sha512-PrujnVFbOdUpw4UHiVwKvKRLMMic8+eC0CuNlxjsyZUiBjhFdPsewdXCkveh2KqBA9/waD0W1b4hXSOBQJezpQ==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.29.7.tgz", + "integrity": "sha512-TM2ZcQLoG2/y4HODiStCo10DibYhWhGWAwVv+EQKmG/7GFl0N+AAmUiXOMKM+aiJ9XBJ9AHVZBvTzMnJ2sM3cQ==", "license": "MIT", "peer": true, "dependencies": { - "@babel/helper-module-transforms": "^7.28.6", - "@babel/helper-plugin-utils": "^7.28.6", - "@babel/helper-validator-identifier": "^7.28.5", - "@babel/traverse": "^7.29.0" + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -2161,31 +2161,31 @@ } }, "node_modules/@babel/template": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", - "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.28.6", - "@babel/parser": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/traverse": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", - "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.29.0", - "@babel/generator": "^7.29.0", - "@babel/helper-globals": "^7.28.0", - "@babel/parser": "^7.29.0", - "@babel/template": "^7.28.6", - "@babel/types": "^7.29.0", + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", "debug": "^4.3.1" }, "engines": { @@ -2212,13 +2212,13 @@ } }, "node_modules/@babel/types": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", - "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", "license": "MIT", "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.28.5" + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -3510,9 +3510,9 @@ "license": "Apache-2.0" }, "node_modules/@grpc/grpc-js": { - "version": "1.9.15", - "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.9.15.tgz", - "integrity": "sha512-nqE7Hc0AzI+euzUwDAy0aY5hCp10r734gMGRdU+qOPX0XSceI2ULrcXB5U2xSc5VkWwalCj4M7GzCAygZl2KoQ==", + "version": "1.9.16", + "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.9.16.tgz", + "integrity": "sha512-wE4Ut/olIzfKqp631XrG+wbF0v1vWFN4YL9FyXC2LJiG33DsV7PLzURjrCvY/6je2ntdRkeLpPDluzSRGaVltQ==", "license": "Apache-2.0", "dependencies": { "@grpc/proto-loader": "^0.7.8", @@ -3634,9 +3634,9 @@ } }, "node_modules/@istanbuljs/load-nyc-config/node_modules/js-yaml": { - "version": "3.14.2", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", - "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", + "version": "3.15.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.0.tgz", + "integrity": "sha512-ttBQIIQPDeLjpPOohtUdXuXUVoA2uIB6fEH9HyJ7234s5mBJ5wTx20njxplLZQgLaOfpmPQA7X2t5AX6tIPbog==", "license": "MIT", "dependencies": { "argparse": "^1.0.7", @@ -3955,19 +3955,18 @@ "license": "BSD-3-Clause" }, "node_modules/@protobufjs/eventemitter": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz", - "integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz", + "integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==", "license": "BSD-3-Clause" }, "node_modules/@protobufjs/fetch": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.0.tgz", - "integrity": "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz", + "integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==", "license": "BSD-3-Clause", "dependencies": { - "@protobufjs/aspromise": "^1.1.1", - "@protobufjs/inquire": "^1.1.0" + "@protobufjs/aspromise": "^1.1.1" } }, "node_modules/@protobufjs/float": { @@ -3976,12 +3975,6 @@ "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", "license": "BSD-3-Clause" }, - "node_modules/@protobufjs/inquire": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.1.tgz", - "integrity": "sha512-mnzgDV26ueAvk7rsbt9L7bE0SuAoqyuys/sMMrmVcN5x9VsxpcG3rqAUSgDyLp0UZlmNfIbQ4fHfCtreVBk8Ew==", - "license": "BSD-3-Clause" - }, "node_modules/@protobufjs/path": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", @@ -4326,9 +4319,9 @@ "license": "ISC" }, "node_modules/@react-native/community-cli-plugin/node_modules/ws": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/ws/-/ws-6.2.3.tgz", - "integrity": "sha512-jmTjYU0j60B+vHey6TfR3Z7RD61z/hmxBS3VMSGIrroOWXQEneK1zNuotOUrGyBHQj0yrpsLHPWtigEFd13ndA==", + "version": "6.2.6", + "resolved": "https://registry.npmjs.org/ws/-/ws-6.2.6.tgz", + "integrity": "sha512-XTrf1gv7kXoVf1hbC3PAyAiPgR8Wz1blcrYIjEsUmr08BLksT41R8KbjmS9408C2ERx7v1JDLD/BkpLEttjfKA==", "license": "MIT", "dependencies": { "async-limiter": "~1.0.0" @@ -4382,9 +4375,9 @@ "license": "MIT" }, "node_modules/@react-native/dev-middleware/node_modules/ws": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/ws/-/ws-6.2.3.tgz", - "integrity": "sha512-jmTjYU0j60B+vHey6TfR3Z7RD61z/hmxBS3VMSGIrroOWXQEneK1zNuotOUrGyBHQj0yrpsLHPWtigEFd13ndA==", + "version": "6.2.6", + "resolved": "https://registry.npmjs.org/ws/-/ws-6.2.6.tgz", + "integrity": "sha512-XTrf1gv7kXoVf1hbC3PAyAiPgR8Wz1blcrYIjEsUmr08BLksT41R8KbjmS9408C2ERx7v1JDLD/BkpLEttjfKA==", "license": "MIT", "dependencies": { "async-limiter": "~1.0.0" @@ -5446,9 +5439,9 @@ } }, "node_modules/brace-expansion": { - "version": "1.1.14", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", - "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", + "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", @@ -6095,9 +6088,9 @@ } }, "node_modules/cosmiconfig/node_modules/js-yaml": { - "version": "3.14.2", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", - "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", + "version": "3.15.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.0.tgz", + "integrity": "sha512-ttBQIIQPDeLjpPOohtUdXuXUVoA2uIB6fEH9HyJ7234s5mBJ5wTx20njxplLZQgLaOfpmPQA7X2t5AX6tIPbog==", "license": "MIT", "dependencies": { "argparse": "^1.0.7", @@ -6954,9 +6947,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.1.tgz", - "integrity": "sha512-h2r7rcm6Ee/J8o0LD5djLuFVcfbZxhvho4vvsbeV0aMvXjUgqv4YpxpkEx0d68l6+IleVfLAdVEfhR7QNMkGHQ==", + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz", + "integrity": "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==", "funding": [ { "type": "github", @@ -7193,15 +7186,15 @@ } }, "node_modules/form-data": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-3.0.4.tgz", - "integrity": "sha512-f0cRzm6dkyVYV3nPoooP8XlccPQukegwhAnpoLcXy+X+A8KfpGOoXwDr9FLZd3wzgLaBGQBE3lY93Zm/i1JvIQ==", + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-3.0.5.tgz", + "integrity": "sha512-j23EibVLnp4zNXGW7LjryXYa2X6U/M96yoOX+ybZxwkYajdxRNEqYY3zhh7y0i6kfISKS2jr+EJq1YTUDEv5+w==", "license": "MIT", "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", - "hasown": "^2.0.2", + "hasown": "^2.0.4", "mime-types": "^2.1.35" }, "engines": { @@ -7400,9 +7393,9 @@ } }, "node_modules/glob/node_modules/brace-expansion": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz", - "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz", + "integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==", "license": "MIT", "dependencies": { "balanced-match": "^1.0.0" @@ -7498,9 +7491,9 @@ } }, "node_modules/hasown": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", - "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -8152,9 +8145,19 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", + "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], "license": "MIT", "dependencies": { "argparse": "^2.0.1" @@ -9150,9 +9153,9 @@ "license": "MIT" }, "node_modules/metro/node_modules/ws": { - "version": "7.5.10", - "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz", - "integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==", + "version": "7.5.13", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.13.tgz", + "integrity": "sha512-rsKI6xDBFVf4r/x8XyChGK04QR/XHroxs/jUcoWvtEZM8TPU/X/uIY9B1CsSzYws9ZJb/6bbBu7dPhFW00CAoA==", "license": "MIT", "engines": { "node": ">=8.3.0" @@ -10210,24 +10213,23 @@ "license": "MIT" }, "node_modules/protobufjs": { - "version": "7.5.6", - "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.6.tgz", - "integrity": "sha512-M71sTMB146U3u0di3yup8iM+zv8yPRNQVr1KK4tyBitl3qFvEGucq/rGDRShD2rsJhtN02RJaJ7j5X5hmy8SJg==", + "version": "7.6.5", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.5.tgz", + "integrity": "sha512-/FPD0nUc9jH6rfFjji9IBqOz4pcSE3CsT1m7Ep6Mdb0LxSUMj8hgl6GomOvZzpNpAqqGaXA0P3VSrZLFzIhQrw==", "hasInstallScript": true, "license": "BSD-3-Clause", "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.5", - "@protobufjs/eventemitter": "^1.1.0", - "@protobufjs/fetch": "^1.1.0", + "@protobufjs/eventemitter": "^1.1.1", + "@protobufjs/fetch": "^1.1.1", "@protobufjs/float": "^1.0.2", - "@protobufjs/inquire": "^1.1.1", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.1", "@types/node": ">=13.7.0", - "long": "^5.0.0" + "long": "^5.3.2" }, "engines": { "node": ">=12.0.0" @@ -10354,9 +10356,9 @@ } }, "node_modules/react-devtools-core/node_modules/ws": { - "version": "7.5.10", - "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz", - "integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==", + "version": "7.5.13", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.13.tgz", + "integrity": "sha512-rsKI6xDBFVf4r/x8XyChGK04QR/XHroxs/jUcoWvtEZM8TPU/X/uIY9B1CsSzYws9ZJb/6bbBu7dPhFW00CAoA==", "license": "MIT", "engines": { "node": ">=8.3.0" @@ -10692,9 +10694,9 @@ } }, "node_modules/react-native/node_modules/ws": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/ws/-/ws-6.2.3.tgz", - "integrity": "sha512-jmTjYU0j60B+vHey6TfR3Z7RD61z/hmxBS3VMSGIrroOWXQEneK1zNuotOUrGyBHQj0yrpsLHPWtigEFd13ndA==", + "version": "6.2.6", + "resolved": "https://registry.npmjs.org/ws/-/ws-6.2.6.tgz", + "integrity": "sha512-XTrf1gv7kXoVf1hbC3PAyAiPgR8Wz1blcrYIjEsUmr08BLksT41R8KbjmS9408C2ERx7v1JDLD/BkpLEttjfKA==", "license": "MIT", "dependencies": { "async-limiter": "~1.0.0" @@ -11226,9 +11228,9 @@ } }, "node_modules/shell-quote": { - "version": "1.8.3", - "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz", - "integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==", + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.10.0.tgz", + "integrity": "sha512-w1aiOKwKuRgtwAReIIj89puqg+I7GvX4IbLrvmhXbzQsj1+Zwi4VO3+fa6ZF91TWSjIxoEkKnMeHcLEODK5ZXA==", "license": "MIT", "engines": { "node": ">= 0.4" @@ -12026,9 +12028,9 @@ "license": "MIT" }, "node_modules/undici": { - "version": "6.25.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-6.25.0.tgz", - "integrity": "sha512-ZgpWDC5gmNiuY9CnLVXEH8rl50xhRCuLNA97fAUnKi8RRuV4E6KG31pDTsLVUKnohJE0I3XDrTeEydAXRw47xg==", + "version": "6.28.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.28.0.tgz", + "integrity": "sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA==", "license": "MIT", "engines": { "node": ">=18.17" @@ -12265,9 +12267,9 @@ "license": "BSD-2-Clause" }, "node_modules/websocket-driver": { - "version": "0.7.4", - "resolved": "https://registry.npmjs.org/websocket-driver/-/websocket-driver-0.7.4.tgz", - "integrity": "sha512-b17KeDIQVjvb0ssuSDF2cYXSg2iztliJ4B9WdsuB6J952qCPKmnVq4DyW5motImXHDC1cBT/1UezrJVsKw5zjg==", + "version": "0.7.5", + "resolved": "https://registry.npmjs.org/websocket-driver/-/websocket-driver-0.7.5.tgz", + "integrity": "sha512-ZL2+3c7kMBdIRCMz6l8jQMHyGVxj+UL+xVk74Ombiciboca8rHa15L86B19E5oh1pL9Ii/uj54gtsIrZGMo6zA==", "license": "Apache-2.0", "dependencies": { "http-parser-js": ">=0.5.1", @@ -12470,9 +12472,9 @@ "license": "ISC" }, "node_modules/ws": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", - "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", + "version": "8.21.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz", + "integrity": "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==", "license": "MIT", "engines": { "node": ">=10.0.0"