[nextjs]SNS Server-42(myapp52) 

👉🏻 myapp52에서는 글 작성 후 이미지 삭제 부분을 구현합니다.
In myapp52, we are implementing the image deletion feature for posts.

👉🏻 글 작성하면서 이미지 첨부하고 서버 오류나 브라우저 새로고침시 고아파일이 발생합니다.
Orphaned files are created when attaching images while writing a post if a server error occurs or the browser is refreshed.

👉🏻 api/clenup-orphan 라우트를 이용해서 정리할 수 있습니다.
You can clean them up using the api/cleanup-orphan route.

👉🏻 전체 코드는 깃허브에서 확인 할 수 있습니다.
You can find the full code on GitHub.

https://github.com/gideonslife01/flm-nextjs

📁 전체 프로젝트 구조 / Overall Project Structure

myapp project/  
├── app/  (Next.js App Router)
│   ├── .well-known/webfinger/route.ts  -> webfinger
│   ├── api/follow/route.ts    -> Follow API(temporary)
│   ├── api/announce/route.ts  -> Boost(Announcement)
│   ├── api/like/route.ts.     -> Like API
│   ├── api/posts/route.ts     -> Writing API(deprecated)
│   ├── api/timeline/route.ts  -> Timeline API
│   ├── api/followinglist/route.ts  -> followinglist API
│   ├── api/followerslist/route.ts  -> followerslist API
│   ├── api/v1/instance/route.ts -> auth
│   ├── api/v1/apps/route.ts.    -> auth
│   ├── api/v1/accounts/verify_credentials/route.ts -> auth
│   ├── api/v1/statuses/route.ts -> Write Post
│   ├── api/v1/timelines/home/route.ts -> timeline
│   ├── api/v1/media/route.ts -> Image upload
│   ├── api/v1/media/[id]/route.ts  -> Image Delete 
│   ├── oauth/authorize/route.ts -> auth
│   ├── oauth/token/route.ts -> auth
│   ├── api/v1/search/home/route.ts -> search
│   ├── api/v2/search/home/route.ts -> search
│   ├── api/v1/accounts/[id]/followers/route.ts -> followers
│   ├── api/v1/accounts/[id]/following/route.ts -> following
│   ├── api/v1/statuses/[id]/route.ts -> -> Post delete(pinafore)
│   ├── api/auth/signup/route.ts        -> Signup
│   ├── api/auth/login/route.ts         -> Login
│   ├── api/auth/logout/route.ts        -> Logout
│   ├── api/auth/refresh/route.ts       -> Refresh Token
│   ├── api/cleanup-orphan/route.ts     -> post,outbox clean up
│   ├── api/auth/me/route.tsx           -> Login Check
│   ├── auth/signup/page.tsx            -> Signup UI
│   ├── auth/signup/page.tsx            -> Login UI
│   ├── users/[username]/
│   │   ├── statuses/[id]/route.ts -> Indivisual Post
│   │   ├── route.ts               -> Acotr Information
│   │   ├── followers/route.ts     -> Followers List
│   │   ├── following/route.ts     -> Following List
│   │   ├── inbox/route.ts         -> Inbox
│   │   └── outbox/route.ts        -> outbox
│   ├── usersui/[username]/
│   │   ├── page.tsx               -> Timeline UI(Server, 404)
│   │   ├── ClientPage.tsx.        -> Timeline UI(Client,Call Active Themes)
│   │   └── _components/themes/
│   │       ├── themeex/ThemeexTheme.tsx   -> Example Theme
│   │       ├── pinafore/PinaforeTheme.tsx -> Theme 1
│   │       ├── pinafore/FollowersList.tsx -> Theme 1, FollowersList
│   │       ├── pinafore/FollowingList.tsx -> Theme 1, FollowingList
│   │       ├── pinafore/NotificationsList.tsx -> Theme 1, NotificationsList
│   │       ├── mastodon/MastodonTheme.tsx -> Theme 2
│   │       └── minimal/MinimaltTheme.tsx  -> Theme 3
│   ├── layout.tsx, page.tsx, globals.css
│   └── favicon.ico
├── lib/
│   ├── theme.tsx              -> Theme Provider
│   ├── watchThemes.ts         -> Check real-time theme changes
│   ├── auth.ts                -> Authentication, User Management
│   ├── ap.ts                  -> Follow,Undo,Create,Likes,Announce
│   ├── visibility.ts          -> Visibility(Public,Unlisted,Private,Direct)
│   └── db.ts                  -> DB connection
├── data/
│   ├── keys/userIDs/          -> private.pem, public.pem(New)
│   └── keys/                  -> private.pem, public.pem(legacy)
├── data.sqlite                -> Database(1/3)
├── data.sqlite-wal            -> Database(2/3)
├── data.sqlite-shm            -> Database(3/3)
├── Caddyfile                  -> https 
├── instrumentation.ts         -> Background Server
└── package.json

📁 프로젝트 시작 / Project Start


📁 코드수정 / Code Modification

✔️ app/api/v1/statuses/[id]/route.ts

— 이미지 삭제 기능 추가 / Added image deletion feature.

import fs from 'fs';     // ✅ myapp52
import path from 'path'; // ✅ myapp52
... ...

const UPLOAD_DIR = path.join(process.cwd(), 'public', 'uploads'); // ✅ myapp52

export async function DELETE(
  req: Request,
  { params }: { params: Promise<{ id: string }> }
) {
... ...

  if (!post) { // ✅ myapp52
    return NextResponse.json({ id, deleted: true, alreadyDeleted: true },//{ error: 'not found' }, { status: 404 } error?
    {
      headers: { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Headers': '*' }
    });
  }

  if (post.username !== username) {
    console.log(`🚫 삭제 거부/Refusal to delete: ${username}가 ${post.username} 글 삭제 시도`);
    return NextResponse.json({ error: 'forbidden - not your post' }, { 
      status: 403, 
      headers: { 'Access-Control-Allow-Origin': '*' } 
    });
  }
  
// ✅ myapp52 
  // -  연결된 미디어(이미지) 찾기 / Find linked media (images)
  const medias = db.prepare(`
      SELECT ma.* FROM media_attachments ma
      JOIN post_media pm ON pm.media_id = ma.id
      WHERE pm.post_id = ? AND ma.username = ?
  `).all(id, username) as any[];

  // ✅ myapp52 -  트랜잭션으로 DB 정리 / Clean up DB with transactions
  // - DB에 CASCADE 있으나 확인 삭제 / Check for and delete CASCADE settings in the DB.
  const tx = db.transaction(() => {
    // post_media 먼저 / first post_media
    db.prepare('DELETE FROM post_media WHERE post_id=?').run(id);
    // media_attachments
    if (medias.length > 0) {
      const placeholders = medias.map(() => '?').join(',');
      db.prepare(`DELETE FROM media_attachments WHERE id IN (${placeholders}) AND username=?`)
      .run(...medias.map(m => m.id), username);
    }
    // delete posts
    db.prepare('DELETE FROM posts WHERE id=? AND username=?').run(id, username);
    // delete outbox
    db.prepare('DELETE FROM outbox WHERE object LIKE ?').run(`%/${id}%`);
    // delete inbox_posts
    try { db.prepare('DELETE FROM inbox_posts WHERE original_id LIKE ? OR id LIKE ?').run(`%${id}%`, `%${id}%`); } catch {}
  });
  tx();

    // ✅ myapp52 - 실제 파일 삭제 / Actual file deletion
    for (const m of medias) {
      try {
        // m.url = https://aloy-horizon.duckdns.org/uploads/xxx.jpg
        // or /uploads/xxx.jpg
        const filename = path.basename(new URL(m.url, `https://${DOMAIN}`).pathname);
        const filePath = path.join(UPLOAD_DIR, filename);
        if (fs.existsSync(filePath)) {
          fs.unlinkSync(filePath);
          console.log(`🗑 file deleted: ${filename}`);
        }
      } catch (e) {
        console.error('file delete fail', m.id, e);
      }
    }
... ...
}

✔️ app/api/v1/media/route.ts

— 파일 업로드 갯수 제한 설정(4개), 파일 업로드 용량제한 설정(1MB),이미지 파일만 업로드 설정
Set file upload limit (4 files), set file upload size limit (1MB), and restrict uploads to image files only.

// ✅ myapp52 - 파일 허용 타입 + 파일 갯수 제한 + 파일 업로드 용량 제한
// Allowed file types + File count limit + File upload size limit
const ALLOWED_MIMES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
const MAX_FILE_SIZE = 1 * 1024 * 1024; // 1MB(10 * 1024 * 1024 = 10MB)
const MAX_FILES_PER_USER_PENDING = 4; // 글에 첨부 가능한 이미지는 4개까지만 허용 / A maximum of four images can be attached to the post.


export async function POST(req: Request) {
  // ✅ myapp52 - 파일 갯수 제한 / File count limit
  try {
    const pending = db.prepare(`
      SELECT COUNT(*) as c FROM media_attachments
      WHERE username=? AND id NOT IN (SELECT media_id FROM post_media)
    `).get(username) as any;
    if (pending.c >= MAX_FILES_PER_USER_PENDING) {
      return NextResponse.json({error:`이미지 ${MAX_FILES_PER_USER_PENDING}개까지만 업로드 가능 - 먼저 글을 쓰거나 삭제하세요\n`}, {status:400});
    }
  } catch {}
... ...
  // ✅ myapp52 - 파일 용량 제한 / File size limit
  if (file.size === 0) return NextResponse.json({error:'empty file'}, {status:400});
  if (file.size > MAX_FILE_SIZE) return NextResponse.json({error:`${MAX_FILE_SIZE/1024/1024}MB 이하만 (현재 ${(file.size/1024/1024).toFixed(2)}MB)`}, {status:413});

  // ✅ myapp52 - 마임타입 체크(파일 타입체크) / MIME type check (file type check)
    if (!ALLOWED_MIMES.includes(file.type)) {
    return NextResponse.json({error:`허용 안됨: ${file.type}. 허용: ${ALLOWED_MIMES.join(', ')}`}, {status:400});
  }
... ...
  // const ext = file.name.split('.').pop() || 'jpg';
  // ✅ myapp52 - 파일 확장자 위조 방어 / Protection against file extension spoofing
  const ext = file.type === 'image/jpeg'? 'jpg' : file.type.split('/')[1]; // png, gif, webp
  if (!['jpg','png','gif','webp'].includes(ext)) {
    return NextResponse.json({error:'invalid extension'}, {status:400});
  }
}

✔️ app/api/v1/media/[id]/route.ts – 라우트 추가 / Add route

— 테마UI에 이미지 선택후 선택 취소시 삭제 하는 기능
Function to delete an image when it is deselected in the theme UI.

— 깃허브 전체 코드 참조 / Refer to the full code on GitHub.

import db from '@/lib/db';
import fs from 'fs';
import path from 'path';
import { NextResponse } from 'next/server';

const UPLOAD_DIR = path.join(process.cwd(), 'public', 'uploads');

function getUser(req: Request) {
... ...
}
export async function DELETE(req: Request, { params }: { params: Promise<{ id: string }> }) {
... ...
}

✔️ app/usersui/[username]/_components/themes/pinafore/PinaforeTheme.tsx

— 이미지 삭제(프리뷰,서버) / Delete image (preview, server)

... ...
// ✅ myapp52 - 이미지 삭제(프리뷰,서버) / Delete image (preview, server)
  const removePreview = async (idx: number) => {
      const mediaId = mediaIds[idx];
  // 서버에서도 삭제 / Delete from the server as well
  try {
    await fetch(`/api/v1/media/${mediaId}`, { method: 'DELETE', credentials: 'include' });
  } catch {}
    setMediaIds(prev => prev.filter((_, i) => i!== idx));
    setPreviews(prev => prev.filter((_, i) => i!== idx));
  };
... ...

✔️ 버그 수정 / Bug fixes

— 이미지 업로드 할경우 에러 경고창 한번에 띄우기(기존 파일 3개 업로드하면 경고창 3개 실행됨.)
Display a single error alert when uploading images (previously, uploading three files would trigger three separate alert windows).

 async function onFiles(e: React.ChangeEvent<HTMLInputElement>) {
      const failed: string[] = []; // ✅ myapp52 - 실패 모음 / Compilation of Failures
... ...
        
      // ✅ myapp52 - 루프 끝나고 경고창 한번만 띄우기 / Display a warning message only once after the loop finishes.
      if (failed.length > 0) {
        alert(`업로드 실패/upload failed ${failed.length}개:\n${failed.join('\n')}`);
      }
      } catch (err: any) {
        console.error(err);
        alert(`업로드 실패/upload failed: ${err.message}`);
      } finally {
        setUploading(false);
        // 같은 파일 다시 선택 가능하도록 reset / Reset to allow re-selecting the same file.
        e.target.value = '';
      }

... ...
}

✔️ 고아파일 삭제 기능 / Orphaned file deletion function

— 파일 정리용으로 사용되며 미디어 파일삭제 부분이 추가되었습니다.
It is used for file organization, and a media file deletion feature has been added.

— 전체 코드는 깃허브 코드를 참조하세요
Please refer to the GitHub repository for the full code.

— 사용방법 / How to use

# 토큰 먼저 확인 (브라우저 개발자도구 > Application > LocalStorage > refresh_token)
# 
export TOKEN="your_refresh_token"

# 1. 전체 삭제 정보확인 / Check Bulk Deletion Information (GET)
curl "https://aloy-horizon.duckdns.org/api/cleanup-orphan" \
  -H "Authorization: Bearer $TOKEN"

# 단일 글 상태 확인 / Check single post status
curl "https://aloy-horizon.duckdns.org/api/cleanup-orphan?id=5a24536b-06ec-400c-8539-4c1350dd1d19" \
  -H "Authorization: Bearer $TOKEN"

# 2. 유령글 + 24시간 지난 미디어 고아만 삭제 / Delete only "ghost posts" and media items older than 24 hours.(POST)
curl -X POST https://aloy-horizon.duckdns.org/api/cleanup-orphan \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json"

# 응답 예 / Response Example: 
{"orphans":3,"deleted":[...],"deletedFiles":2,"orphanMediasCleaned":2}

# 3. 리모트 + 로컬 + 미디어 전체 삭제 / Delete all remote, local, and media files (DELETE all)
curl -X DELETE "https://aloy-horizon.duckdns.org/api/cleanup-orphan?all=true" \
  -H "Authorization: Bearer $TOKEN"

# 4. 리모트 + 로컬 단일 글 삭제 / Delete single post (remote + local)(DELETE id)
curl -X DELETE "https://aloy-horizon.duckdns.org/api/cleanup-orphan?id=5a24536b-06ec-400c-8539-4c1350dd1d19" \
  -H "Authorization: Bearer $TOKEN"

# 응답 예 / Response Example: 
# {"message":"✅ 5a24536b... 리모트+로컬 삭제 완료 / Remote and local deletion complete.료!","deletedId":"5a24536b...","deletedFiles":2,...}

📁 테스트 / Test

✔️ 리모트 서버 / Remote Server

Remote Server

✔️ 로컬서버 / Local Server

Local Server

✔️ 파일 업로드 용량 제한 / File upload size limit

File upload size limit

✔️ 파일 업로드 수량 제한 / File upload quantity limit

Quantity limit

요한복음 8장 32절 / John 8:32

“그리고 너희는 진리를 알게 될 것이며, 진리가 너희를 자유롭게 할 것이다.”

“Then you will know the truth ,and the truth will set you free”

Leave a Reply