From b1216df09ee112a0dfc8f1a1147925ad4ada950f Mon Sep 17 00:00:00 2001 From: Khairul Hidayat <me@khairul.my.id> Date: Sat, 16 Mar 2024 16:03:15 +0700 Subject: [PATCH] feat: add file manager app --- backend/.env.example | 1 + backend/lib/jwt.ts | 37 ++++- backend/package.json | 1 + backend/routes/_routes.ts | 4 +- backend/routes/files.ts | 157 ++++++++++++++++++ backend/yarn.lock | 5 + src/app/_layout.tsx | 6 +- src/app/apps/files/index.tsx | 67 ++++++++ src/app/apps/terminal.tsx | 15 +- src/app/{index => }/index.tsx | 11 +- src/components/pages/files/FileList.tsx | 71 ++++++++ .../pages/home}/Apps.tsx | 7 +- .../pages/home}/Performance.tsx | 0 .../pages/home}/ProcessList.tsx | 0 .../pages/home}/Storage.tsx | 0 .../pages/home}/Summary.tsx | 0 src/components/ui/BackButton.tsx | 32 ++++ src/hooks/useAsyncStorage.ts | 33 ++++ 18 files changed, 434 insertions(+), 13 deletions(-) create mode 100644 backend/routes/files.ts create mode 100644 src/app/apps/files/index.tsx rename src/app/{index => }/index.tsx (76%) create mode 100644 src/components/pages/files/FileList.tsx rename src/{app/index/_sections => components/pages/home}/Apps.tsx (94%) rename src/{app/index/_sections => components/pages/home}/Performance.tsx (100%) rename src/{app/index/_sections => components/pages/home}/ProcessList.tsx (100%) rename src/{app/index/_sections => components/pages/home}/Storage.tsx (100%) rename src/{app/index/_sections => components/pages/home}/Summary.tsx (100%) create mode 100644 src/components/ui/BackButton.tsx create mode 100644 src/hooks/useAsyncStorage.ts diff --git a/backend/.env.example b/backend/.env.example index 495ee33..eb93faa 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -7,3 +7,4 @@ AUTH_PASSWORD= # Apps PC_MAC_ADDR= TERMINAL_SHELL= +FILE_DIRS="/some/path;/another/path" diff --git a/backend/lib/jwt.ts b/backend/lib/jwt.ts index bc1d951..e879d3a 100644 --- a/backend/lib/jwt.ts +++ b/backend/lib/jwt.ts @@ -1,3 +1,5 @@ +import type { Context, Next } from "hono"; +import { HTTPException } from "hono/http-exception"; import * as jwt from "hono/jwt"; const JWT_SECRET = @@ -11,4 +13,37 @@ export const verifyToken = async (token: string) => { return jwt.verify(token, JWT_SECRET); }; -export const authMiddleware = jwt.jwt({ secret: JWT_SECRET }); +export const authMiddleware = async (ctx: Context, next: Next) => { + const authHeader = ctx.req.raw.headers.get("Authorization"); + const queryToken = ctx.req.query("token"); + let token; + + if (authHeader) { + const parts = authHeader.split(/\s+/); + if (parts.length !== 2) { + throw new HTTPException(401, { message: "Unauthorized!" }); + } else { + token = parts[1]; + } + } + + if (queryToken) { + token = queryToken; + } + + if (!token) { + throw new HTTPException(401, { message: "Unauthorized!" }); + } + + let payload; + try { + payload = await jwt.verify(token, JWT_SECRET); + } catch (e) {} + + if (!payload) { + throw new HTTPException(401, { message: "Unauthorized!" }); + } + + ctx.set("jwtPayload", payload); + await next(); +}; diff --git a/backend/package.json b/backend/package.json index 117baf7..313d043 100644 --- a/backend/package.json +++ b/backend/package.json @@ -20,6 +20,7 @@ "@hono/zod-validator": "^0.2.0", "dotenv": "^16.4.5", "hono": "^4.1.0", + "mime": "^4.0.1", "nanoid": "^5.0.6", "node-pty": "^1.0.0", "systeminformation": "^5.22.2", diff --git a/backend/routes/_routes.ts b/backend/routes/_routes.ts index 09c4916..c530503 100644 --- a/backend/routes/_routes.ts +++ b/backend/routes/_routes.ts @@ -3,6 +3,7 @@ import auth from "./auth"; import system from "./system"; import _process from "./process"; import apps from "./apps"; +import files from "./files"; import { authMiddleware } from "../lib/jwt"; const routes = new Hono() @@ -10,7 +11,8 @@ const routes = new Hono() .use(authMiddleware) .route("/system", system) .route("/process", _process) - .route("/apps", apps); + .route("/apps", apps) + .route("/files", files); export type AppType = typeof routes; diff --git a/backend/routes/files.ts b/backend/routes/files.ts new file mode 100644 index 0000000..70fe9a1 --- /dev/null +++ b/backend/routes/files.ts @@ -0,0 +1,157 @@ +import { zValidator } from "@hono/zod-validator"; +import { Hono } from "hono"; +import { z } from "zod"; +import fs from "node:fs/promises"; +import { HTTPException } from "hono/http-exception"; +import { ReadStream, createReadStream } from "node:fs"; +import { ReadableStream } from "stream/web"; +import mime from "mime"; + +const getFilesSchema = z + .object({ + path: z.string(), + }) + .partial() + .optional(); + +const filesDirList = process.env.FILE_DIRS + ? process.env.FILE_DIRS.split(";").map((i) => ({ + name: i.split("/").at(-1), + path: i, + })) + : []; + +const route = new Hono() + .get("/", zValidator("query", getFilesSchema), async (c) => { + const input: z.infer<typeof getFilesSchema> = c.req.query(); + const pathname = (input.path || "").split("/"); + const path = pathname.slice(2).join("/"); + const baseName = pathname[1]; + + if (!baseName?.length) { + return c.json( + filesDirList.map((i) => ({ + name: i.name, + path: "/" + i.name, + isDirectory: true, + })) + ); + } + + const baseDir = filesDirList.find((i) => i.name === baseName)?.path; + if (!baseDir) { + return c.json([]); + } + + try { + const cwd = baseDir + "/" + path; + const entities = await fs.readdir(cwd, { withFileTypes: true }); + + const files = entities + .filter((e) => !e.name.startsWith(".")) + .map((e) => ({ + name: e.name, + path: "/" + [baseName, path, e.name].filter(Boolean).join("/"), + isDirectory: e.isDirectory(), + })) + .sort((a, b) => { + if (a.isDirectory && !b.isDirectory) { + return -1; + } else if (!a.isDirectory && b.isDirectory) { + return 1; + } else { + return a.name.localeCompare(b.name); + } + }); + + return c.json(files); + } catch (err) {} + + return c.json([]); + }) + .get( + "/download", + zValidator("query", z.object({ path: z.string().min(1) })), + async (c) => { + const pathname = (c.req.query("path") || "").split("/"); + const path = "/" + pathname.slice(2).join("/"); + const baseName = pathname[1]; + + try { + if (!baseName?.length) { + throw new Error(); + } + + const baseDir = filesDirList.find((i) => i.name === baseName)?.path; + if (!baseDir) { + throw new Error(); + } + + const filepath = baseDir + path; + const stat = await fs.stat(filepath); + const size = stat.size; + + // c.header("Content-Type", "application/octet-stream"); + // c.header("Content-Disposition", `attachment; filename="${path}"`); + + c.header( + "Content-Type", + mime.getType(filepath) || "application/octet-stream" + ); + + if (c.req.method == "HEAD" || c.req.method == "OPTIONS") { + c.header("Content-Length", size.toString()); + c.status(200); + return c.body(null); + } + + const range = c.req.header("range") || ""; + + if (!range) { + c.header("Content-Length", size.toString()); + return c.body(createStreamBody(createReadStream(filepath)), 200); + } + + c.header("Accept-Ranges", "bytes"); + c.header("Date", stat.birthtime.toUTCString()); + + const parts = range.replace(/bytes=/, "").split("-", 2); + const start = parts[0] ? parseInt(parts[0], 10) : 0; + let end = parts[1] ? parseInt(parts[1], 10) : stat.size - 1; + if (size < end - start + 1) { + end = size - 1; + } + + const chunksize = end - start + 1; + const stream = createReadStream(filepath, { start, end }); + + c.header("Content-Length", chunksize.toString()); + c.header("Content-Range", `bytes ${start}-${end}/${stat.size}`); + + return c.body(createStreamBody(stream), 206); + } catch (err) { + console.error(err); + throw new HTTPException(404, { message: "Not Found!" }); + } + } + ); + +const createStreamBody = (stream: ReadStream) => { + const body = new ReadableStream({ + start(controller) { + stream.on("data", (chunk) => { + controller.enqueue(chunk); + }); + stream.on("end", () => { + controller.close(); + }); + }, + + cancel() { + stream.destroy(); + }, + }); + return body; +}; + +export default route; diff --git a/backend/yarn.lock b/backend/yarn.lock index de3ed25..8b5127d 100644 --- a/backend/yarn.lock +++ b/backend/yarn.lock @@ -199,6 +199,11 @@ hono@^4.1.0: resolved "https://registry.yarnpkg.com/hono/-/hono-4.1.0.tgz#62cef81df0dbf731643155e1e5c1b9dffb230dc4" integrity sha512-9no6DCHb4ijB1tWdFXU6JnrnFgzwVZ1cnIcS1BjAFnMcjbtBTOMsQrDrPH3GXbkNEEEkj8kWqcYBy8Qc0bBkJQ== +mime@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/mime/-/mime-4.0.1.tgz#ad7563d1bfe30253ad97dedfae2b1009d01b9470" + integrity sha512-5lZ5tyrIfliMXzFtkYyekWbtRXObT9OWa8IwQ5uxTBDHucNNwniRqo0yInflj+iYi5CBa6qxadGzGarDfuEOxA== + nan@^2.17.0: version "2.19.0" resolved "https://registry.yarnpkg.com/nan/-/nan-2.19.0.tgz#bb58122ad55a6c5bc973303908d5b16cfdd5a8c0" diff --git a/src/app/_layout.tsx b/src/app/_layout.tsx index 8cc3d12..44111bb 100644 --- a/src/app/_layout.tsx +++ b/src/app/_layout.tsx @@ -1,5 +1,5 @@ import React, { useEffect, useState } from "react"; -import { Slot, router, usePathname } from "expo-router"; +import { Slot, Stack, router, usePathname } from "expo-router"; import { QueryClientProvider } from "react-query"; import queryClient from "@/lib/queryClient"; import { View } from "react-native"; @@ -40,7 +40,9 @@ const RootLayout = () => { <QueryClientProvider client={queryClient}> <StatusBar style="auto" /> <View style={cn("flex-1 bg-[#f2f7fb]", { paddingTop: insets.top })}> - <Slot /> + <Stack + screenOptions={{ contentStyle: { backgroundColor: "#f2f7fb" } }} + /> </View> <Toast ref={(ref) => { diff --git a/src/app/apps/files/index.tsx b/src/app/apps/files/index.tsx new file mode 100644 index 0000000..2258f72 --- /dev/null +++ b/src/app/apps/files/index.tsx @@ -0,0 +1,67 @@ +import FileList, { FileItem } from "@/components/pages/files/FileList"; +import { useAsyncStorage } from "@/hooks/useAsyncStorage"; +import api from "@/lib/api"; +import authStore, { useAuth } from "@/stores/authStore"; +import BackButton from "@ui/BackButton"; +import Box from "@ui/Box"; +import Input from "@ui/Input"; +import { Stack } from "expo-router"; +import React from "react"; +import { useQuery } from "react-query"; + +const FilesPage = () => { + const { isLoggedIn } = useAuth(); + const [params, setParams] = useAsyncStorage("files", { + path: "", + }); + const parentPath = + params.path.length > 0 + ? params.path.split("/").slice(0, -1).join("/") + : null; + + const { data } = useQuery({ + queryKey: ["app/files", params], + queryFn: () => api.files.$get({ query: params }).then((r) => r.json()), + enabled: isLoggedIn, + }); + + return ( + <> + <Stack.Screen + options={{ headerLeft: () => <BackButton />, title: "Files" }} + /> + + <Box className="px-2 py-2 bg-white"> + <Input + placeholder="/" + value={params.path} + onChangeText={(path) => setParams({ path })} + /> + </Box> + + <FileList + files={data} + onSelect={(file) => { + if (file.path === "..") { + return setParams({ ...params, path: parentPath }); + } + if (file.isDirectory) { + return setParams({ ...params, path: file.path }); + } + + downloadFile(file); + }} + canGoBack={parentPath != null} + /> + </> + ); +}; + +async function downloadFile(file: FileItem) { + const url = api.files.download.$url(); + url.searchParams.set("path", file.path); + url.searchParams.set("token", authStore.getState().token); + window.open(url.toString(), "_blank"); +} + +export default FilesPage; diff --git a/src/app/apps/terminal.tsx b/src/app/apps/terminal.tsx index dd0419f..7ce5c9f 100644 --- a/src/app/apps/terminal.tsx +++ b/src/app/apps/terminal.tsx @@ -9,6 +9,8 @@ import React, { useEffect, useRef } from "react"; import "xterm/css/xterm.css"; import { API_BASEURL } from "@/lib/constants"; import authStore, { useAuth } from "@/stores/authStore"; +import { Stack } from "expo-router"; +import BackButton from "@ui/BackButton"; const isWeb = Platform.OS === "web"; @@ -83,10 +85,15 @@ const TerminalPage = () => { } return ( - <div - ref={terminalRef} - style={{ height: "100vh", background: "#1d1e2b", padding: 16 }} - /> + <> + <Stack.Screen + options={{ title: "Terminal", headerLeft: () => <BackButton /> }} + /> + <div + ref={terminalRef} + style={{ height: "100vh", background: "#1d1e2b", padding: 16 }} + /> + </> ); }; diff --git a/src/app/index/index.tsx b/src/app/index.tsx similarity index 76% rename from src/app/index/index.tsx rename to src/app/index.tsx index 37fb8c0..7a93f22 100644 --- a/src/app/index/index.tsx +++ b/src/app/index.tsx @@ -2,14 +2,15 @@ import React from "react"; import api from "@/lib/api"; import { useQuery } from "react-query"; import Text from "@ui/Text"; -import Performance from "./_sections/Performance"; -import Summary from "./_sections/Summary"; -import Storage from "./_sections/Storage"; +import Performance from "../components/pages/home/Performance"; +import Summary from "../components/pages/home/Summary"; +import Storage from "../components/pages/home/Storage"; import Container from "@ui/Container"; import { useAuth } from "@/stores/authStore"; import { HStack } from "@ui/Stack"; import Box from "@ui/Box"; -import Apps from "./_sections/Apps"; +import Apps from "../components/pages/home/Apps"; +import { Stack } from "expo-router"; const HomePage = () => { const { isLoggedIn } = useAuth(); @@ -26,6 +27,8 @@ const HomePage = () => { return ( <Container scrollable className="px-4 md:px-8 max-w-none py-8"> + <Stack.Screen options={{ headerShown: false, title: "Home Lab" }} /> + <HStack className="items-start gap-8"> <Box className="flex-1 md:max-w-lg"> <Text className="text-2xl font-medium">Home Lab</Text> diff --git a/src/components/pages/files/FileList.tsx b/src/components/pages/files/FileList.tsx new file mode 100644 index 0000000..b1aca6c --- /dev/null +++ b/src/components/pages/files/FileList.tsx @@ -0,0 +1,71 @@ +import { FlatList, Pressable } from "react-native"; +import React, { useMemo } from "react"; +import Text from "@ui/Text"; +import { HStack } from "@ui/Stack"; +import { cn } from "@/lib/utils"; +import { Ionicons } from "@ui/Icons"; + +export type FileItem = { + name: string; + path: string; + isDirectory: boolean; +}; + +type FileListProps = { + files?: FileItem[]; + onSelect?: (file: FileItem) => void; + canGoBack?: boolean; +}; + +const FileList = ({ files, onSelect, canGoBack }: FileListProps) => { + const fileList = useMemo(() => { + if (canGoBack) { + return [{ name: "..", path: "..", isDirectory: true }, ...(files || [])]; + } + return files || []; + }, [files, canGoBack]); + + return ( + <FlatList + contentContainerStyle={cn("bg-white")} + data={fileList || []} + renderItem={({ item }) => ( + <FileItem file={item} onPress={() => onSelect?.(item)} /> + )} + keyExtractor={(item) => item.path} + /> + ); +}; + +const FileItem = ({ + file, + onPress, +}: { + file: FileItem; + onPress?: () => void; +}) => { + return ( + <HStack className="bg-white border-b border-gray-200 items-center"> + <Pressable + style={({ pressed }) => + cn( + "flex-1 px-4 py-3 flex flex-row gap-4 items-center", + pressed && "bg-gray-100" + ) + } + onPress={onPress} + > + <Ionicons + name={file.isDirectory ? "folder" : "document"} + style={cn( + "text-2xl", + file.isDirectory ? "text-blue-400" : "text-gray-500" + )} + /> + <Text numberOfLines={1}>{file.name}</Text> + </Pressable> + </HStack> + ); +}; + +export default FileList; diff --git a/src/app/index/_sections/Apps.tsx b/src/components/pages/home/Apps.tsx similarity index 94% rename from src/app/index/_sections/Apps.tsx rename to src/components/pages/home/Apps.tsx index 38ead77..4af9e72 100644 --- a/src/app/index/_sections/Apps.tsx +++ b/src/components/pages/home/Apps.tsx @@ -5,8 +5,8 @@ import { Ionicons } from "@ui/Icons"; import { HStack } from "@ui/Stack"; import Button from "@ui/Button"; import { useNavigation } from "expo-router"; -import { wakePcUp } from "@/app/apps/lib"; import { showDialog } from "@/stores/dialogStore"; +import { wakePcUp } from "@/app/apps/lib"; type Props = ComponentProps<typeof Box>; @@ -14,6 +14,11 @@ const Apps = (props: Props) => { const navigation = useNavigation(); const appList = [ + { + name: "Files", + icon: <Ionicons name="folder" />, + path: "files/index", + }, { name: "Terminal", icon: <Ionicons name="terminal" />, diff --git a/src/app/index/_sections/Performance.tsx b/src/components/pages/home/Performance.tsx similarity index 100% rename from src/app/index/_sections/Performance.tsx rename to src/components/pages/home/Performance.tsx diff --git a/src/app/index/_sections/ProcessList.tsx b/src/components/pages/home/ProcessList.tsx similarity index 100% rename from src/app/index/_sections/ProcessList.tsx rename to src/components/pages/home/ProcessList.tsx diff --git a/src/app/index/_sections/Storage.tsx b/src/components/pages/home/Storage.tsx similarity index 100% rename from src/app/index/_sections/Storage.tsx rename to src/components/pages/home/Storage.tsx diff --git a/src/app/index/_sections/Summary.tsx b/src/components/pages/home/Summary.tsx similarity index 100% rename from src/app/index/_sections/Summary.tsx rename to src/components/pages/home/Summary.tsx diff --git a/src/components/ui/BackButton.tsx b/src/components/ui/BackButton.tsx new file mode 100644 index 0000000..6f9db86 --- /dev/null +++ b/src/components/ui/BackButton.tsx @@ -0,0 +1,32 @@ +import { useNavigation } from "expo-router"; +import React from "react"; +import { Ionicons } from "./Icons"; +import Button from "./Button"; + +type BackButtonProps = { + prev?: string; +}; + +const BackButton = ({ prev }: BackButtonProps) => { + const navigation = useNavigation(); + + const onPress = () => { + if (navigation.canGoBack()) { + navigation.goBack(); + } else { + navigation.navigate((prev || "index") as never); + } + }; + + return ( + <Button + icon={<Ionicons name="arrow-back" />} + variant="ghost" + className="h-14 w-14" + iconClassName="text-black text-2xl" + onPress={onPress} + /> + ); +}; + +export default BackButton; diff --git a/src/hooks/useAsyncStorage.ts b/src/hooks/useAsyncStorage.ts new file mode 100644 index 0000000..e59829c --- /dev/null +++ b/src/hooks/useAsyncStorage.ts @@ -0,0 +1,33 @@ +import { useEffect, useState } from "react"; +import AsyncStorage from "@react-native-async-storage/async-storage"; + +export const useAsyncStorage = <T = any>(key: string, defaultValue: T) => { + const [value, setValue] = useState<T>(defaultValue); + + useEffect(() => { + const getData = async () => { + try { + const jsonValue = await AsyncStorage.getItem(key); + return jsonValue != null ? JSON.parse(jsonValue) : defaultValue; + } catch (e) { + console.warn(e); + return defaultValue; + } + }; + + const init = async () => setValue(await getData()); + init(); + }, [key]); + + const setValueToAsyncStorage = async (newValue: T) => { + try { + const jsonValue = JSON.stringify(newValue); + await AsyncStorage.setItem(key, jsonValue); + setValue(newValue); + } catch (e) { + console.warn(e); + } + }; + + return [value, setValueToAsyncStorage] as const; +};