Archivos
1. Crear bucket
- Crear bucket en AWS S3, Cloudflare R2, Digital Ocean Spaces, etc.
- Obtener variables de entorno (en R2 se crea en R2/API/Manage API Tokens)
- Crear archivo
.envy.env.templatey agregar al.gitignore
# ...
# Storage
STORAGE_BUCKET_NAME=abc
STORAGE_ENDPOINT=https://{ACCOUNT_ID}.r2.cloudflarestorage.com
STORAGE_ACCESS_KEY_ID=abc123 # or ACCOUNT_ID
STORAGE_SECRET_ACCESS_KEY=abc123
# In Cloudflare the SECRET_ACCESS_KEY is the API key
2. Configuración
1. Instalar AWS SDK S3
bun i @aws-sdk/client-s3
#npm i @aws-sdk/client-s3
2. Configuración del cliente
src\libs\services\storage-client.ts
import { S3Client } from "@aws-sdk/client-s3";
export const storageClient = new S3Client({
region: "auto",
endpoint: import.meta.env.STORAGE_ENDPOINT,
credentials: {
accessKeyId: import.meta.env.STORAGE_ACCESS_KEY_ID,
secretAccessKey: import.meta.env.STORAGE_SECRET_ACCESS_KEY,
},
});
3. Funciones reutilizables
src\libs\services\storage.service.ts
import { DeleteObjectCommand, PutObjectCommand } from "@aws-sdk/client-s3";
import { v4 as UUID } from "uuid";
import { storageClient } from "./storage-client";
export async function uploadFile(file: File): Promise<string> {
const fileExtension = file.name.split(".").pop();
if (!fileExtension) throw new Error("File extension not found");
const buffer = Buffer.from(await file.arrayBuffer());
const params = {
Bucket: import.meta.env.STORAGE_BUCKET_NAME,
Key: `${UUID()}.${fileExtension}`,
Body: buffer,
ContentType: file.type,
};
const res = await storageClient.send(new PutObjectCommand(params));
if (res.$metadata.httpStatusCode === 200) {
return params.Key;
}
throw new Error("Failed to upload file");
}
export async function deleteFile(fileKey: string): Promise<void> {
try {
await storageClient.send(
new DeleteObjectCommand({
Bucket: import.meta.env.STORAGE_BUCKET_NAME,
Key: fileKey,
})
);
} catch (error) {
console.error("Failed to delete file", error);
}
}
3. Uso
1. Action (o endpoint)
src\actions\products\update-product.action.ts
import { processImage } from "@libs/process-image";
import { deleteFile, uploadFile } from "@libs/storage";
import { ActionError, defineAction } from "astro:actions";
import { db, eq, Product } from "astro:db";
import { z } from "astro:schema";
const MAX_FILE_SIZE = 2 * 1024 * 1024;
const ACCEPTED_IMAGE_TYPES = [
"image/svg+xml",
"image/avif",
"image/webp",
"image/png",
"image/jpeg",
"image/jpg",
];
export const updateProduct = defineAction({
accept: "form",
input: z.object({
id: z.number().positive(),
name: z.string().min(1),
price: z.number().positive(),
image: z
.instanceof(File)
.optional()
.refine(
(file) =>
!file || file.size === 0 || ACCEPTED_IMAGE_TYPES.includes(file.type),
"Only .svg, .avif, .webp, .png, .jpeg and .jpg formats are supported."
)
.refine(
(file) => !file || file.size === 0 || file.size <= MAX_FILE_SIZE,
`Max image size is 2MB.`
),
}),
handler: async ({ id, name, price, image }, { locals }) => {
if (!locals.isLoggedIn) throw new ActionError({ code: "UNAUTHORIZED" });
// Check if the product exists
const existingProduct = await db
.select()
.from(Product)
.where(eq(Product.id, id));
if (existingProduct.length === 0) {
throw new ActionError({ code: "NOT_FOUND" });
}
// Save the image
let imageId: string | null = null;
if (image && image instanceof File && image.size > 0 && image.name !== "") {
try {
const processedImage = await processImage(image);
imageId = await uploadFile(processedImage);
} catch (error) {
throw new ActionError({
code: "UNPROCESSABLE_CONTENT",
message:
error instanceof Error ? error.message : "Failed to upload image",
});
}
}
// Update in the database
const { rowsAffected } = await db
.update(Product)
.set({ name, price, ...(imageId ? { image: imageId } : {}) })
.where(eq(Product.id, id));
if (rowsAffected === 0) {
// Delete the image if the update failed
if (imageId) await deleteFile(imageId);
throw new ActionError({
code: "NOT_FOUND",
message: "Product not found",
});
}
// Delete the old image if a new one was uploaded
if (imageId && existingProduct[0].image) {
await deleteFile(existingProduct[0].image);
}
return true;
},
});
2. Form
src\pages\products\edit[id].astro
<form>
<!-- Fields... -->
<div>
{
unit.imageUrl && (
<>
<p>Imagen actual</p>
<img
src={`https://junlaj-assets.042025.xyz/${unit.imageUrl}`}
alt="Unit"
class="rounded-md my-4"
width={500}
height={500}
/>
</>
)
}
<p>New image</p>
<input id="imageInput" type="file" name="image" accept="image/*" />
<img
id="imagePreview"
src="#"
alt="Preview"
class="rounded-md max-h-96 hidden my-4"
/>
</div>
<button id="btn-submit" class="w-full">Update</button>
</form>
<script>
import { toast } from "@/components/ui/toast";
import { ActionInputError, actions } from "astro:actions";
// Form submit
const form = document.querySelector("form") as HTMLFormElement;
const btnSubmit = document.getElementById("btn-submit") as HTMLButtonElement;
form.addEventListener("submit", async (e) => {
e.preventDefault();
btnSubmit.setAttribute("disabled", "disabled");
const formData = new FormData(form);
const { error } = await actions.updateUnit(formData);
if (error) {
const errorMessage =
error instanceof ActionInputError
? Object.entries(error.fields)
.map(([field, messages]) => `${field}: ${messages?.join(", ")}`)
.join("\n")
: error.message;
toast.error(errorMessage);
btnSubmit.removeAttribute("disabled");
return;
}
const courseId = form.getAttribute("data-courseId");
window.location.replace(`/course/${courseId}`);
});
// Image preview
const imageInput = document.getElementById("imageInput") as HTMLInputElement;
const imagePreview = document.getElementById(
"imagePreview"
) as HTMLImageElement;
if (imageInput && imagePreview) {
imageInput.addEventListener("change", function () {
if (this.files && this.files[0]) {
// Create URL for the selected image
const objectUrl = URL.createObjectURL(this.files[0]);
imagePreview.src = objectUrl;
// Show the image element
imagePreview.classList.remove("hidden");
} else {
// Hide the image element if no image is selected
imagePreview.classList.add("hidden");
imagePreview.src = "#";
}
});
}
</script>
3. Op. Estilo
src\styles\global.css
@layer base {
input[type="file"] {
@apply file:mr-4 file:px-4 file:py-1 file:rounded-md file:cursor-pointer file:bg-muted hover:file:bg-foreground/40;
}
}