feature: v1

This commit is contained in:
Alexander Nicholson 4584443+DragonStuff@users.noreply.github.com
2024-11-19 00:48:26 +09:00
parent 6b1e6c945f
commit 6d8acb2752
17 changed files with 794 additions and 2 deletions

13
src/main.ts Normal file
View File

@@ -0,0 +1,13 @@
import { Hono } from 'hono'
import { routes } from "./routes/mod.ts";
import { serveStatic } from 'hono/middleware.ts'
const app = new Hono();
// Serve static files
app.use("/static/*", serveStatic({ root: "./" }));
// Mount all routes
app.route("/", routes);
// Start server
Deno.serve({ port: 3000 }, app.fetch);

83
src/routes/api.ts Normal file
View File

@@ -0,0 +1,83 @@
import { Hono } from "hono";
import { StorageService } from "../services/storage.ts";
import { SearchService } from "../services/search.ts";
import { objectList } from "../templates/components/object_list.ts";
const apiRoutes = new Hono();
const storageService = new StorageService(
Deno.env.get("S3_BUCKET") || "",
Deno.env.get("S3_REGION") || "",
Deno.env.get("AWS_ACCESS_KEY_ID") || "",
Deno.env.get("AWS_SECRET_ACCESS_KEY") || "",
);
const searchService = new SearchService(storageService);
apiRoutes.get("/search", async (c) => {
const query = c.req.query("q") || "";
const prefix = c.req.query("prefix") || "";
const objects = await searchService.search({
query,
prefix,
maxKeys: 1000,
});
return c.html(objectList({ objects, prefixes: [], truncated: false }));
});
apiRoutes.get("/download/:key{.*}", async (c) => {
try {
const key = c.req.param("key");
if (!key) {
throw new Error("No key provided for download");
}
console.log("Attempting to download:", {
bucket: Deno.env.get("S3_BUCKET"),
region: Deno.env.get("S3_REGION"),
key,
});
const url = await storageService.getSignedUrl(key);
if (!url) {
throw new Error(`Failed to generate signed URL for key: ${key}`);
}
return c.redirect(url);
} catch (error) {
console.error("Download error details:", {
error: error instanceof Error ? {
name: error.name,
message: error.message,
stack: error.stack,
} : String(error),
key: c.req.param("key"),
bucket: Deno.env.get("S3_BUCKET"),
region: Deno.env.get("S3_REGION"),
});
// Return a user-friendly error page with more details
return c.html(`
<div class="error">
<h2>Download Failed</h2>
<p>Failed to download file: ${error instanceof Error ? error.message : String(error)}</p>
<div class="error-details">
<p><strong>File:</strong> ${c.req.param("key")}</p>
<p><strong>Bucket:</strong> ${Deno.env.get("S3_BUCKET")}</p>
<p><strong>Region:</strong> ${Deno.env.get("S3_REGION")}</p>
${error instanceof Error && error.stack ? `
<details>
<summary>Technical Details</summary>
<pre>${error.stack}</pre>
</details>
` : ''}
</div>
<a href="javascript:history.back()" class="button">Go Back</a>
</div>
`, 404);
}
});
export { apiRoutes };

10
src/routes/mod.ts Normal file
View File

@@ -0,0 +1,10 @@
import { Hono } from "hono";
import { apiRoutes } from "./api.ts";
import { viewRoutes } from "./views.ts";
const routes = new Hono();
routes.route("/api", apiRoutes);
routes.route("/", viewRoutes);
export { routes };

47
src/routes/views.ts Normal file
View File

@@ -0,0 +1,47 @@
import { Hono } from "hono";
import { StorageService } from "../services/storage.ts";
import { browser, renderBreadcrumbs } from "../templates/browser.ts";
import { layout } from "../templates/layout.ts";
import { objectList } from "../templates/components/object_list.ts";
import { pagination } from "../templates/components/pagination.ts";
const viewRoutes = new Hono();
const storageService = new StorageService(
Deno.env.get("S3_BUCKET") || "",
Deno.env.get("S3_REGION") || "",
Deno.env.get("AWS_ACCESS_KEY_ID") || "",
Deno.env.get("AWS_SECRET_ACCESS_KEY") || "",
);
viewRoutes.get("/", async (c) => {
const prefix = c.req.query("prefix") || "";
const continuationToken = c.req.query("continuation");
const query = c.req.query("q") || "";
const result = await storageService.listObjects({
prefix,
delimiter: "/",
maxKeys: 1000,
continuationToken,
});
// If it's an HTMX request, return both navigation and content
if (c.req.header("HX-Request")) {
return c.html(`
<div id="browser-navigation">
${renderBreadcrumbs(prefix)}
</div>
<div id="browser-content">
${objectList(result)}
${pagination(result)}
</div>
`);
}
// Otherwise return the full layout
const content = browser(result, prefix, query);
return c.html(layout(content));
});
export { viewRoutes };

19
src/services/search.ts Normal file
View File

@@ -0,0 +1,19 @@
import { SearchOptions, StorageObject } from "../types/mod.ts";
import { StorageService } from "./storage.ts";
export class SearchService {
constructor(private storageService: StorageService) {}
async search(options: SearchOptions): Promise<StorageObject[]> {
const listResult = await this.storageService.listObjects({
prefix: options.prefix,
maxKeys: options.maxKeys,
continuationToken: options.continuationToken
});
// Filter objects based on search query
return listResult.objects.filter(obj =>
obj.key.toLowerCase().includes(options.query.toLowerCase())
);
}
}

86
src/services/storage.ts Normal file
View File

@@ -0,0 +1,86 @@
import { ListObjectsOptions, ListObjectsResult, StorageObject } from "../types/mod.ts";
import { S3, type ListObjectsV2Request } from "https://deno.land/x/aws_api@v0.8.1/services/s3/mod.ts";
import { ApiFactory } from "https://deno.land/x/aws_api@v0.8.1/client/mod.ts";
import { getSignedUrl } from "https://deno.land/x/aws_s3_presign@2.2.1/mod.ts";
export class StorageService {
private client: S3;
constructor(
private bucket: string,
private region: string,
private accessKeyId: string,
private secretAccessKey: string,
) {
const factory = new ApiFactory({
region: this.region,
credentials: {
awsAccessKeyId: this.accessKeyId,
awsSecretKey: this.secretAccessKey,
},
});
this.client = new S3(factory);
}
async getSignedUrl(key: string, expiresIn = 3600): Promise<string> {
return await getSignedUrl({
accessKeyId: this.accessKeyId,
secretAccessKey: this.secretAccessKey,
bucket: this.bucket,
key,
region: this.region,
expiresIn,
});
}
async listObjects(options: ListObjectsOptions): Promise<ListObjectsResult> {
try {
const params: ListObjectsV2Request = {
Bucket: this.bucket,
Prefix: options.prefix,
Delimiter: options.delimiter,
MaxKeys: options.maxKeys,
ContinuationToken: options.continuationToken,
};
const response = await this.client.listObjectsV2(params);
const objects: StorageObject[] = response.Contents?.map(obj => ({
key: obj.Key || "",
size: obj.Size || 0,
lastModified: obj.LastModified || new Date(),
etag: (obj.ETag || "").replace(/^"|"$/g, ""),
contentType: undefined, // Content type requires separate HEAD request
})) || [];
// Get content types in parallel for better performance
await Promise.all(objects.map(async (obj) => {
obj.contentType = await this.getContentType(obj.key);
}));
return {
objects,
prefixes: response.CommonPrefixes?.map(p => p.Prefix || "") || [],
truncated: response.IsTruncated || false,
nextContinuationToken: response.NextContinuationToken,
};
} catch (error) {
throw new Error(
`Failed to list objects: ${error instanceof Error ? error.message : String(error)}`
);
}
}
private async getContentType(key: string): Promise<string | undefined> {
try {
const response = await this.client.headObject({
Bucket: this.bucket,
Key: key,
});
return response.ContentType;
} catch {
return undefined;
}
}
}

52
src/templates/browser.ts Normal file
View File

@@ -0,0 +1,52 @@
import { ListObjectsResult } from "../types/mod.ts";
import { objectList } from "./components/object_list.ts";
import { pagination } from "./components/pagination.ts";
import { search } from "./components/search.ts";
export function browser(
result: ListObjectsResult,
prefix = "",
query = "",
) {
return `
<div class="browser">
<h1>Object Browser</h1>
${search(query)}
<div id="browser-navigation">
${renderBreadcrumbs(prefix)}
</div>
<div id="browser-content">
${objectList(result)}
${pagination(result)}
</div>
</div>
`;
}
export function renderBreadcrumbs(prefix: string) {
const parts = prefix.split("/").filter(Boolean);
const links = parts.map((part, i) => {
const path = parts.slice(0, i + 1).join("/");
return `
<a href="/?prefix=${path}"
hx-get="/?prefix=${path}"
hx-target="#browser-navigation, #browser-content"
hx-swap="innerHTML"
hx-push-url="true">${part}</a>
`;
});
return `
<div class="breadcrumbs">
<a href="/"
hx-get="/"
hx-target="#browser-navigation, #browser-content"
hx-swap="innerHTML"
hx-push-url="true">Home</a>
${links.length ? " / " + links.join(" / ") : ""}
</div>
`;
}

View File

@@ -0,0 +1,48 @@
import { ListObjectsResult } from "../../types/mod.ts";
export function objectList(result: ListObjectsResult) {
return `
<div class="object-list">
${result.prefixes.map(prefix => `
<div class="folder">
<a href="/?prefix=${prefix}"
hx-get="/?prefix=${prefix}"
hx-target="#browser-navigation, #browser-content"
hx-swap="innerHTML"
hx-push-url="true">
📁 ${prefix}
</a>
</div>
`).join("")}
${result.objects.map(obj => `
<div class="object">
<span class="name">📄 ${obj.key}</span>
<span class="size">${formatSize(obj.size)}</span>
<span class="modified">${formatDate(obj.lastModified)}</span>
<a href="/api/download/${encodeURIComponent(obj.key)}"
class="download">
⬇️ Download
</a>
</div>
`).join("")}
</div>
`;
}
function formatSize(bytes: number): string {
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
let size = bytes;
let unit = 0;
while (size >= 1024 && unit < units.length - 1) {
size /= 1024;
unit++;
}
return `${size.toFixed(1)} ${units[unit]}`;
}
function formatDate(date: Date): string {
return date.toLocaleDateString();
}

View File

@@ -0,0 +1,15 @@
import { ListObjectsResult } from "../../types/mod.ts";
export function pagination(result: ListObjectsResult) {
if (!result.truncated) return '';
return `
<div class="pagination">
<button hx-get="/?continuation=${result.nextContinuationToken}"
hx-target="#browser-content"
hx-swap="innerHTML">
Load More
</button>
</div>
`;
}

View File

@@ -0,0 +1,13 @@
export function search(query = "") {
return `
<div class="search">
<input type="search"
name="q"
placeholder="Search objects..."
value="${query}"
hx-get="/api/search"
hx-trigger="keyup changed delay:500ms"
hx-target="#browser-content">
</div>
`;
}

20
src/templates/layout.ts Normal file
View File

@@ -0,0 +1,20 @@
export function layout(content: string) {
return `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Object Storage Browser</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@1/css/pico.min.css">
<link rel="stylesheet" href="/static/styles.css">
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
</head>
<body>
<main class="container">
${content}
</main>
</body>
</html>
`;
}

25
src/types/mod.ts Normal file
View File

@@ -0,0 +1,25 @@
export interface StorageObject {
key: string;
size: number;
lastModified: Date;
etag: string;
contentType?: string;
}
export interface ListObjectsResult {
objects: StorageObject[];
prefixes: string[];
nextContinuationToken?: string;
truncated: boolean;
}
export interface ListObjectsOptions {
prefix?: string;
delimiter?: string;
maxKeys?: number;
continuationToken?: string;
}
export interface SearchOptions extends ListObjectsOptions {
query: string;
}