feat(upload): enhance upload functionality with chunk management and cancellation support
- Updated Upload.vue to include cancelItem functionality in the upload queue. - Modified UploadQueue.vue to emit cancel events for individual items. - Enhanced UploadQueueItem.vue to display cancel button for ongoing uploads. - Added merge.ts for handling manifest creation and S3 operations for chunk uploads. - Introduced temp.html for testing multi-threaded chunk uploads with progress tracking. - Created AGENTS.md for comprehensive project documentation and guidelines.
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { tryGetContext } from "hono/context-storage";
|
||||
|
||||
export const baseAPIURL = "https://api.pipic.fun";
|
||||
export const customFetch = (url: string, options: RequestInit) => {
|
||||
options.credentials = "include";
|
||||
const c = tryGetContext<any>();
|
||||
@@ -21,7 +21,7 @@ export const customFetch = (url: string, options: RequestInit) => {
|
||||
...(options.headers as Record<string, string>),
|
||||
};
|
||||
|
||||
const apiUrl = ["https://api.pipic.fun", url.replace(/^r/, "")].join("");
|
||||
const apiUrl = [baseAPIURL, url.replace(/^r/, "")].join("");
|
||||
return fetch(apiUrl, options).then(async (res) => {
|
||||
res.headers.getSetCookie()?.forEach((cookie) => {
|
||||
c.header("Set-Cookie", cookie);
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
import { getContext } from "hono/context-storage";
|
||||
import { HonoVarTypes } from "types";
|
||||
|
||||
// We can keep checkAuth to return the current user profile from the context
|
||||
// which is populated by the firebaseAuthMiddleware
|
||||
async function checkAuth() {
|
||||
const context = getContext<HonoVarTypes>();
|
||||
const user = context.get('user');
|
||||
|
||||
if (!user) {
|
||||
return { authenticated: false, user: null };
|
||||
}
|
||||
|
||||
return {
|
||||
authenticated: true,
|
||||
user: user
|
||||
};
|
||||
}
|
||||
|
||||
export const authMethods = {
|
||||
checkAuth,
|
||||
};
|
||||
@@ -1 +0,0 @@
|
||||
export const secret = "123_it-is-very-secret_123";
|
||||
@@ -1,344 +0,0 @@
|
||||
import {
|
||||
exposeTinyRpc,
|
||||
httpServerAdapter,
|
||||
validateFn,
|
||||
} from "@hiogawa/tiny-rpc";
|
||||
import { tinyassert } from "@hiogawa/utils";
|
||||
import { MiddlewareHandler, type Context, type Next } from "hono";
|
||||
import { getContext } from "hono/context-storage";
|
||||
// import { adminAuth } from "../../lib/firebaseAdmin";
|
||||
import { z } from "zod";
|
||||
import { authMethods } from "./auth";
|
||||
import { abortChunk, chunkedUpload, completeChunk, createPresignedUrls, imageContentTypes, nanoid, presignedPut, videoContentTypes } from "./s3_handle";
|
||||
// import { createElement } from "react";
|
||||
|
||||
let counter = 0;
|
||||
const listCourses = [
|
||||
{
|
||||
id: 1,
|
||||
title: "Lập trình Web Fullstack",
|
||||
description:
|
||||
"Học cách xây dựng ứng dụng web hoàn chỉnh từ frontend đến backend. Khóa học bao gồm HTML, CSS, JavaScript, React, Node.js và MongoDB.",
|
||||
category: "Lập trình",
|
||||
rating: 4.9,
|
||||
price: "1.200.000 VNĐ",
|
||||
icon: "fas fa-code",
|
||||
bgImg: "https://placehold.co/600x400/EEE/31343C?font=playfair-display&text=Web%20Fullstack",
|
||||
slug: "lap-trinh-web-fullstack",
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: "Phân tích dữ liệu với Python",
|
||||
description:
|
||||
"Khám phá sức mạnh của Python trong việc phân tích và trực quan hóa dữ liệu. Sử dụng Pandas, NumPy, Matplotlib và Seaborn.",
|
||||
category: "Phân tích dữ liệu",
|
||||
rating: 4.8,
|
||||
price: "900.000 VNĐ",
|
||||
icon: "fas fa-chart-bar",
|
||||
bgImg: "https://placehold.co/600x400/EEE/31343C?font=playfair-display&text=Data%20Analysis",
|
||||
slug: "phan-tich-du-lieu-voi-python",
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: "Thiết kế UI/UX chuyên nghiệp",
|
||||
description:
|
||||
"Học các nguyên tắc thiết kế giao diện và trải nghiệm người dùng hiện đại. Sử dụng Figma và Adobe XD.",
|
||||
category: "Thiết kế",
|
||||
rating: 4.7,
|
||||
price: "800.000 VNĐ",
|
||||
icon: "fas fa-paint-brush",
|
||||
bgImg: "https://placehold.co/600x400/EEE/31343C?font=playfair-display&text=UI/UX%20Design",
|
||||
slug: "thiet-ke-ui-ux-chuyen-nghiep",
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
title: "Machine Learning cơ bản",
|
||||
description:
|
||||
"Nhập môn Machine Learning với Python. Tìm hiểu về các thuật toán học máy cơ bản như Linear Regression, Logistic Regression, Decision Trees.",
|
||||
category: "AI/ML",
|
||||
rating: 4.6,
|
||||
price: "1.500.000 VNĐ",
|
||||
icon: "fas fa-brain",
|
||||
bgImg: "https://placehold.co/600x400/EEE/31343C?font=playfair-display&text=Machine%20Learning",
|
||||
slug: "machine-learning-co-ban",
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
title: "Digital Marketing toàn diện",
|
||||
description:
|
||||
"Chiến lược Marketing trên các nền tảng số. SEO, Google Ads, Facebook Ads và Content Marketing.",
|
||||
category: "Marketing",
|
||||
rating: 4.5,
|
||||
price: "700.000 VNĐ",
|
||||
icon: "fas fa-bullhorn",
|
||||
bgImg: "https://placehold.co/600x400/EEE/31343C?font=playfair-display&text=Digital%20Marketing",
|
||||
slug: "digital-marketing-toan-dien",
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
title: "Lập trình Mobile với Flutter",
|
||||
description:
|
||||
"Xây dựng ứng dụng di động đa nền tảng (iOS & Android) với Flutter và Dart.",
|
||||
category: "Lập trình",
|
||||
rating: 4.8,
|
||||
price: "1.100.000 VNĐ",
|
||||
icon: "fas fa-mobile-alt",
|
||||
bgImg: "https://placehold.co/600x400/EEE/31343C?font=playfair-display&text=Flutter%20Mobile",
|
||||
slug: "lap-trinh-mobile-voi-flutter",
|
||||
},
|
||||
{
|
||||
id: 7,
|
||||
title: "Tiếng Anh giao tiếp công sở",
|
||||
description:
|
||||
"Cải thiện kỹ năng giao tiếp tiếng Anh trong môi trường làm việc chuyên nghiệp.",
|
||||
category: "Ngoại ngữ",
|
||||
rating: 4.4,
|
||||
price: "600.000 VNĐ",
|
||||
icon: "fas fa-language",
|
||||
bgImg: "https://placehold.co/600x400/EEE/31343C?font=playfair-display&text=Business%20English",
|
||||
slug: "tieng-anh-giao-tiep-cong-so",
|
||||
},
|
||||
{
|
||||
id: 8,
|
||||
title: "Quản trị dự án Agile/Scrum",
|
||||
description:
|
||||
"Phương pháp quản lý dự án linh hoạt Agile và khung làm việc Scrum.",
|
||||
category: "Kỹ năng mềm",
|
||||
rating: 4.7,
|
||||
price: "950.000 VNĐ",
|
||||
icon: "fas fa-tasks",
|
||||
bgImg: "https://placehold.co/600x400/EEE/31343C?font=playfair-display&text=Agile%20Scrum",
|
||||
slug: "quan-tri-du-an-agile-scrum",
|
||||
},
|
||||
{
|
||||
id: 9,
|
||||
title: "Nhiếp ảnh cơ bản",
|
||||
description:
|
||||
"Làm chủ máy ảnh và nghệ thuật nhiếp ảnh. Bố cục, ánh sáng và chỉnh sửa ảnh.",
|
||||
category: "Nghệ thuật",
|
||||
rating: 4.9,
|
||||
price: "500.000 VNĐ",
|
||||
icon: "fas fa-camera",
|
||||
bgImg: "https://placehold.co/600x400/EEE/31343C?font=playfair-display&text=Photography",
|
||||
slug: "nhiep-anh-co-ban",
|
||||
},
|
||||
{
|
||||
id: 10,
|
||||
title: "Blockchain 101",
|
||||
description:
|
||||
"Hiểu về công nghệ Blockchain, Bitcoin, Ethereum và Smart Contracts.",
|
||||
category: "Công nghệ",
|
||||
rating: 4.6,
|
||||
price: "1.300.000 VNĐ",
|
||||
icon: "fas fa-link",
|
||||
bgImg: "https://placehold.co/600x400/EEE/31343C?font=playfair-display&text=Blockchain",
|
||||
slug: "blockchain-101",
|
||||
},
|
||||
{
|
||||
id: 11,
|
||||
title: "ReactJS Nâng cao",
|
||||
description:
|
||||
"Các kỹ thuật nâng cao trong React: Hooks, Context, Redux, Performance Optimization.",
|
||||
category: "Lập trình",
|
||||
rating: 4.9,
|
||||
price: "1.000.000 VNĐ",
|
||||
icon: "fas fa-code",
|
||||
bgImg: "https://placehold.co/600x400/EEE/31343C?font=playfair-display&text=Advanced%20React",
|
||||
slug: "reactjs-nang-cao",
|
||||
},
|
||||
{
|
||||
id: 12,
|
||||
title: "Viết Content Marketing thu hút",
|
||||
description:
|
||||
"Kỹ thuật viết bài chuẩn SEO, thu hút người đọc và tăng tỷ lệ chuyển đổi.",
|
||||
category: "Marketing",
|
||||
rating: 4.5,
|
||||
price: "550.000 VNĐ",
|
||||
icon: "fas fa-pen-nib",
|
||||
bgImg: "https://placehold.co/600x400/EEE/31343C?font=playfair-display&text=Content%20Marketing",
|
||||
slug: "viet-content-marketing",
|
||||
}
|
||||
];
|
||||
|
||||
const courseContent = [
|
||||
{
|
||||
id: 1,
|
||||
title: "Giới thiệu khóa học",
|
||||
type: "video",
|
||||
duration: "5:00",
|
||||
completed: true,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: "Cài đặt môi trường",
|
||||
type: "video",
|
||||
duration: "15:00",
|
||||
completed: false,
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: "Kiến thức cơ bản",
|
||||
type: "video",
|
||||
duration: "25:00",
|
||||
completed: false,
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
title: "Bài tập thực hành 1",
|
||||
type: "quiz",
|
||||
duration: "10:00",
|
||||
completed: false,
|
||||
},
|
||||
];
|
||||
|
||||
const routes = {
|
||||
// define as a bare function
|
||||
checkId: (id: string) => {
|
||||
const context = getContext();
|
||||
console.log(context.req.raw.headers);
|
||||
return id === "good";
|
||||
},
|
||||
|
||||
checkIdThrow: (id: string) => {
|
||||
tinyassert(id === "good", "Invalid ID");
|
||||
return null;
|
||||
},
|
||||
|
||||
getCounter: () => {
|
||||
const context = getContext();
|
||||
console.log(context.get("jwtPayload"));
|
||||
return counter;
|
||||
},
|
||||
|
||||
// define with zod validation + input type inference
|
||||
incrementCounter: validateFn(z.object({ delta: z.number().default(1) }))(
|
||||
(input) => {
|
||||
// expectTypeOf(input).toEqualTypeOf<{ delta: number }>();
|
||||
counter += input.delta;
|
||||
return counter;
|
||||
}
|
||||
),
|
||||
|
||||
// access context
|
||||
components: async () => { },
|
||||
getHomeCourses: async () => {
|
||||
return listCourses.slice(0, 3);
|
||||
},
|
||||
getCourses: validateFn(
|
||||
z.object({
|
||||
page: z.number().default(1),
|
||||
limit: z.number().default(6),
|
||||
search: z.string().optional(),
|
||||
category: z.string().optional(),
|
||||
})
|
||||
)(async ({ page, limit, search, category }) => {
|
||||
let filtered = listCourses;
|
||||
|
||||
if (search) {
|
||||
const lowerSearch = search.toLowerCase();
|
||||
filtered = filtered.filter(
|
||||
(c) =>
|
||||
c.title.toLowerCase().includes(lowerSearch) ||
|
||||
c.description.toLowerCase().includes(lowerSearch)
|
||||
);
|
||||
}
|
||||
|
||||
if (category && category !== "All") {
|
||||
filtered = filtered.filter((c) => c.category === category);
|
||||
}
|
||||
|
||||
const start = (page - 1) * limit;
|
||||
const end = start + limit;
|
||||
const paginated = filtered.slice(start, end);
|
||||
|
||||
return {
|
||||
data: paginated,
|
||||
total: filtered.length,
|
||||
page,
|
||||
totalPages: Math.ceil(filtered.length / limit),
|
||||
};
|
||||
}),
|
||||
getCourseBySlug: validateFn(z.object({ slug: z.string() }))(async ({ slug }) => {
|
||||
const course = listCourses.find((c) => c.slug === slug);
|
||||
if (!course) {
|
||||
throw new Error("Course not found");
|
||||
}
|
||||
return course;
|
||||
}),
|
||||
getCourseContent: validateFn(z.object({ slug: z.string() }))(async ({ slug }) => {
|
||||
// In a real app, we would fetch content specific to the course
|
||||
return courseContent;
|
||||
}),
|
||||
presignedPut: validateFn(z.object({ fileName: z.string(), contentType: z.string().refine((val) => imageContentTypes.includes(val), { message: "Invalid content type" }) }))(async ({ fileName, contentType }) => {
|
||||
return await presignedPut(fileName, contentType);
|
||||
}),
|
||||
chunkedUpload: validateFn(z.object({ fileName: z.string(), contentType: z.string().refine((val) => videoContentTypes.includes(val), { message: "Invalid content type" }), fileSize: z.number().min(1024 * 10).max(3 * 1024 * 1024 * 1024).default(1024 * 256) }))(async ({ fileName, contentType, fileSize }) => {
|
||||
const key = nanoid() + "_" + fileName;
|
||||
const { UploadId } = await chunkedUpload(key, contentType, fileSize);
|
||||
const chunkSize = 1024 * 1024 * 20; // 20MB
|
||||
const presignedUrls = await createPresignedUrls({
|
||||
key,
|
||||
uploadId: UploadId!,
|
||||
totalParts: Math.ceil(fileSize / chunkSize),
|
||||
});
|
||||
return { uploadId: UploadId!, presignedUrls, chunkSize, key, totalParts: presignedUrls.length };
|
||||
}),
|
||||
completeChunk: validateFn(z.object({ key: z.string(), uploadId: z.string(), parts: z.array(z.object({ PartNumber: z.number(), ETag: z.string() })) }))(async ({ key, uploadId, parts }) => {
|
||||
await completeChunk(key, uploadId, parts);
|
||||
return { success: true };
|
||||
}),
|
||||
abortChunk: validateFn(z.object({ key: z.string(), uploadId: z.string() }))(async ({ key, uploadId }) => {
|
||||
await abortChunk(key, uploadId);
|
||||
return { success: true };
|
||||
}),
|
||||
...authMethods
|
||||
};
|
||||
export type RpcRoutes = typeof routes;
|
||||
export const endpoint = "/rpc";
|
||||
export const pathsForGET: (keyof typeof routes)[] = ["getCounter"];
|
||||
|
||||
export const firebaseAuthMiddleware: MiddlewareHandler = async (c, next) => {
|
||||
const publicPaths: (keyof typeof routes)[] = ["getHomeCourses", "getCourses", "getCourseBySlug", "getCourseContent"];
|
||||
const isPublic = publicPaths.some((path) => c.req.path.split("/").includes(path));
|
||||
c.set("isPublic", isPublic);
|
||||
|
||||
if (c.req.path !== endpoint && !c.req.path.startsWith(endpoint + "/") || isPublic) {
|
||||
return await next();
|
||||
}
|
||||
|
||||
const authHeader = c.req.header("Authorization");
|
||||
if (!authHeader || !authHeader.startsWith("Bearer ")) {
|
||||
// Option: return 401 or let it pass with no user?
|
||||
// Old logic seemed to require it for non-public paths.
|
||||
return c.json({ error: "Unauthorized" }, 401);
|
||||
}
|
||||
|
||||
const token = authHeader.split("Bearer ")[1];
|
||||
try {
|
||||
// const decodedToken = await adminAuth.verifyIdToken(token);
|
||||
// c.set("user", decodedToken);
|
||||
} catch (error) {
|
||||
console.error("Firebase Auth Error:", error);
|
||||
return c.json({ error: "Unauthorized" }, 401);
|
||||
}
|
||||
|
||||
return await next();
|
||||
}
|
||||
|
||||
export const rpcServer = async (c: Context, next: Next) => {
|
||||
if (c.req.path !== endpoint && !c.req.path.startsWith(endpoint + "/")) {
|
||||
return await next();
|
||||
}
|
||||
const cert = c.req.header()
|
||||
console.log("RPC Request Path:", c.req.raw.cf);
|
||||
// if (!cert) return c.text('Forbidden', 403)
|
||||
const handler = exposeTinyRpc({
|
||||
routes,
|
||||
adapter: httpServerAdapter({ endpoint }),
|
||||
});
|
||||
const res = await handler({ request: c.req.raw });
|
||||
if (res) {
|
||||
return res;
|
||||
}
|
||||
return await next();
|
||||
};
|
||||
@@ -1,198 +0,0 @@
|
||||
import {
|
||||
S3Client,
|
||||
ListBucketsCommand,
|
||||
ListObjectsV2Command,
|
||||
GetObjectCommand,
|
||||
PutObjectCommand,
|
||||
DeleteObjectCommand,
|
||||
CreateMultipartUploadCommand,
|
||||
UploadPartCommand,
|
||||
AbortMultipartUploadCommand,
|
||||
CompleteMultipartUploadCommand,
|
||||
ListPartsCommand,
|
||||
} from "@aws-sdk/client-s3";
|
||||
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
|
||||
import { createPresignedPost } from "@aws-sdk/s3-presigned-post";
|
||||
import { randomBytes } from "crypto";
|
||||
const urlAlphabet = 'useandom-26T198340PX75pxJACKVERYMINDBUSHWOLF_GQZbfghjklqvwyzrict';
|
||||
|
||||
export function nanoid(size = 21) {
|
||||
let id = '';
|
||||
const bytes = randomBytes(size); // Node.js specific method
|
||||
|
||||
for (let i = 0; i < size; i++) {
|
||||
id += urlAlphabet[bytes[i] & 63];
|
||||
}
|
||||
|
||||
return id;
|
||||
}
|
||||
// createPresignedPost
|
||||
const S3 = new S3Client({
|
||||
region: "auto", // Required by SDK but not used by R2
|
||||
endpoint: `https://s3.cloudfly.vn`,
|
||||
credentials: {
|
||||
// accessKeyId: "Q3AM3UQ867SPQQA43P2F",
|
||||
// secretAccessKey: "Ik7nlCaUUCFOKDJAeSgFcbF5MEBGh9sVGBUrsUOp",
|
||||
accessKeyId: "BD707P5W8J5DHFPUKYZ6",
|
||||
secretAccessKey: "LTX7IizSDn28XGeQaHNID2fOtagfLc6L2henrP6P",
|
||||
},
|
||||
forcePathStyle: true,
|
||||
});
|
||||
// const S3 = new S3Client({
|
||||
// region: "auto", // Required by SDK but not used by R2
|
||||
// endpoint: `https://u.pipic.fun`,
|
||||
// credentials: {
|
||||
// // accessKeyId: "Q3AM3UQ867SPQQA43P2F",
|
||||
// // secretAccessKey: "Ik7nlCaUUCFOKDJAeSgFcbF5MEBGh9sVGBUrsUOp",
|
||||
// accessKeyId: "cdnadmin",
|
||||
// secretAccessKey: "D@tkhong9",
|
||||
// },
|
||||
// forcePathStyle: true,
|
||||
// });
|
||||
export const imageContentTypes = ["image/png", "image/jpg", "image/jpeg", "image/webp"];
|
||||
export const videoContentTypes = ["video/mp4", "video/webm", "video/ogg", "video/*"];
|
||||
const nanoId = () => {
|
||||
// return crypto.randomUUID().replace(/-/g, "").slice(0, 10);
|
||||
return ""
|
||||
}
|
||||
export async function presignedPut(fileName: string, contentType: string){
|
||||
if (!imageContentTypes.includes(contentType)) {
|
||||
throw new Error("Invalid content type");
|
||||
}
|
||||
const key = nanoId()+"_"+fileName;
|
||||
const url = await getSignedUrl(
|
||||
S3,
|
||||
new PutObjectCommand({
|
||||
Bucket: "tmp",
|
||||
Key: key,
|
||||
ContentType: contentType,
|
||||
CacheControl: "public, max-age=31536000, immutable",
|
||||
// ContentLength: 31457280, // Max 30MB
|
||||
// ACL: "public-read", // Uncomment if you want the object to be publicly readable
|
||||
}),
|
||||
{ expiresIn: 600 } // URL valid for 10 minutes
|
||||
);
|
||||
return { url, key };
|
||||
}
|
||||
export async function createPresignedUrls({
|
||||
key,
|
||||
uploadId,
|
||||
totalParts,
|
||||
expiresIn = 60 * 15, // 15 phút
|
||||
}: {
|
||||
key: string;
|
||||
uploadId: string;
|
||||
totalParts: number;
|
||||
expiresIn?: number;
|
||||
}) {
|
||||
const urls = [];
|
||||
|
||||
for (let partNumber = 1; partNumber <= totalParts; partNumber++) {
|
||||
const command = new UploadPartCommand({
|
||||
Bucket: "tmp",
|
||||
Key: key,
|
||||
UploadId: uploadId,
|
||||
PartNumber: partNumber,
|
||||
});
|
||||
|
||||
const url = await getSignedUrl(S3, command, {
|
||||
expiresIn,
|
||||
});
|
||||
|
||||
urls.push({
|
||||
partNumber,
|
||||
url,
|
||||
});
|
||||
}
|
||||
|
||||
return urls;
|
||||
}
|
||||
export async function chunkedUpload(Key: string, contentType: string, fileSize: number) {
|
||||
// lớn hơn 3gb thì cút
|
||||
if (fileSize > 3 * 1024 * 1024 * 1024) {
|
||||
throw new Error("File size exceeds 3GB");
|
||||
}
|
||||
// CreateMultipartUploadCommand
|
||||
const uploadParams = {
|
||||
Bucket: "tmp",
|
||||
Key,
|
||||
ContentType: contentType,
|
||||
CacheControl: "public, max-age=31536000, immutable",
|
||||
};
|
||||
let data = await S3.send(new CreateMultipartUploadCommand(uploadParams));
|
||||
return data;
|
||||
}
|
||||
export async function abortChunk(key: string, uploadId: string) {
|
||||
await S3.send(
|
||||
new AbortMultipartUploadCommand({
|
||||
Bucket: "tmp",
|
||||
Key: key,
|
||||
UploadId: uploadId,
|
||||
})
|
||||
);
|
||||
}
|
||||
export async function completeChunk(key: string, uploadId: string, parts: { ETag: string; PartNumber: number }[]) {
|
||||
const listed = await S3.send(
|
||||
new ListPartsCommand({
|
||||
Bucket: "tmp",
|
||||
Key: key,
|
||||
UploadId: uploadId,
|
||||
})
|
||||
);
|
||||
if (!listed.Parts || listed.Parts.length !== parts.length) {
|
||||
throw new Error("Not all parts have been uploaded");
|
||||
}
|
||||
await S3.send(
|
||||
new CompleteMultipartUploadCommand({
|
||||
Bucket: "tmp",
|
||||
Key: key,
|
||||
UploadId: uploadId,
|
||||
MultipartUpload: {
|
||||
Parts: parts.sort((a, b) => a.PartNumber - b.PartNumber),
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
export async function deleteObject(bucketName: string, objectKey: string) {
|
||||
await S3.send(
|
||||
new DeleteObjectCommand({
|
||||
Bucket: bucketName,
|
||||
Key: objectKey,
|
||||
})
|
||||
);
|
||||
}
|
||||
export async function listBuckets() {
|
||||
const data = await S3.send(new ListBucketsCommand({}));
|
||||
return data.Buckets;
|
||||
}
|
||||
export async function listObjects(bucketName: string) {
|
||||
const data = await S3.send(
|
||||
new ListObjectsV2Command({
|
||||
Bucket: bucketName,
|
||||
})
|
||||
);
|
||||
return data.Contents;
|
||||
}
|
||||
export async function generateUploadForm(fileName: string, contentType: string) {
|
||||
if (!imageContentTypes.includes(contentType)) {
|
||||
throw new Error("Invalid content type");
|
||||
}
|
||||
return await createPresignedPost(S3, {
|
||||
Bucket: "tmp",
|
||||
Key: nanoId()+"_"+fileName,
|
||||
Expires: 10 * 60, // URL valid for 10 minutes
|
||||
Conditions: [
|
||||
["starts-with", "$Content-Type", contentType],
|
||||
["content-length-range", 0, 31457280], // Max 30MB
|
||||
],
|
||||
});
|
||||
}
|
||||
// generateUploadUrl("tmp", "cat.png", "image/png").then(console.log);
|
||||
export async function createDownloadUrl(key: string): Promise<string> {
|
||||
const url = await getSignedUrl(
|
||||
S3,
|
||||
new GetObjectCommand({ Bucket: "tmp", Key: key }),
|
||||
{ expiresIn: 600 } // 600 giây = 10 phút
|
||||
);
|
||||
return url;
|
||||
}
|
||||
Reference in New Issue
Block a user