URL μμ± κ°μ
Public URL
κ³΅κ° μ κ·Ό μꡬμ λ§ν¬
Signed URL
μκ° μ ν μ κ·Ό 보μ κ°ν
μλ μμ±
saveToDisk ν μλ url, signedUrl μμ±
Storage Manager
getUrl() getSignedUrl()
UploadedFile URL μμ±
url μμ± (Public URL)
saveToDisk() νΈμΆ ν μλμΌλ‘ Public URLμ΄ μμ±λ©λλ€.
class FileModel extends BaseModelClass {
@upload()
async upload(): Promise<{
url: string;
signedUrl: string;
}> {
const { bufferedFiles } = Sonamu.getContext();
const file = bufferedFiles?.[0];
if (!file) throw new Error("νμΌμ΄ νμν©λλ€");
// νμΌ μ μ₯
await file.saveToDisk("fs", `uploads/${Date.now()}.${file.extname}`);
// URL μλ μμ±λ¨
const publicUrl = file.url!; // Public URL
const signedUrl = file.signedUrl!; // Signed URL
return {
url: publicUrl,
signedUrl,
};
}
}
urlκ³Ό signedUrlμ saveToDisk() νΈμΆ νμλ§ μ¬μ© κ°λ₯ν©λλ€.Storage Managerλ‘ URL μμ±
getUrl() - Public URL
import { Sonamu } from "sonamu";
class FileModel extends BaseModelClass {
@api({ httpMethod: "GET" })
async getFileUrl(fileId: number): Promise<{ url: string }> {
const rdb = this.getPuri("r");
const file = await rdb.table("files").where("id", fileId).first();
if (!file) {
throw new Error("File not found");
}
// Storage Managerλ‘ URL μμ±
const disk = Sonamu.storage.use(file.disk_name);
const url = await disk.getUrl(file.key);
return { url };
}
}
getSignedUrl() - Signed URL
class FileModel extends BaseModelClass {
@api({ httpMethod: "GET" })
async getDownloadUrl(
fileId: number,
expiresIn: number = 3600, // 1μκ°
): Promise<{
url: string;
expiresAt: Date;
}> {
const rdb = this.getPuri("r");
const file = await rdb.table("files").where("id", fileId).first();
if (!file) {
throw new Error("File not found");
}
// Signed URL μμ±
const disk = Sonamu.storage.use(file.disk_name);
const url = await disk.getSignedUrl(file.key, expiresIn);
const expiresAt = new Date(Date.now() + expiresIn * 1000);
return {
url,
expiresAt,
};
}
}
μ€ν 리μ§λ³ URL νμ
Local Storage
// Local μ€ν λ¦¬μ§ URL
// http://localhost:3000/uploads/1736234567.jpg
AWS S3
// S3 Public URL
// https://my-bucket.s3.us-east-1.amazonaws.com/uploads/1736234567.jpg
// S3 Signed URL
// https://my-bucket.s3.us-east-1.amazonaws.com/uploads/1736234567.jpg?
// X-Amz-Algorithm=AWS4-HMAC-SHA256&
// X-Amz-Credential=...&
// X-Amz-Date=...&
// X-Amz-Expires=3600&
// X-Amz-Signature=...&
// X-Amz-SignedHeaders=host
Google Cloud Storage
// GCS Public URL
// https://storage.googleapis.com/my-bucket/uploads/1736234567.jpg
// GCS Signed URL
// https://storage.googleapis.com/my-bucket/uploads/1736234567.jpg?
// GoogleAccessId=...&
// Expires=...&
// Signature=...
μ€μ μμ
νμΌ λ€μ΄λ‘λ URL
class FileModel extends BaseModelClass {
@api({ httpMethod: "GET" })
async getDownloadUrl(fileId: number): Promise<{
url: string;
filename: string;
expiresAt: Date;
}> {
const context = Sonamu.getContext();
const rdb = this.getPuri("r");
const file = await rdb.table("files").where("id", fileId).first();
if (!file) {
throw new Error("File not found");
}
// λ€μ΄λ‘λ λ‘κ·Έ
const wdb = this.getPuri("w");
await wdb.table("download_logs").insert({
file_id: fileId,
user_id: context.user?.id,
ip_address: context.request.ip,
user_agent: context.request.headers["user-agent"],
downloaded_at: new Date(),
});
// Signed URL μμ± (1μκ°)
const disk = Sonamu.storage.use(file.disk_name);
const url = await disk.getSignedUrl(file.key, 3600);
const expiresAt = new Date(Date.now() + 3600 * 1000);
return {
url,
filename: file.filename,
expiresAt,
};
}
}
μ΄λ―Έμ§ ν¬κΈ°λ³ URL
class ImageModel extends BaseModelClass {
@api({ httpMethod: "GET" })
async getImageUrls(imageId: number): Promise<{
original: string;
thumbnail: string;
medium: string;
}> {
const rdb = this.getPuri("r");
const image = await rdb.table("images").where("id", imageId).first();
if (!image) {
throw new Error("Image not found");
}
const disk = Sonamu.storage.use(image.disk_name);
return {
original: await disk.getUrl(image.original_key),
thumbnail: await disk.getUrl(image.thumbnail_key),
medium: await disk.getUrl(image.medium_key),
};
}
}
κΆν κΈ°λ° URL μμ±
class DocumentModel extends BaseModelClass {
@api({ httpMethod: "GET" })
async getDocumentUrl(documentId: number): Promise<{
url: string;
type: "public" | "signed";
}> {
const context = Sonamu.getContext();
const rdb = this.getPuri("r");
const document = await rdb.table("documents").where("id", documentId).first();
if (!document) {
throw new Error("Document not found");
}
const disk = Sonamu.storage.use(document.disk_name);
// Public λ¬Έμλ Public URL
if (document.is_public) {
const url = await disk.getUrl(document.key);
return {
url,
type: "public",
};
}
// Private λ¬Έμλ Signed URL
if (!context.user) {
throw new Error("Authentication required");
}
// κΆν νμΈ
if (document.user_id !== context.user.id && context.user.role !== "admin") {
throw new Error("Access denied");
}
const url = await disk.getSignedUrl(document.key, 3600);
return {
url,
type: "signed",
};
}
}
μΌκ΄ URL μμ±
class FileModel extends BaseModelClass {
@api({ httpMethod: "POST" })
async getBatchUrls(params: { fileIds: number[] }): Promise<{
urls: Record<
number,
{
url: string;
signedUrl: string;
}
>;
}> {
const { fileIds } = params;
if (fileIds.length > 100) {
throw new Error("Maximum 100 files at once");
}
const rdb = this.getPuri("r");
const files = await rdb.table("files").whereIn("id", fileIds).select("*");
const urls: Record<
number,
{
url: string;
signedUrl: string;
}
> = {};
for (const file of files) {
const disk = Sonamu.storage.use(file.disk_name);
urls[file.id] = {
url: await disk.getUrl(file.key),
signedUrl: await disk.getSignedUrl(file.key, 3600),
};
}
return { urls };
}
}
URL μΊμ±
Redis μΊμ±
import Redis from "ioredis";
class FileModel extends BaseModelClass {
private redis = new Redis(process.env.REDIS_URL);
@api({ httpMethod: "GET" })
async getSignedUrlCached(fileId: number): Promise<{
url: string;
expiresAt: Date;
}> {
// μΊμ νμΈ
const cacheKey = `signed-url:${fileId}`;
const cached = await this.redis.get(cacheKey);
if (cached) {
const data = JSON.parse(cached);
return data;
}
// νμΌ μ 보 μ‘°ν
const rdb = this.getPuri("r");
const file = await rdb.table("files").where("id", fileId).first();
if (!file) {
throw new Error("File not found");
}
// Signed URL μμ±
const disk = Sonamu.storage.use(file.disk_name);
const expiresIn = 3600; // 1μκ°
const url = await disk.getSignedUrl(file.key, expiresIn);
const expiresAt = new Date(Date.now() + expiresIn * 1000);
const result = { url, expiresAt };
// μΊμ μ μ₯ (λ§λ£ μκ°λ³΄λ€ μ§§κ²)
await this.redis.setex(
cacheKey,
expiresIn - 60, // 1λΆ μ¬μ
JSON.stringify(result),
);
return result;
}
}
CDN URL
CloudFront (AWS)
class FileModel extends BaseModelClass {
@api({ httpMethod: "GET" })
async getCDNUrl(fileId: number): Promise<{ url: string }> {
const rdb = this.getPuri("r");
const file = await rdb.table("files").where("id", fileId).first();
if (!file) {
throw new Error("File not found");
}
// CloudFront URL μμ±
const cloudFrontDomain = process.env.CLOUDFRONT_DOMAIN;
if (cloudFrontDomain) {
const url = `https://${cloudFrontDomain}/${file.key}`;
return { url };
}
// CloudFront μμΌλ©΄ μΌλ° URL
const disk = Sonamu.storage.use(file.disk_name);
const url = await disk.getUrl(file.key);
return { url };
}
}
Cloud CDN (GCP)
class FileModel extends BaseModelClass {
@api({ httpMethod: "GET" })
async getCDNUrl(fileId: number): Promise<{ url: string }> {
const rdb = this.getPuri("r");
const file = await rdb.table("files").where("id", fileId).first();
if (!file) {
throw new Error("File not found");
}
// Cloud CDN URL μμ±
const cdnDomain = process.env.CLOUD_CDN_DOMAIN;
if (cdnDomain) {
const url = `https://${cdnDomain}/${file.key}`;
return { url };
}
// CDN μμΌλ©΄ μΌλ° URL
const disk = Sonamu.storage.use(file.disk_name);
const url = await disk.getUrl(file.key);
return { url };
}
}
μμ μ λ‘λ URL (Presigned Upload)
ν΄λΌμ΄μΈνΈ μ§μ μ λ‘λ
class FileModel extends BaseModelClass {
@api({ httpMethod: "POST" })
async getUploadUrl(params: { filename: string; contentType: string }): Promise<{
uploadUrl: string;
key: string;
}> {
const { filename, contentType } = params;
// μμ ν ν€ μμ±
const ext = filename.split(".").pop();
const key = `uploads/${Date.now()}.${ext}`;
// Presigned Upload URL μμ± (5λΆ)
const disk = Sonamu.storage.use();
const uploadUrl = await disk.getSignedUrl(key, 300);
return {
uploadUrl,
key,
};
}
@api({ httpMethod: "POST" })
async confirmUpload(params: {
key: string;
filename: string;
contentType: string;
size: number;
}): Promise<{
fileId: number;
url: string;
}> {
const { key, filename, contentType, size } = params;
// νμΌ μ‘΄μ¬ νμΈ
const disk = Sonamu.storage.use();
const exists = await disk.exists(key);
if (!exists) {
throw new Error("File not uploaded");
}
// DBμ λ©νλ°μ΄ν° μ μ₯
const wdb = this.getPuri("w");
const [record] = await wdb
.table("files")
.insert({
key,
filename,
mime_type: contentType,
size,
url: await disk.getUrl(key),
})
.returning("id");
return {
fileId: record.id,
url: await disk.getUrl(key),
};
}
}
URL μ ν¨μ± κ²μ¬
URL λ§λ£ μκ° νμΈ
class FileModel extends BaseModelClass {
@api({ httpMethod: "GET" })
async checkUrlValidity(url: string): Promise<{
valid: boolean;
expiresAt?: Date;
}> {
// Signed URLμμ λ§λ£ μκ° μΆμΆ
const urlObj = new URL(url);
const expires = urlObj.searchParams.get("Expires") || urlObj.searchParams.get("X-Amz-Date");
if (!expires) {
// Public URL (λ§λ£ μμ)
return { valid: true };
}
const expiresAt = new Date(parseInt(expires) * 1000);
const valid = expiresAt > new Date();
return {
valid,
expiresAt,
};
}
}
μ£Όμμ¬ν
URL μμ± μ μ£Όμμ¬ν: 1. Signed URLμ λ§λ£ μκ° μ€μ 2. Public URLμ μꡬμ μ΄λ―λ‘ μ μ€ν μ¬μ©
3. CDN μ¬μ© μ μΊμ 무ν¨ν κ³ λ € 4. URL λ‘κΉ
μΌλ‘ μ¬μ© μΆμ κΆμ₯ 5. λ―Όκ°ν νμΌμ Signed URL μ¬μ©
λ€μ λ¨κ³
νμΌ μ λ‘λ μ€μ
@upload λ°μ½λ μ΄ν°
UploadedFile ν΄λμ€
νμΌ μ 보 μ κ·Ό
νμΌ μ μ₯
Storage Manager
@api λ°μ½λ μ΄ν°
API κΈ°λ³Έ μ¬μ©λ²