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;
+};