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
34 changes: 30 additions & 4 deletions AuthViewModel.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@

import { useState, useEffect, useCallback } from 'react';
import { createUser } from './Models';
import { createUser, isReservedUsername } from './Models';
import {
saveUser,
userExists,
Expand All @@ -13,10 +13,16 @@ import {
saveActiveSessionUserID,
getActiveSessionUserID,
clearActiveSessionUserID,
generateSalt,
hashPassword,
} from './StorageExtension';
import { hasModerationMatch } from './ContentModeration';
import uuid from 'react-native-uuid';

// Field length caps — mirrored in firestore.rules so client and rules agree.
const MAX_USERNAME_LEN = 64;
const MAX_DISPLAY_NAME_LEN = 64;

export const useAuthViewModel = () => {
const [currentUser, setCurrentUser] = useState(null);
const [isLoggedIn, setIsLoggedIn] = useState(false);
Expand Down Expand Up @@ -62,12 +68,28 @@ export const useAuthViewModel = () => {
setAuthError('Username must be at least 3 characters');
return;
}
if (normalizedUsername.length > MAX_USERNAME_LEN) {
setAuthError(`Username must be at most ${MAX_USERNAME_LEN} characters`);
return;
}
if (normalizedDisplayName.length > MAX_DISPLAY_NAME_LEN) {
setAuthError(`Display name must be at most ${MAX_DISPLAY_NAME_LEN} characters`);
return;
}
if (hasModerationMatch(normalizedUsername)) {
setAuthError('Username contains disallowed language');
return;
}
if (password.length < 6) {
setAuthError('Password must be at least 6 characters');
if (isReservedUsername(normalizedUsername)) {
setAuthError('This username is reserved and cannot be registered.');
return;
}
if (password.length < 10) {
setAuthError('Password must be at least 10 characters');
return;
}
if (password.length > 256) {
setAuthError('Password is too long');
return;
}
if (password !== confirmPassword) {
Expand All @@ -86,11 +108,15 @@ export const useAuthViewModel = () => {

await new Promise((r) => setTimeout(r, 500));

const passwordSalt = generateSalt();
const passwordHash = await hashPassword(password, passwordSalt);

const newUser = createUser({
id: uuid.v4(),
username: normalizedUsername,
displayName: normalizedDisplayName,
password,
passwordHash,
passwordSalt,
role: 'user',
bio: '',
profileImage: null,
Expand Down
56 changes: 55 additions & 1 deletion DiscussionDetailView.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,51 @@ import {
Alert,
Modal,
Platform,
Linking,
} from 'react-native';
import Markdown from 'react-native-markdown-display';
import { createComment } from './Models';
import { hasModerationMatch } from './ContentModeration';
import { isSafeUrl, isHttpUrl } from './urlSafety';
import uuid from 'react-native-uuid';

const COMMENT_MAX_LENGTH = 5000;

// Only allow http(s) image handlers; everything else is dropped.
const SAFE_IMAGE_HANDLERS = ['http://', 'https://'];

// Open links only when their scheme is allowlisted; never hand a
// javascript:/data:/vbscript:/file: URL to Linking.openURL.
const handleMarkdownLinkPress = (url) => {
if (isSafeUrl(url)) {
Linking.openURL(url).catch(() => {});
}
// Always return false so the library never opens the URL itself.
return false;
};

// Render rules that neutralize unsafe link/image hrefs while leaving normal
// http(s) links and images fully functional.
const safeMarkdownRules = {
link: (node, children, parent, styles) => {
const href = node.attributes?.href;
if (!isSafeUrl(href)) {
// Render the link text inline as plain (inert) text.
return <Text key={node.key} style={styles.text}>{children}</Text>;
}
return (
<Text key={node.key} style={styles.link} onPress={() => handleMarkdownLinkPress(href)}>
{children}
</Text>
);
},
image: (node, children, parent, styles) => {
const src = node.attributes?.src;
if (!isHttpUrl(src)) return null;
return <Image key={node.key} style={styles.image} source={{ uri: src }} />;
},
};

const toImageURI = (image) => {
if (!image) return null;
if (typeof image === 'string') return `data:image/jpeg;base64,${image}`;
Expand Down Expand Up @@ -196,7 +235,13 @@ const DiscussionDetailView = ({ discussion, viewModel, currentUser, onBack, onOp
{/* Title & Content */}
<View style={styles.section}>
<Text style={styles.title}>{liveDiscussion.title}</Text>
<Markdown style={styles.markdownContentStyles}>
<Markdown
style={styles.markdownContentStyles}
rules={safeMarkdownRules}
onLinkPress={handleMarkdownLinkPress}
allowedImageHandlers={SAFE_IMAGE_HANDLERS}
defaultImageHandler={null}
>
{liveDiscussion.content || ''}
</Markdown>
</View>
Expand Down Expand Up @@ -354,6 +399,7 @@ const DiscussionDetailView = ({ discussion, viewModel, currentUser, onBack, onOp
value={newCommentText}
onChangeText={setNewCommentText}
multiline={false}
maxLength={COMMENT_MAX_LENGTH}
editable={permissions.canPostOrComment}
/>
<TouchableOpacity
Expand Down Expand Up @@ -422,6 +468,14 @@ const styles = StyleSheet.create({
code_inline: { backgroundColor: '#F3F4F6', color: '#111827', paddingHorizontal: 4 },
fence: { backgroundColor: '#111827', color: '#F9FAFB', borderRadius: 6, padding: 8 },
link: { color: '#2563EB' },
image: {
width: '100%',
height: 220,
resizeMode: 'contain',
marginVertical: 8,
borderRadius: 10,
backgroundColor: '#F3F4F6',
},
},
postImage: {
width: '100%',
Expand Down
77 changes: 57 additions & 20 deletions DiscussionViewModel.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@

import { useState, useCallback, useEffect, useMemo, useRef } from 'react';
import { createDiscussion, createComment, createForumConfig } from './Models';
import { createDiscussion, createComment, createForumConfig, isAdminUsername } from './Models';
import { getForumState, saveForumState } from './StorageExtension';
import { updateUserBanStatus, updateUserMuteStatus, updateUserRole } from './StorageExtension';
import { deleteDiscussionRemote, deleteForumRemote } from './StorageExtension';
import { moderateText } from './ContentModeration';
import uuid from 'react-native-uuid';

Expand All @@ -17,16 +18,12 @@ const SAMPLE_USER_IDS = {
paul: 'sample-user-paul',
};

const ADMIN_IDS = [
'Varun',
'ekansh_mishra',
'si_yuan',
'zwe',
'paul',
'joel',
'julianteh',
'rogeryeo'
];
// Field length caps — mirrored in firestore.rules so client and rules agree.
const MAX_TITLE_LEN = 200;
const MAX_DESCRIPTION_LEN = 1000;
const MAX_CONTENT_LEN = 20000;
const MAX_TAG_LEN = 40;
const MAX_TAGS = 10;

const nowIso = () => new Date().toISOString();
const isForumExpired = (forum) => new Date(forum.expiresAt).getTime() <= Date.now();
Expand Down Expand Up @@ -358,7 +355,7 @@ export const useDiscussionViewModel = () => {
if (!userID) return false;
const username = knownUsers[userID];
if (!username) return false;
return ADMIN_IDS.map((id) => id.toLowerCase()).includes(String(username).trim().toLowerCase());
return isAdminUsername(username);
}, [knownUsers]);

const getPermissionSummary = useCallback((user) => {
Expand All @@ -376,7 +373,7 @@ export const useDiscussionViewModel = () => {
}
const role = String(user?.role || 'user').trim().toLowerCase();
const username = String(user?.username || '').trim().toLowerCase();
const isAdmin = ADMIN_IDS.map((id) => id.toLowerCase()).includes(username);
const isAdmin = isAdminUsername(username);
const forumModerators = Array.isArray(user?.forumModerators) ? user.forumModerators : [];
const isModerator = selectedForum?.id ? forumModerators.includes(selectedForum.id) : false;
const persistedMutedUntil = user?.mutedUntil || null;
Expand Down Expand Up @@ -413,6 +410,10 @@ export const useDiscussionViewModel = () => {
enqueueNotification('Forum end date/time must be in the future.');
return false;
}
if (title.trim().length > MAX_TITLE_LEN) {
enqueueNotification(`Forum title must be at most ${MAX_TITLE_LEN} characters.`, 'danger');
return false;
}
const titleResult = moderateText(title.trim(), blockedWords);
if (titleResult.blockedCount > 0) {
enqueueNotification('Forum title contains blocked language.', 'danger');
Expand Down Expand Up @@ -468,6 +469,28 @@ export const useDiscussionViewModel = () => {
enqueueNotification('This forum is read-only.', 'info');
return;
}
// Input validation (length caps mirror firestore.rules).
if ((title || '').trim().length > MAX_TITLE_LEN) {
enqueueNotification(`Title must be at most ${MAX_TITLE_LEN} characters.`, 'danger');
return false;
}
if ((description || '').trim().length > MAX_DESCRIPTION_LEN) {
enqueueNotification(`Description must be at most ${MAX_DESCRIPTION_LEN} characters.`, 'danger');
return false;
}
if ((content || '').trim().length > MAX_CONTENT_LEN) {
enqueueNotification(`Content must be at most ${MAX_CONTENT_LEN} characters.`, 'danger');
return false;
}
const tagList = Array.isArray(tags) ? tags : [];
if (tagList.length > MAX_TAGS) {
enqueueNotification(`A post may have at most ${MAX_TAGS} tags.`, 'danger');
return false;
}
if (tagList.some((tag) => String(tag || '').length > MAX_TAG_LEN)) {
enqueueNotification(`Each tag must be at most ${MAX_TAG_LEN} characters.`, 'danger');
return false;
}
const titleResult = moderateText(title.trim(), blockedWords);
const descriptionResult = moderateText((description || '').trim(), blockedWords);
const contentResult = moderateText(content.trim(), blockedWords);
Expand Down Expand Up @@ -718,6 +741,8 @@ export const useDiscussionViewModel = () => {
deletedByName: actor?.username || 'moderator',
};
setDeletedDiscussions((prev) => [archived, ...prev]);
// Targeted remote delete (write-only sync no longer removes diffed docs).
deleteDiscussionRemote(discussionID);
enqueueNotification('Post archived by moderation team.', 'danger');
return true;
}, [enqueueNotification, getPermissionSummary]);
Expand Down Expand Up @@ -852,13 +877,15 @@ export const useDiscussionViewModel = () => {
}

setDiscussions((prev) => {
const removedCount = prev.filter((discussion) => discussion.authorID === userID).length;
const removed = prev.filter((discussion) => discussion.authorID === userID);
enqueueNotification(
removedCount > 0
? `User banned and ${removedCount} post(s) removed.`
removed.length > 0
? `User banned and ${removed.length} post(s) removed.`
: 'User banned by admin.',
'danger'
);
// Targeted remote deletes for each removed post.
removed.forEach((discussion) => deleteDiscussionRemote(discussion.id));
return prev.filter((discussion) => discussion.authorID !== userID);
});
return true;
Expand Down Expand Up @@ -1072,12 +1099,14 @@ export const useDiscussionViewModel = () => {
return normalizeName(discussion.authorName) === normalizedAuthorName;
};

const removedCount = prev.filter(matchesTarget).length;
const removed = prev.filter(matchesTarget);
enqueueNotification(
removedCount > 0
? `${removedCount} post(s) removed for this user.`
removed.length > 0
? `${removed.length} post(s) removed for this user.`
: 'No posts found for this user.'
);
// Targeted remote deletes for each removed post.
removed.forEach((discussion) => deleteDiscussionRemote(discussion.id));
return prev.filter((discussion) => !matchesTarget(discussion));
});
return true;
Expand Down Expand Up @@ -1176,12 +1205,20 @@ export const useDiscussionViewModel = () => {

const remaining = forums.filter((forum) => forum.id !== forumID);
setForums(remaining);
// Targeted remote delete of the forum doc.
deleteForumRemote(forumID);

if (selectedForumID === forumID) {
setSelectedForumID(remaining[0]?.id || null);
}

setDiscussions((prev) => prev.filter((discussion) => discussion.forumID !== forumID));
setDiscussions((prev) => {
// Targeted remote deletes for each child post of the removed forum.
prev
.filter((discussion) => discussion.forumID === forumID)
.forEach((discussion) => deleteDiscussionRemote(discussion.id));
return prev.filter((discussion) => discussion.forumID !== forumID);
});
enqueueNotification('Forum deleted with all its posts.', 'danger');
return true;
}, [enqueueNotification, getPermissionSummary, forums, selectedForumID]);
Expand Down
1 change: 1 addition & 0 deletions ForumHomeView.js
Original file line number Diff line number Diff line change
Expand Up @@ -1442,6 +1442,7 @@ const ForumHomeView = ({ authVM, currentUser, onLogout, newUserNotice, clearNewU
placeholderTextColor="#B6BFCC"
value={forumTitle}
onChangeText={setForumTitle}
maxLength={200}
/>
{forumTitleHasBlockedLanguage ? (
<Text style={styles.validationText}>Blocked language detected in forum title.</Text>
Expand Down
3 changes: 2 additions & 1 deletion LexicalMarkdownEditor.js
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
import React from 'react';
import { TextInput } from 'react-native';

const LexicalMarkdownEditor = ({ value, onChange, placeholder, style }) => {
const LexicalMarkdownEditor = ({ value, onChange, placeholder, style, maxLength = 20000 }) => {
return (
<TextInput
style={style}
placeholder={placeholder}
placeholderTextColor="#B6BFCC"
value={value}
onChangeText={onChange}
maxLength={maxLength}
multiline
textAlignVertical="top"
/>
Expand Down
3 changes: 2 additions & 1 deletion LexicalMarkdownEditor.web.js
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
import React from 'react';
import { TextInput } from 'react-native';

const LexicalMarkdownEditor = ({ value, onChange, placeholder, style }) => {
const LexicalMarkdownEditor = ({ value, onChange, placeholder, style, maxLength = 20000 }) => {
return (
<TextInput
style={style}
placeholder={placeholder}
placeholderTextColor="#B6BFCC"
value={value}
onChangeText={onChange}
maxLength={maxLength}
multiline
textAlignVertical="top"
autoCorrect={false}
Expand Down
Loading