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 = 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 = () => { - + { 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 ( + <> + , title: "Files" }} + /> + + + setParams({ path })} + /> + + + { + 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 ( -
+ <> + }} + /> +
+ ); }; 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 ( + + Home Lab 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 ( + ( + onSelect?.(item)} /> + )} + keyExtractor={(item) => item.path} + /> + ); +}; + +const FileItem = ({ + file, + onPress, +}: { + file: FileItem; + onPress?: () => void; +}) => { + return ( + + + cn( + "flex-1 px-4 py-3 flex flex-row gap-4 items-center", + pressed && "bg-gray-100" + ) + } + onPress={onPress} + > + + {file.name} + + + ); +}; + +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; @@ -14,6 +14,11 @@ const Apps = (props: Props) => { const navigation = useNavigation(); const appList = [ + { + name: "Files", + icon: , + path: "files/index", + }, { name: "Terminal", icon: , 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 ( +