Skip to content

Commit 3aed7a3

Browse files
Merge pull request #6 from Inkubator-IT/feat/obj-storage
Add obj storage APIs
2 parents d97a2e7 + a6276b7 commit 3aed7a3

File tree

10 files changed

+142
-1
lines changed

10 files changed

+142
-1
lines changed

.env.example

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,4 +10,11 @@
1010

1111
APP_PORT=9000
1212
NODE_ENV=development
13-
DATABASE_URL=postgresql://myuser:mypassword@localhost:5433/mydb
13+
DATABASE_URL=postgresql://myuser:mypassword@localhost:5433/mydb
14+
15+
# S3 Storage Configuration
16+
S3_ACCESS_KEY_ID=your_access_key_id
17+
S3_SECRET_ACCESS_KEY=your_secret_access_key
18+
S3_BUCKET=your_bucket_name
19+
S3_ENDPOINT=https://s3.amazonaws.com
20+
S3_REGION=us-east-1

bun.lock

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
"drizzle-orm": "^0.44.7",
99
"hono": "^4.9.6",
1010
"postgres": "^3.4.4",
11+
"slugify": "^1.6.6",
1112
"zod": "^3.23.8",
1213
},
1314
"devDependencies": {
@@ -126,6 +127,8 @@
126127

127128
"resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="],
128129

130+
"slugify": ["slugify@1.6.6", "", {}, "sha512-h+z7HKHYXj6wJU+AnS/+IH8Uh9fdcX1Lrhg1/VMdf9PwoBQXFcXiAdsy2tSK0P6gKwJLXp02r90ahUCqHk9rrw=="],
131+
129132
"source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
130133

131134
"source-map-support": ["source-map-support@0.5.21", "", { "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" } }, "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w=="],

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
"drizzle-orm": "^0.44.7",
1717
"hono": "^4.9.6",
1818
"postgres": "^3.4.4",
19+
"slugify": "^1.6.6",
1920
"zod": "^3.23.8"
2021
},
2122
"devDependencies": {

src/configs/env.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { z } from "zod";
2+
3+
const envSchema = z.object({
4+
APP_PORT: z.string().default("9000"),
5+
NODE_ENV: z.string().default("development"),
6+
DATABASE_URL: z.string(),
7+
S3_ACCESS_KEY_ID: z.string(),
8+
S3_SECRET_ACCESS_KEY: z.string(),
9+
S3_BUCKET: z.string(),
10+
S3_ENDPOINT: z.string(),
11+
S3_REGION: z.string(),
12+
});
13+
14+
export const env = envSchema.parse(Bun.env);

src/configs/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export { env } from "./env";
2+
export { default as s3 } from "./s3";

src/configs/s3.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { S3Client } from "bun";
2+
import { env } from "./env";
3+
4+
const s3 = new S3Client({
5+
accessKeyId: env.S3_ACCESS_KEY_ID,
6+
secretAccessKey: env.S3_SECRET_ACCESS_KEY,
7+
bucket: env.S3_BUCKET,
8+
endpoint: env.S3_ENDPOINT,
9+
region: env.S3_REGION,
10+
});
11+
12+
export default s3;

src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
projectsRoutes,
1010
blogsRoutes,
1111
clientInformationRoutes,
12+
storageRoutes,
1213
} from "./routes";
1314

1415
const app = new Hono();
@@ -54,6 +55,7 @@ app.route("/api/services", servicesRoutes);
5455
app.route("/api/projects", projectsRoutes);
5556
app.route("/api/blogs", blogsRoutes);
5657
app.route("/api/client-information", clientInformationRoutes);
58+
app.route("/api/storage", storageRoutes);
5759

5860
// Initialize database on startup
5961
async function initializeDatabase() {

src/routes/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,4 @@ export { servicesRoutes } from "./services.routes";
44
export { projectsRoutes } from "./projects.routes";
55
export { blogsRoutes } from "./blogs.routes";
66
export { clientInformationRoutes } from "./client-information.routes";
7+
export { storageRoutes } from "./storage.routes";

src/routes/storage.routes.ts

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import { Hono } from "hono";
2+
import { s3 } from "../configs";
3+
import {
4+
storagePresignBodyValidator,
5+
storageReadBodyValidator,
6+
} from "../validators/storage.validator";
7+
import slugify from "slugify";
8+
9+
const ALLOWED_TYPES = [
10+
"image/jpeg",
11+
"image/png",
12+
"image/webp",
13+
"image/heic",
14+
"image/heif",
15+
];
16+
17+
export const storageRoutes = new Hono();
18+
19+
// POST /api/storage - Get presigned URL for upload
20+
storageRoutes.post("/", storagePresignBodyValidator, async (c) => {
21+
const { fileName } = await c.req.json();
22+
23+
const ext = fileName.substring(fileName.lastIndexOf("."));
24+
const nameWithoutExt = fileName.substring(0, fileName.lastIndexOf("."));
25+
26+
const extToType: Record<string, string> = {
27+
".jpg": "image/jpeg",
28+
".jpeg": "image/jpeg",
29+
".png": "image/png",
30+
".webp": "image/webp",
31+
".heic": "image/heic",
32+
".heif": "image/heif",
33+
};
34+
35+
const contentType = extToType[ext] ?? "";
36+
37+
if (!ALLOWED_TYPES.includes(contentType)) {
38+
return c.json({ error: "Invalid file type" }, 400);
39+
}
40+
41+
const sanitizedFileName = slugify(nameWithoutExt, {
42+
lower: true,
43+
strict: true,
44+
trim: true,
45+
}).substring(0, 100);
46+
47+
const uuid = crypto.randomUUID();
48+
const key = `uploads/${sanitizedFileName}-${uuid}${ext}`;
49+
50+
const uploadUrl = s3.presign(key, {
51+
expiresIn: 60 * 15, // 15 minutes
52+
method: "PUT",
53+
type: contentType,
54+
});
55+
56+
return c.json({ url: uploadUrl, key, contentType });
57+
});
58+
59+
// GET /api/storage - Get presigned URL for reading
60+
storageRoutes.get("/", storageReadBodyValidator, async (c) => {
61+
const { key } = c.req.query();
62+
63+
const readUrl = s3.presign(key, {
64+
method: "GET",
65+
expiresIn: 60 * 60, // 1 hour
66+
});
67+
68+
return c.json({ url: readUrl });
69+
});
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { z } from "zod";
2+
import type { Context, Next } from "hono";
3+
4+
const storagePresignBodySchema = z.object({
5+
fileName: z.string().min(1, "File name is required"),
6+
});
7+
8+
const storageReadBodySchema = z.object({
9+
key: z.string().min(1, "Key is required"),
10+
});
11+
12+
export const storagePresignBodyValidator = async (c: Context, next: Next) => {
13+
try {
14+
const body = await c.req.json();
15+
storagePresignBodySchema.parse(body);
16+
await next();
17+
} catch (error) {
18+
return c.json({ error: "Invalid request body" }, 400);
19+
}
20+
};
21+
22+
export const storageReadBodyValidator = async (c: Context, next: Next) => {
23+
try {
24+
const query = c.req.query();
25+
storageReadBodySchema.parse(query);
26+
await next();
27+
} catch (error) {
28+
return c.json({ error: "Invalid query parameters" }, 400);
29+
}
30+
};

0 commit comments

Comments
 (0)