🔗 이전 프로젝트 내용 보기
🔗 레파지토리에서 커밋 히스토리별로 보기
// lib/meals.js
export function getMeal(slug) {
return db.prepare("SELECT * FROM meals WHERE slug = ?").get(slug);
}
// app/meals/[mealSlug]/page.js
import { getMeal } from "@/lib/meals";
import styles from "./page.module.css";
import Image from "next/image";
export default function MealDetailPage({ params }) {
const meal = getMeal(params.mealSlug);
meal.instructions = meal.instructions.replace(/\n/g, "<br />");
return (
<>
<header className={styles.header}>
<div className={styles.image}>
<Image src={meal.image} fill />
</div>
<div className={styles.headerText}>
<h1>{meal.title}</h1>
<p className={styles.creator}>
by <a href={`mailto:${meal.creator_email}`}>{meal.creator}</a>
</p>
<p className={styles.summary}>{meal.summary}</p>
</div>
</header>
<main>
<p
className={styles.instructions}
dangerouslySetInnerHTML={{ __html: meal.instructions }}
></p>
</main>
</>
);
}
dangerouslySetInnerHTML
: 컨텐츠를 HTML로 출력시키면 크로스 사이트 스크립트(XSS) 공격에 노출될 수 있다. 참고// app/meals/[mealSlug]/page.js
import { getMeal } from "@/lib/meals";
import styles from "./page.module.css";
import Image from "next/image";
import { notFound } from "next/navigation";
export default function MealDetailPage({ params }) {
const meal = getMeal(params.mealSlug);
if (!meal) {
notFound();
}
meal.instructions = meal.instructions.replace(/\n/g, "<br />");
return (
<>
<header className={styles.header}>
<div className={styles.image}>
<Image src={meal.image} fill />
</div>
<div className={styles.headerText}>
<h1>{meal.title}</h1>
<p className={styles.creator}>
by <a href={`mailto:${meal.creator_email}`}>{meal.creator}</a>
</p>
<p className={styles.summary}>{meal.summary}</p>
</div>
</header>
<main>
<p
className={styles.instructions}
dangerouslySetInnerHTML={{ __html: meal.instructions }}
></p>
</main>
</>
);
}
notFound
: 해당 컴포넌트가 실행되는 것을 멈추고 가장 가까운 not-found나 오류화면을 보여준다.// app/meals/share/page.js
import classes from "./page.module.css";
export default function ShareMealPage() {
return (
<>
<header className={classes.header}>
<h1>
Share your <span className={classes.highlight}>favorite meal</span>
</h1>
<p>Or any other meal you feel needs sharing!</p>
</header>
<main className={classes.main}>
<form className={classes.form}>
<div className={classes.row}>
<p>
<label htmlFor="name">Your name</label>
<input type="text" id="name" name="name" required />
</p>
<p>
<label htmlFor="email">Your email</label>
<input type="email" id="email" name="email" required />
</p>
</div>
<p>
<label htmlFor="title">Title</label>
<input type="text" id="title" name="title" required />
</p>
<p>
<label htmlFor="summary">Short Summary</label>
<input type="text" id="summary" name="summary" required />
</p>
<p>
<label htmlFor="instructions">Instructions</label>
<textarea
id="instructions"
name="instructions"
rows="10"
required
></textarea>
</p>
IMAGE PICKER
<p className={classes.actions}>
<button type="submit">Share Meal</button>
</p>
</form>
</main>
</>
);
}
// components/meals/image-picker.js
"use client";
import { useRef } from "react";
import styles from "./image-picker.module.css";
export default function ImagePicker({ label, name }) {
const imageInputRef = useRef();
function handlePickClick() {
imageInputRef.current.click();
}
return (
<div className={styles.picker}>
<label htmlFor={name}>{label}</label>
<div className={styles.controls}>
<input
className={styles.input}
type="file"
id={name}
accept="image/png, image/jpeg"
name={name}
ref={imageInputRef}
/>
<button
className={styles.button}
type="button"
onClick={handlePickClick}
>
Pick an Image
</button>
</div>
</div>
);
}
// app/meals/share/page.js
import ImagePicker from "@/components/meals/image-picker";
import classes from "./page.module.css";
export default function ShareMealPage() {
return (
<>
<header className={classes.header}>
<h1>
Share your <span className={classes.highlight}>favorite meal</span>
</h1>
<p>Or any other meal you feel needs sharing!</p>
</header>
<main className={classes.main}>
<form className={classes.form}>
<div className={classes.row}>
<p>
<label htmlFor="name">Your name</label>
<input type="text" id="name" name="name" required />
</p>
<p>
<label htmlFor="email">Your email</label>
<input type="email" id="email" name="email" required />
</p>
</div>
<p>
<label htmlFor="title">Title</label>
<input type="text" id="title" name="title" required />
</p>
<p>
<label htmlFor="summary">Short Summary</label>
<input type="text" id="summary" name="summary" required />
</p>
<p>
<label htmlFor="instructions">Instructions</label>
<textarea
id="instructions"
name="instructions"
rows="10"
required
></textarea>
</p>
<ImagePicker />
<p className={classes.actions}>
<button type="submit">Share Meal</button>
</p>
</form>
</main>
</>
);
}
// components/meals/image-picker.js
"use client";
import { useRef, useState } from "react";
import styles from "./image-picker.module.css";
import Image from "next/image";
export default function ImagePicker({ label, name }) {
const [pickedImage, setPickedImage] = useState();
const imageInputRef = useRef();
function handlePickClick() {
imageInputRef.current.click();
}
function handleImageChange(event) {
const file = event.target.files[0];
if (!file) {
setPickedImage(null);
return;
}
const fileReader = new FileReader();
fileReader.onload = () => {
setPickedImage(fileReader.result);
};
fileReader.readAsDataURL(file); // 아무것도 반환하지 않는다. 대신, fileReader 객체에 있는 load 속성에 값을 지정하는것으로 생성되는 DataURL을 얻게된다.
}
return (
<div className={styles.picker}>
<label htmlFor={name}>{label}</label>
<div className={styles.controls}>
<div className={styles.preview}>
{!pickedImage && <p>No image picked yet.</p>}
{pickedImage && (
<Image
src={pickedImage}
alt="The image selected by the user."
fill
/>
)}
</div>
<input
className={styles.input}
type="file"
id={name}
accept="image/png, image/jpeg"
name={name}
ref={imageInputRef}
onChange={handleImageChange}
required
/>
<button
className={styles.button}
type="button"
onClick={handlePickClick}
>
Pick an Image
</button>
</div>
</div>
);
}
// app/meals/share/page.js
import ImagePicker from "@/components/meals/image-picker";
import classes from "./page.module.css";
export default function ShareMealPage() {
async function shareMeal(formData) {
// 이 함수를 클라이언트 쪽이 아닌 서버 측에서 실행된다. 또한 이 함수는 자동적으로 제출된 폼 데이터를 받는다.
"use server";
const meal = {
title: formData.get("title"),
creator_email: formData.get("email"),
summary: formData.get("summary"),
image: formData.get("image"),
instructions: formData.get("instructions"),
creator: formData.get("name"),
};
console.log(meal);
}
return (
<>
<header className={classes.header}>
<h1>
Share your <span className={classes.highlight}>favorite meal</span>
</h1>
<p>Or any other meal you feel needs sharing!</p>
</header>
<main className={classes.main}>
<form className={classes.form} action={shareMeal}>
<div className={classes.row}>
<p>
<label htmlFor="name">Your name</label>
<input type="text" id="name" name="name" required />
</p>
<p>
<label htmlFor="email">Your email</label>
<input type="email" id="email" name="email" required />
</p>
</div>
<p>
<label htmlFor="title">Title</label>
<input type="text" id="title" name="title" required />
</p>
<p>
<label htmlFor="summary">Short Summary</label>
<input type="text" id="summary" name="summary" required />
</p>
<p>
<label htmlFor="instructions">Instructions</label>
<textarea
id="instructions"
name="instructions"
rows="10"
required
></textarea>
</p>
<ImagePicker name="image" label="Your image" />
<p className={classes.actions}>
<button type="submit">Share Meal</button>
</p>
</form>
</main>
</>
);
}
'use server'
: Server Action이라는 것을 생성하는데 오직 서버에서만 실행될 수 있게 보장해주는 기능이다.async
키워도 또한 붙여야 한다.// lib/action.js
"use server";
export async function shareMeal(formData) {
const meal = {
title: formData.get("title"),
creator_email: formData.get("email"),
summary: formData.get("summary"),
image: formData.get("image"),
instructions: formData.get("instructions"),
creator: formData.get("name"),
};
console.log(meal);
}
// app/meals/share/page.js
"use client"
import ImagePicker from "@/components/meals/image-picker";
import classes from "./page.module.css";
import { shareMeal } from "@/lib/action";
export default function ShareMealPage() {
return (
<>
<header className={classes.header}>
<h1>
Share your <span className={classes.highlight}>favorite meal</span>
</h1>
<p>Or any other meal you feel needs sharing!</p>
</header>
<main className={classes.main}>
<form className={classes.form} action={shareMeal}>
<div className={classes.row}>
<p>
<label htmlFor="name">Your name</label>
<input type="text" id="name" name="name" required />
</p>
<p>
<label htmlFor="email">Your email</label>
<input type="email" id="email" name="email" required />
</p>
</div>
<p>
<label htmlFor="title">Title</label>
<input type="text" id="title" name="title" required />
</p>
<p>
<label htmlFor="summary">Short Summary</label>
<input type="text" id="summary" name="summary" required />
</p>
<p>
<label htmlFor="instructions">Instructions</label>
<textarea
id="instructions"
name="instructions"
rows="10"
required
></textarea>
</p>
<ImagePicker name="image" label="Your image" />
<p className={classes.actions}>
<button type="submit">Share Meal</button>
</p>
</form>
</main>
</>
);
}
npm install slugify xss
→ instructions의 경우 HTML을 그대로 표현하므로 XSS에 취약하다. 따라서 xss도 설치// lib/meals.js
import sql from "better-sqlite3";
import slugify from "slugify";
import xss from "xss";
const db = sql("meals.db");
// ...
export function saveMeal(meal) {
meal.slug = slugify(meal.title, { lower: true });
meal.instructions = xss(meal.instructions); // instructions 검열
}
// lib/meals.js
import fs from "node:fs"; // 파일시스템 이용
import sql from "better-sqlite3";
import slugify from "slugify";
import xss from "xss";
const db = sql("meals.db");
//...
export async function saveMeal(meal) {
meal.slug = slugify(meal.title, { lower: true });
meal.instructions = xss(meal.instructions); // instructions 검열
const extension = meal.image.name.split(".").pop(); // 마지막 요소. 즉 확장자 받음
const fileName = `${meal.slug}.${extension}`;
const stream = fs.createWriteStream(`public/images/${fileName}`);
const bufferedImage = await meal.image.arrayBuffer(); // arrayBuffer함수가 프로미스를 반환 -> 버퍼로 변환됨.. 따라서 await 키워드 사용
stream.write(Buffer.from(bufferedImage), (error) => {
// write(저장할 chunk, 저장한 후 진행하는 코드(폴백))
if (error) {
// 에러가 있다면 에러에 대한 동작
throw new Error("이미지를 저장하는데 실패했습니다.");
}
}); // chunk : 이미지를 버퍼로..
meal.image = `/images/${fileName}`; // 모든 이미지에 관한 요청은 자동적으로 public 폴더로 보내짐
// 데이터베이스에 저장하기
db.prepare(
`
INSERT INTO meals
(title, summary, instructions, creator, creator_email, image, slug)
VALUES (
@title,
@summary,
@instructions,
@creator,
@creator_email,
@image,
@slug
)
`
).run(meal);
}
// lib/action.js
"use server"
import { redirect } from "next/dist/server/api-utils";
import { saveMeal } from "./meals";
export async function shareMeal(formData) {
const meal = {
title: formData.get("title"),
creator_email: formData.get("email"),
summary: formData.get("summary"),
image: formData.get("image"),
instructions: formData.get("instructions"),
creator: formData.get("name"),
};
await saveMeal(meal);
redirect("/meals");
}
// components/meals/meals-form-submit.js
"use client";
import { useFormStatus } from "react-dom";
export default function MealsFormSubmit() {
const { pending } = useFormStatus();
return (
<button type="submit" disabled={pending}>
{pending ? "Submitting..." : "Share Meal"}
</button>
);
}
// app/meals/share/page.js
"use client";
import ImagePicker from "@/components/meals/image-picker";
import classes from "./page.module.css";
import { shareMeal } from "@/lib/action";
import MealsFormSubmit from "@/components/meals/meals-form-submit";
export default function ShareMealPage() {
return (
<>
<header className={classes.header}>
<h1>
Share your <span className={classes.highlight}>favorite meal</span>
</h1>
<p>Or any other meal you feel needs sharing!</p>
</header>
<main className={classes.main}>
<form className={classes.form} action={shareMeal}>
<div className={classes.row}>
<p>
<label htmlFor="name">Your name</label>
<input type="text" id="name" name="name" required />
</p>
<p>
<label htmlFor="email">Your email</label>
<input type="email" id="email" name="email" required />
</p>
</div>
<p>
<label htmlFor="title">Title</label>
<input type="text" id="title" name="title" required />
</p>
<p>
<label htmlFor="summary">Short Summary</label>
<input type="text" id="summary" name="summary" required />
</p>
<p>
<label htmlFor="instructions">Instructions</label>
<textarea
id="instructions"
name="instructions"
rows="10"
required
></textarea>
</p>
<ImagePicker name="image" label="Your image" />
<p className={classes.actions}>
<MealsFormSubmit />
</p>
</form>
</main>
</>
);
}
// lib/action.js
"use server";
import { redirect } from "next/navigation";
import { saveMeal } from "./meals";
function isInvalidText(text) {
return !text || text.trim() === "";
}
export async function shareMeal(formData) {
const meal = {
title: formData.get("title"),
creator_email: formData.get("email"),
summary: formData.get("summary"),
image: formData.get("image"),
instructions: formData.get("instructions"),
creator: formData.get("name"),
};
if (
isInvalidText(meal.title) ||
isInvalidText(meal.summary) ||
isInvalidText(meal.instructions) ||
isInvalidText(meal.creator) ||
isInvalidText(meal.creator_email) ||
!meal.creator_email.instructions("@") ||
!meal.image ||
meal.image.size === 0
) {
throw new Error("Invalid Input");
}
await saveMeal(meal);
redirect("/meals");
}