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"