fix: merge main, address code review feedback for security fix PR #816
- Merge origin/main into branch (resolve conflict in integrations_dashboard.html) - Add defensive JSON parsing with try/except for integration.config - Wrap tester() call in try/except to prevent 500 errors from bad config - Add i18n key integrations.connection_test_failed_fallback in en.json - Reference i18n key in template JS fallback message - Update SECURITY_AUDIT.md: add fix date (2026-03-23), update doc date - Remove accidental revert.sh file - Fix missing MagicMock/patch imports in test file - Add tests for invalid JSON config and tester exception error paths Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com> Agent-Logs-Url: https://github.com/christianlouis/DocuElevate/sessions/daebb70e-059a-4601-8864-88eef49f99cf
This commit is contained in:
@@ -12,6 +12,7 @@ export default function AuthLayout() {
|
||||
<Stack screenOptions={{ headerShown: false }}>
|
||||
<Stack.Screen name="index" />
|
||||
<Stack.Screen name="login" />
|
||||
<Stack.Screen name="qr-scanner" />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
/**
|
||||
* QR scanner route – camera-based QR code scanning for mobile login.
|
||||
*/
|
||||
export { default } from "../../src/screens/QRScannerScreen";
|
||||
@@ -11,10 +11,15 @@ import { Ionicons } from "@expo/vector-icons";
|
||||
import React from "react";
|
||||
import { usePushNotifications } from "../../src/hooks/usePushNotifications";
|
||||
import { useAuth } from "../../src/context/AuthContext";
|
||||
import { useLocale, t } from "../../src/i18n";
|
||||
|
||||
export default function TabLayout() {
|
||||
const { isAuthenticated } = useAuth();
|
||||
usePushNotifications(isAuthenticated);
|
||||
// Subscribe to language changes so tab labels re-render when the language
|
||||
// is switched. The `lang` variable is intentionally unused – its only
|
||||
// purpose is to make this component a consumer of LocaleContext.
|
||||
useLocale();
|
||||
|
||||
return (
|
||||
<Tabs
|
||||
@@ -37,8 +42,8 @@ export default function TabLayout() {
|
||||
<Tabs.Screen
|
||||
name="index"
|
||||
options={{
|
||||
title: "Upload",
|
||||
tabBarLabel: "Upload",
|
||||
title: t("tabs.upload"),
|
||||
tabBarLabel: t("tabs.upload"),
|
||||
tabBarIcon: ({ color, size }) => (
|
||||
<Ionicons name="cloud-upload-outline" size={size} color={color} />
|
||||
),
|
||||
@@ -48,23 +53,32 @@ export default function TabLayout() {
|
||||
<Tabs.Screen
|
||||
name="files"
|
||||
options={{
|
||||
title: "Files",
|
||||
tabBarLabel: "Files",
|
||||
title: t("tabs.files"),
|
||||
tabBarLabel: t("tabs.files"),
|
||||
tabBarIcon: ({ color, size }) => (
|
||||
<Ionicons name="document-text-outline" size={size} color={color} />
|
||||
),
|
||||
headerTitle: "My Documents",
|
||||
headerTitle: t("files.title"),
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="profile"
|
||||
options={{
|
||||
title: "Profile",
|
||||
tabBarLabel: "Profile",
|
||||
title: t("tabs.profile"),
|
||||
tabBarLabel: t("tabs.profile"),
|
||||
tabBarIcon: ({ color, size }) => (
|
||||
<Ionicons name="person-circle-outline" size={size} color={color} />
|
||||
),
|
||||
headerTitle: "Profile",
|
||||
headerTitle: t("tabs.profile"),
|
||||
}}
|
||||
/>
|
||||
{/* File detail screen – hidden from tab bar, accessed via navigation */}
|
||||
<Tabs.Screen
|
||||
name="file-detail"
|
||||
options={{
|
||||
href: null,
|
||||
title: t("file_detail.title"),
|
||||
headerTitle: t("file_detail.title"),
|
||||
}}
|
||||
/>
|
||||
</Tabs>
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
/**
|
||||
* File detail route – displays processing status and logs for a single file.
|
||||
*/
|
||||
export { default } from "../../src/screens/FileDetailScreen";
|
||||
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* Catch-all "not found" route for expo-router.
|
||||
*
|
||||
* This screen intercepts two different situations:
|
||||
*
|
||||
* 1. **iOS "Open In…" / share sheet** — iOS delivers files to the app via a
|
||||
* `docuelevate://<path>` URL. expo-router strips the custom scheme and
|
||||
* tries to match the raw filesystem path (e.g.
|
||||
* `/private/var/mobile/Library/…/file.pdf`) as an in-app route. Because
|
||||
* no such route exists, expo-router previously threw "unmatched route
|
||||
* docuelevate://…" and the upload never happened.
|
||||
*
|
||||
* This screen detects the filesystem-path pattern, adds the file directly
|
||||
* to `ShareContext`, and redirects to the Upload tab. `UploadScreen`
|
||||
* picks up the pending file and begins uploading automatically.
|
||||
*
|
||||
* The `Linking` listener in `_layout.tsx` may also fire for the same URL;
|
||||
* `ShareContext.addPendingFile` deduplicates by URI so the file is only
|
||||
* uploaded once.
|
||||
*
|
||||
* 2. **Any other unmatched in-app route** — redirect silently to the root so
|
||||
* the user isn't left on a blank error page.
|
||||
*/
|
||||
|
||||
import { usePathname, useRouter } from "expo-router";
|
||||
import React, { useEffect } from "react";
|
||||
import { ActivityIndicator, StyleSheet, View } from "react-native";
|
||||
import { useShare } from "../src/context/ShareContext";
|
||||
import { mimeTypeFromFilename } from "../src/utils/mimeTypes";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* First path-segment names that identify iOS/Android sandbox filesystem paths.
|
||||
* These can never be expo-router route-group names, so their presence is a
|
||||
* strong positive signal that the URL is a shared file rather than a route.
|
||||
*
|
||||
* iOS: /private/var/mobile/… → "private"
|
||||
* /var/mobile/… → "var" (symlink to /private/var/mobile)
|
||||
* /tmp/… → "tmp"
|
||||
* Android: /data/user/0/… → "data"
|
||||
* /storage/emulated/0/… → "storage"
|
||||
*/
|
||||
const FS_PATH_ROOTS = ["private", "var", "tmp", "data", "storage"];
|
||||
|
||||
/**
|
||||
* Route-group / special-file prefixes that identify genuine in-app routes
|
||||
* rather than filesystem path segments.
|
||||
*
|
||||
* ⚠️ Keep this list in sync with the top-level entries in the `app/`
|
||||
* directory. Add an entry here if you add a new top-level route group
|
||||
* that does **not** use the parentheses convention.
|
||||
*/
|
||||
const IN_APP_ROUTE_PREFIXES = [
|
||||
"(auth)", // app/(auth)/
|
||||
"(tabs)", // app/(tabs)/
|
||||
"_", // expo-router special files (_layout, _sitemap, …)
|
||||
"+", // expo-router special files (+not-found, …)
|
||||
"--", // Expo Go development proxy prefix
|
||||
];
|
||||
|
||||
/**
|
||||
* Return `true` when `pathname` looks like a filesystem path delivered by iOS
|
||||
* "Open In…" (e.g. `/private/var/mobile/Library/…/file.pdf`) rather than a
|
||||
* legitimate in-app route.
|
||||
*
|
||||
* Detection strategy:
|
||||
* 1. **Positive check** – if the first path segment matches a known device
|
||||
* filesystem root (see `FS_PATH_ROOTS`), it is definitely a file path.
|
||||
* 2. **Fallback negative check** – if the path does not start with any known
|
||||
* in-app route prefix (see `IN_APP_ROUTE_PREFIXES`), treat it as a file
|
||||
* path. This is a heuristic but safe because expo-router route groups
|
||||
* always use parentheses (e.g. `(auth)`, `(tabs)`).
|
||||
*/
|
||||
function looksLikeFilePath(pathname: string): boolean {
|
||||
const stripped = pathname.replace(/^\/+/, "");
|
||||
if (stripped.length === 0) return false;
|
||||
|
||||
// Positive signal: path starts with a known device filesystem root segment.
|
||||
const firstSegment = stripped.split("/")[0];
|
||||
if (FS_PATH_ROOTS.includes(firstSegment)) return true;
|
||||
|
||||
// Fallback: paths that start with a known in-app route prefix are routes.
|
||||
return !IN_APP_ROUTE_PREFIXES.some((prefix) => stripped.startsWith(prefix));
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract a display filename from a filesystem path.
|
||||
* Handles URL-encoded characters and strips query strings.
|
||||
*/
|
||||
function filenameFromPath(pathname: string): string {
|
||||
try {
|
||||
const decoded = decodeURIComponent(pathname);
|
||||
const segments = decoded.split("/").filter(Boolean);
|
||||
const last = segments[segments.length - 1] ?? "shared_file";
|
||||
return last.split("?")[0] || "shared_file";
|
||||
} catch {
|
||||
return "shared_file";
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Screen component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export default function NotFoundScreen() {
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const { addPendingFile } = useShare();
|
||||
|
||||
// Guard: track which pathname has been handled so the effect does not
|
||||
// re-fire when `router` or `addPendingFile` change identity mid-navigation.
|
||||
const handledRef = React.useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (handledRef.current === pathname) return; // already handled
|
||||
handledRef.current = pathname;
|
||||
|
||||
if (looksLikeFilePath(pathname)) {
|
||||
// Filesystem path from iOS "Open In…" – add the file to ShareContext
|
||||
// and redirect to the Upload tab. UploadScreen will pick up the
|
||||
// pending file and begin uploading automatically.
|
||||
//
|
||||
// The pathname from expo-router is the raw filesystem path
|
||||
// (e.g. "/private/var/mobile/Library/…/file.pdf"). Reconstruct a
|
||||
// file:// URI so the upload logic can read the file.
|
||||
const fileUri = `file://${pathname}`;
|
||||
const filename = filenameFromPath(pathname);
|
||||
addPendingFile({ uri: fileUri, filename, mimeType: mimeTypeFromFilename(filename) });
|
||||
router.replace("/(tabs)/");
|
||||
} else {
|
||||
// Truly unknown in-app route – fall back to the root redirect.
|
||||
router.replace("/");
|
||||
}
|
||||
}, [pathname, router, addPendingFile]);
|
||||
|
||||
// Show a brief spinner while the redirect is in flight.
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<ActivityIndicator size="large" color="#1e40af" />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
backgroundColor: "#f9fafb",
|
||||
},
|
||||
});
|
||||
+123
-4
@@ -4,23 +4,135 @@
|
||||
* Wraps the entire app in AuthProvider and SafeAreaProvider, then uses the
|
||||
* AuthGuard component to redirect between the unauthenticated (auth) route
|
||||
* group and the authenticated (tabs) route group based on session state.
|
||||
*
|
||||
* ShareProvider + Linking listener: when iOS opens the app via the share
|
||||
* sheet (CFBundleDocumentTypes) or Android via a SEND intent, the incoming
|
||||
* file:// / content:// URL is captured and forwarded to UploadScreen via
|
||||
* ShareContext.
|
||||
*
|
||||
* The companion `+not-found.tsx` handles the case where expo-router receives
|
||||
* a `docuelevate://` URL with a filesystem path (from iOS "Open In…") and
|
||||
* cannot match it to a route. It adds the file directly to ShareContext and
|
||||
* redirects to the Upload tab so the file is uploaded transparently.
|
||||
*/
|
||||
|
||||
import * as Linking from "expo-linking";
|
||||
import { Stack, useRouter, useSegments } from "expo-router";
|
||||
import React, { useEffect } from "react";
|
||||
import { ActivityIndicator, StyleSheet, Text, View } from "react-native";
|
||||
import { SafeAreaProvider } from "react-native-safe-area-context";
|
||||
import { AuthProvider, useAuth } from "../src/context/AuthContext";
|
||||
import { ShareProvider, useShare } from "../src/context/ShareContext";
|
||||
import { LocaleProvider, useLocale, isLanguageSupported } from "../src/i18n";
|
||||
import { mimeTypeFromFilename } from "../src/utils/mimeTypes";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** The custom URL scheme registered in app.json. */
|
||||
const APP_SCHEME_PREFIX = "docuelevate://";
|
||||
|
||||
/**
|
||||
* Known deep-link path prefixes that should NOT be treated as shared files.
|
||||
* These are in-app deep-link routes handled by their respective screens
|
||||
* (e.g. QR login, OAuth callback).
|
||||
*/
|
||||
const DEEP_LINK_PATHS = ["qr-login", "callback"];
|
||||
|
||||
/** Extract a display filename from a file:// or content:// URI. */
|
||||
function filenameFromUri(uri: string): string {
|
||||
try {
|
||||
const decoded = decodeURIComponent(uri);
|
||||
// Take the last path segment and strip any query string
|
||||
const last = decoded.split("/").pop() ?? "shared_file";
|
||||
return last.split("?")[0] || "shared_file";
|
||||
} catch {
|
||||
return "shared_file";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a Linking URL handler that forwards incoming file:// / content://
|
||||
* URLs to ShareContext. Extracted as a module-level factory so the handler
|
||||
* itself is created once and can be easily unit-tested without a React context.
|
||||
*
|
||||
* On iOS the Share Sheet / "Open In…" action may deliver the file path under
|
||||
* the app's custom URL scheme (`docuelevate://…/file.pdf`) instead of a plain
|
||||
* `file://` URL. When that happens we rewrite the URL to `file:///…` so the
|
||||
* upload logic can read the file normally.
|
||||
*
|
||||
* Note: expo-router also receives the same URL and will attempt to match it as
|
||||
* an in-app route. When no route matches it renders `+not-found.tsx`, which
|
||||
* adds the file to ShareContext directly and redirects to the Upload tab.
|
||||
* Both this handler and `+not-found.tsx` call `addPendingFile`;
|
||||
* `ShareContext` deduplicates by URI so the file is only uploaded once.
|
||||
*/
|
||||
function makeUrlHandler(addPendingFile: (f: { uri: string; filename: string; mimeType?: string }) => void) {
|
||||
return ({ url }: { url: string }) => {
|
||||
let fileUri = url;
|
||||
|
||||
// iOS may pass a filesystem path under the app's custom scheme.
|
||||
// Rewrite it to a file:// URL unless it looks like an in-app deep-link
|
||||
// (expo-router groups always start with "(").
|
||||
if (url.startsWith(APP_SCHEME_PREFIX)) {
|
||||
const path = url.slice(APP_SCHEME_PREFIX.length);
|
||||
|
||||
// Skip known in-app deep-link paths (e.g. qr-login, callback).
|
||||
// These are handled by their respective screens, not the share flow.
|
||||
const pathBase = path.split("?")[0].replace(/^\/+/, "");
|
||||
if (DEEP_LINK_PATHS.includes(pathBase) || path.startsWith("(")) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (path.length > 0) {
|
||||
fileUri = "file:///" + path.replace(/^\/+/, "");
|
||||
}
|
||||
}
|
||||
|
||||
if (!fileUri.startsWith("file://") && !fileUri.startsWith("content://")) return;
|
||||
const filename = filenameFromUri(fileUri);
|
||||
addPendingFile({ uri: fileUri, filename, mimeType: mimeTypeFromFilename(filename) });
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Auth guard – redirects to the correct route group after auth state resolves
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function AuthGuard() {
|
||||
const { isLoading, isAuthenticated } = useAuth();
|
||||
const { isLoading, isAuthenticated, user } = useAuth();
|
||||
const { addPendingFile } = useShare();
|
||||
const { setLang } = useLocale();
|
||||
const segments = useSegments();
|
||||
const router = useRouter();
|
||||
|
||||
// Apply the server-side language preference whenever the user profile is
|
||||
// loaded (on login or app resume). This syncs the language set on the
|
||||
// desktop/web client to the mobile app. If the server language is not
|
||||
// supported by the mobile app, we leave the current language unchanged.
|
||||
useEffect(() => {
|
||||
if (user?.preferred_language && isLanguageSupported(user.preferred_language)) {
|
||||
void setLang(user.preferred_language);
|
||||
}
|
||||
}, [user?.preferred_language, setLang]);
|
||||
|
||||
// Listen for files shared from other apps (iOS Share Sheet / Android Intent).
|
||||
// Both cold-start (app was not running) and warm-start (app in background)
|
||||
// cases are handled.
|
||||
useEffect(() => {
|
||||
const handleIncomingUrl = makeUrlHandler(addPendingFile);
|
||||
|
||||
// Cold start – app launched directly by a share action
|
||||
Linking.getInitialURL().then((url) => {
|
||||
if (url) handleIncomingUrl({ url });
|
||||
});
|
||||
|
||||
// Warm start – app was already running when the share action occurred
|
||||
const subscription = Linking.addEventListener("url", handleIncomingUrl);
|
||||
return () => subscription.remove();
|
||||
}, [addPendingFile]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoading) return;
|
||||
|
||||
@@ -46,8 +158,11 @@ function AuthGuard() {
|
||||
|
||||
return (
|
||||
<Stack screenOptions={{ headerShown: false }}>
|
||||
<Stack.Screen name="index" />
|
||||
<Stack.Screen name="(auth)" />
|
||||
<Stack.Screen name="(tabs)" />
|
||||
{/* +not-found handles unmatched routes such as iOS "Open In…" file paths */}
|
||||
<Stack.Screen name="+not-found" />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -59,9 +174,13 @@ function AuthGuard() {
|
||||
export default function RootLayout() {
|
||||
return (
|
||||
<SafeAreaProvider>
|
||||
<AuthProvider>
|
||||
<AuthGuard />
|
||||
</AuthProvider>
|
||||
<LocaleProvider>
|
||||
<ShareProvider>
|
||||
<AuthProvider>
|
||||
<AuthGuard />
|
||||
</AuthProvider>
|
||||
</ShareProvider>
|
||||
</LocaleProvider>
|
||||
</SafeAreaProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Root index route – redirects to the auth flow on launch.
|
||||
*
|
||||
* expo-router renders this when the "/" route is matched (i.e. on cold start).
|
||||
* Without this file, a stale default scaffold page ("Hello World") can appear
|
||||
* if one was left behind by a previous build or Expo CLI scaffolding.
|
||||
*
|
||||
* The redirect targets the (auth) group; the AuthGuard in _layout.tsx will
|
||||
* immediately forward authenticated users to (tabs).
|
||||
*/
|
||||
|
||||
import { Redirect } from "expo-router";
|
||||
import React from "react";
|
||||
|
||||
export default function RootIndex() {
|
||||
return <Redirect href="/(auth)/" />;
|
||||
}
|
||||
Reference in New Issue
Block a user