Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions app/(authed)/forums/[slug]/[post].tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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.');
Expand Down
20 changes: 19 additions & 1 deletion app/(authed)/forums/new.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -92,13 +104,19 @@ export default function NewForum() {
<Text style={[styles.heading, compact && styles.headingCompact]}>Create a forum</Text>

<Text style={styles.label}>Name</Text>
<FormInput placeholder="e.g. Workshop 3: Intro to Linux" value={name} onChangeText={setName} />
<FormInput
placeholder="e.g. Workshop 3: Intro to Linux"
value={name}
onChangeText={setName}
maxLength={MAX_FORUM_NAME_LENGTH}
/>

<Text style={styles.label}>Description (optional)</Text>
<FormInput
placeholder="What this forum is for"
value={description}
onChangeText={setDescription}
maxLength={MAX_FORUM_DESCRIPTION_LENGTH}
multiline
style={{ height: 80, paddingTop: 14 }}
/>
Expand Down
10 changes: 9 additions & 1 deletion components/AttachmentPicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
validateFile,
type Attachment,
} from '../lib/uploads';
import { MAX_ATTACHMENTS_PER_ITEM } from '../lib/content-limits';

type PendingUpload = {
key: string;
Expand Down Expand Up @@ -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,
Expand Down
7 changes: 6 additions & 1 deletion components/AttachmentStrip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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;
Expand Down
4 changes: 4 additions & 0 deletions components/CommentItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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) {
Expand Down
23 changes: 22 additions & 1 deletion components/EditModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -101,14 +114,22 @@ export function EditModal(props: EditPost | EditComment) {
{props.kind === 'post' && (
<>
<Text style={styles.label}>Title</Text>
<FormInput value={title} onChangeText={setTitle} placeholder="Title" />
<FormInput
value={title}
onChangeText={setTitle}
placeholder="Title"
maxLength={MAX_POST_TITLE_LENGTH}
/>
</>
)}

<Text style={styles.label}>Body</Text>
<FormInput
value={body}
onChangeText={setBody}
maxLength={
props.kind === 'post' ? MAX_POST_BODY_LENGTH : MAX_COMMENT_BODY_LENGTH
}
placeholder="Body"
multiline
style={{ height: 160, paddingTop: 14 }}
Expand Down
2 changes: 2 additions & 0 deletions components/InlineComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { ImageIcon, SendIcon } from './Icons';
import { AttachmentPicker, type AttachmentPickerHandle } from './AttachmentPicker';
import { DropZone } from './DropZone';
import { pickFiles, UPLOAD_ACCEPT, type Attachment } from '../lib/uploads';
import { MAX_COMMENT_BODY_LENGTH } from '../lib/content-limits';



Expand Down Expand Up @@ -88,6 +89,7 @@ export const InlineComposer = forwardRef<
]}
value={value}
onChangeText={onChangeText}
maxLength={MAX_COMMENT_BODY_LENGTH}
placeholder={placeholder}
placeholderTextColor={COLORS.textPlaceholder}
multiline
Expand Down
3 changes: 2 additions & 1 deletion components/LinkPreviewCard.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { View, Text, Image, TouchableOpacity, StyleSheet, Linking } from 'react-native';
import { COLORS, BODY_FONT } from '../lib/theme';
import { faviconFor, hostnameFor, pathSummaryFor } from '../lib/link-previews';
import { isSafeExternalUrl } from '../lib/url-safety';



Expand All @@ -18,7 +19,7 @@ export function LinkPreviewCard({

function handlePress() {
if (onPress) onPress();
else Linking.openURL(url).catch(() => undefined);
else if (isSafeExternalUrl(url)) Linking.openURL(url).catch(() => undefined);
}

return (
Expand Down
2 changes: 2 additions & 0 deletions components/MarkdownBody.tsx
Original file line number Diff line number Diff line change
@@ -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';



Expand All @@ -18,6 +19,7 @@ export function MarkdownBody({
<Markdown
style={sheet}
onLinkPress={(url) => {
if (!isSafeExternalUrl(url, true)) return false;
Linking.openURL(url).catch(() => undefined);
return false;
}}
Expand Down
5 changes: 4 additions & 1 deletion components/PostAttachments.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 (
Expand Down
18 changes: 17 additions & 1 deletion components/PostComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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) {
Expand Down Expand Up @@ -120,11 +130,17 @@ export function PostComposer({
</View>

<DropZone onFiles={(files) => pickerRef.current?.addFiles(files)}>
<FormInput placeholder="Title" value={title} onChangeText={setTitle} />
<FormInput
placeholder="Title"
value={title}
onChangeText={setTitle}
maxLength={MAX_POST_TITLE_LENGTH}
/>
<FormInput
placeholder="Write something…"
value={body}
onChangeText={setBody}
maxLength={MAX_POST_BODY_LENGTH}
multiline
style={{ height: 140, paddingTop: 14 }}
/>
Expand Down
1 change: 1 addition & 0 deletions components/ReportModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
/>
Expand Down
18 changes: 18 additions & 0 deletions firebase.json
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
]
Expand Down
Loading