ππ» 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

βοΈ λ‘컬μλ² / 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”