[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