mirror of
https://github.com/alexandernicholson/s3panoramic.git
synced 2026-05-07 15:19:53 +09:00
feature: v1
This commit is contained in:
parent
6b1e6c945f
commit
6d8acb2752
13
src/main.ts
Normal file
13
src/main.ts
Normal 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
83
src/routes/api.ts
Normal 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
10
src/routes/mod.ts
Normal 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
47
src/routes/views.ts
Normal 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
19
src/services/search.ts
Normal 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
86
src/services/storage.ts
Normal 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
52
src/templates/browser.ts
Normal 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>
|
||||
`;
|
||||
}
|
||||
48
src/templates/components/object_list.ts
Normal file
48
src/templates/components/object_list.ts
Normal 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();
|
||||
}
|
||||
15
src/templates/components/pagination.ts
Normal file
15
src/templates/components/pagination.ts
Normal 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>
|
||||
`;
|
||||
}
|
||||
13
src/templates/components/search.ts
Normal file
13
src/templates/components/search.ts
Normal 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
20
src/templates/layout.ts
Normal 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
25
src/types/mod.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user