[nextjs]SNS Server-41(myapp51)Β 

πŸ‘‰πŸ» myapp51μ—μ„œλŠ” κΈ€ μž‘μ„±μ‹œ 이미지 μ—…λ‘œλ“œ 뢀뢄을 κ΅¬ν˜„ν•©λ‹ˆλ‹€.
In myapp51, we are implementing the image upload feature for creating posts.

πŸ‘‰πŸ» μ—…λ‘œλ“œλœ 이미지 κ²½λ‘œλŠ” ν”„λ‘œμ νŠΈ 루트/public/uploads/ 디렉토리 μž…λ‹ˆλ‹€.
The path for uploaded images is the project root/public/uploads/ directory.

πŸ‘‰πŸ» μ—…λ‘œλ“œλœ μ΄λ―Έμ§€λŠ” 리λͺ¨νŠΈ μ„œλ²„λ‘œ μ „μ†‘ν•˜μ§€ μ•Šκ³  원본 μ£Όμ†Œ μ ˆλŒ€κ²½λ‘œ (note)λΆ€λΆ„λ§Œ μ „μ†‘ν•©λ‹ˆλ‹€.
Uploaded images are not transmitted to the remote server; only the absolute path to the original source is sent.

πŸ‘‰πŸ» 전체 μ½”λ“œλŠ” κΉƒν—ˆλΈŒμ—μ„œ 확인 ν•  수 μžˆμŠ΅λ‹ˆλ‹€.
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
β”‚   β”œβ”€β”€ 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
β”‚   β”œβ”€β”€ 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


πŸ“ ν…Œμ΄λΈ” μΆ”κ°€ / Add table

βœ”οΈ media_attachments : 파일 μ—…λ‘œλ“œ / file upload

βœ”οΈ post_media : μ—…λ‘œλ“œν•œ νŒŒμΌμ„ 글에 뢙이기 / Insert the uploaded file into the post.

-- 이미지 파일 경둜 / Image file path
CREATE TABLE IF NOT EXISTS media_attachments (
  id TEXT PRIMARY KEY,
  username TEXT NOT NULL,
  url TEXT NOT NULL,
  type TEXT NOT NULL,
  created_at INTEGER
);

-- postsμ—μ„œ κΈ€ ν•˜λ‚˜ μ§€μš°λ©΄ post_media에 μžˆλŠ” 연결쀄도 μžλ™μœΌλ‘œ μ§€μ›Œμ§
-- media_attachmentsμ—μ„œ 파일 ν•˜λ‚˜ μ§€μš°λ©΄ post_media 연결쀄도 μžλ™ μ§€μ›Œμ§
-- Deleting a post from `posts` automatically removes the corresponding link in `post_media`.
-- Deleting a file from `media_attachments` automatically removes the corresponding link in `post_media`.

CREATE TABLE IF NOT EXISTS post_media (
  post_id TEXT NOT NULL,
  media_id TEXT NOT NULL,
  PRIMARY KEY(post_id, media_id),
  FOREIGN KEY(post_id) REFERENCES posts(id) ON DELETE CASCADE,
  FOREIGN KEY(media_id) REFERENCES media_attachments(id) ON DELETE CASCADE
);

-- 인덱슀 μΆ”κ°€ / Add index
CREATE INDEX IF NOT EXISTS idx_media_username ON media_attachments(username);
CREATE INDEX IF NOT EXISTS idx_media_created ON media_attachments(created_at);
CREATE INDEX IF NOT EXISTS idx_post_media_media_id ON post_media(media_id);

πŸ“ μ½”λ“œ μˆ˜μ • 및 라우트 μΆ”κ°€ / Code modifications and route additions

βœ”οΈ lib/db.ts

// myapp51 - media
db.exec(`
... ...
CREATE TABLE IF NOT EXISTS media_attachments (
id TEXT PRIMARY KEY,
username TEXT NOT NULL,
url TEXT NOT NULL,
type TEXT NOT NULL, -- image/jpeg, image/png
created_at INTEGER
);
CREATE TABLE IF NOT EXISTS post_media (
post_id TEXT NOT NULL,
media_id TEXT NOT NULL,
PRIMARY KEY(post_id, media_id)
);
`);
db.exec(`
... ...
-- image upload
CREATE INDEX IF NOT EXISTS idx_media_username ON media_attachments(username);
CREATE INDEX IF NOT EXISTS idx_media_created ON media_attachments(created_at);
CREATE INDEX IF NOT EXISTS idx_post_media_media_id ON post_media(media_id);
`);

βœ”οΈ app/api/v1/media/route.ts – 이미지 μ—…λ‘œλ“œ API μ‹ κ·œ μΆ”κ°€ / Added new image upload API.

// app/api/v1/media/route.ts
//  - βœ… myapp51
import db from '@/lib/db';
import { randomUUID } from 'crypto';
import { NextResponse } from 'next/server';
import fs from 'fs';
import path from 'path';

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

function getUser(req: Request) {
  const auth = req.headers.get('Authorization')?.replace('Bearer ','').trim();
  if (auth) {
    const o = db.prepare('SELECT username FROM oauth_tokens WHERE access_token=?').get(auth) as any;
    if (o?.username) return o.username;
  }
  // μΏ ν‚€ / Cookie
  const cookie = req.headers.get('cookie') || '';
  const m = cookie.match(/refresh_token=([^;]+)/);
  if (m) {
    const s = db.prepare('SELECT username FROM sessions WHERE refresh_token=?').get(m[1]) as any;
    if (s?.username) return s.username;
  }
  return null;
}

export async function POST(req: Request) {
  const username = getUser(req);
  if (!username) return NextResponse.json({error:'unauthorized'}, {status:401});

  const form = await req.formData();
  const file = form.get('file') as File;
  if (!file) return NextResponse.json({error:'file required'}, {status:400});
  if (file.size > 8 * 1024 * 1024) return NextResponse.json({error:'8MB μ΄ν•˜λ§Œ'}, {status:400});

  if (!fs.existsSync(UPLOAD_DIR)) fs.mkdirSync(UPLOAD_DIR, {recursive:true});

  const id = randomUUID();
  const ext = file.name.split('.').pop() || 'jpg';
  const filename = `${id}.${ext}`;
  const buffer = Buffer.from(await file.arrayBuffer());
  fs.writeFileSync(path.join(UPLOAD_DIR, filename), buffer);

  const url = `/uploads/${filename}`;
  const fullUrl = `https://${process.env.DOMAIN || 'aloy-horizon.duckdns.org'}${url}`;

  db.prepare('INSERT INTO media_attachments (id, username, url, type, created_at) VALUES (?,?,?,?,?)')
   .run(id, username, fullUrl, file.type, Date.now());

  // Mastodon ν˜Έν™˜ 응닡 / Mastodon-compatible response
  return NextResponse.json({ id, url: fullUrl, preview_url: fullUrl, type: 'image' });
}

βœ”οΈ app/api/v1/statuses/route.ts – κΈ€μ“°κΈ° API μˆ˜μ • / Modify Writing API

... ...
export async function POST(req: Request) {
... ...
    // βœ… myapp51 
    // -  image upload 
    // - 3κ°€μ§€ μΌ€μ΄μŠ€ λͺ¨λ‘ 지원(λ§ˆμŠ€ν† λˆ,둜컬,μ•ˆλ“œλ‘œμ΄λ“œμ•±) + ν¬λž˜μ‹œ λ°©μ§€
    // - Supports all three scenarios (Mastodon, local, Android app) + crash prevention.
    const raw_ids = body.media_ids || body.media?.ids || body.mediaIds || [];
    const media_ids: string[] = Array.isArray(raw_ids) ? raw_ids : [];
... ...
    // βœ… myapp51 - image upload , μ†Œμœ κΆŒ 체크 ν›„ μ—°κ²° / Connect after verifying ownership
    // ['a','b'] β†’ ... β†’ 'a', 'b'
    let attachments: any[] = [];
    if (media_ids.length > 0) {
      const placeholders = media_ids.map(() => '?').join(',');
      attachments = db.prepare(
        `SELECT * FROM media_attachments WHERE id IN (${placeholders}) AND username=?`
      ).all(...media_ids, username) as any[];

      for (const m of attachments) {
        try {
          db.prepare('INSERT INTO post_media (post_id, media_id) VALUES (?,?)').run(id, m.id);
        } catch (e) {
          console.error('post_media insert fail', e);
        }
      }
    }
    // -  url μ ˆλŒ€κ²½λ‘œ 보정 / Adjusting absolute URL paths
    const toAbsoluteUrl = (u: string) => u.startsWith('http') ? u : `https://${DOMAIN}${u}`;
... ...
    // βœ… myapp48 + myapp51
    const note = {
      id: `https://${DOMAIN}/users/${username}/statuses/${id}`,
      type: 'Note',
      content: `<p>${content}</p>`,
      attributedTo: `https://${DOMAIN}/users/${username}`,
      published: new Date(now).toISOString(),
      to,
      cc,
      attachment: attachments.map(m => ({ // βœ… myapp51 - image uload
        type: 'Document',
        mediaType: m.type,
        url: toAbsoluteUrl(m.url)
      }))
    };
... ...
    return NextResponse.json({
      id: id,
... ...
      favourites_count: 0,
      replies_count: 0,
      // βœ… myapp51 - Pinafore/Tusky 미리보기 / 
      media_attachments: attachments.map(m => ({
        id: m.id,
        type: 'image',
        url: toAbsoluteUrl(m.url),
        preview_url: toAbsoluteUrl(m.url)
      }))
    }, {
      headers: { 'Access-Control-Allow-Origin': '*' }
    });


}

βœ”οΈ app/usersui/[username]/_components/themes/pinafore/PinaforeTheme.tsx

— UI Themeμˆ˜μ • / Modify UI Theme

// βœ… myapp51 - ν† ν°κ°€μ Έμ˜€κ°€ , url κ²½λ‘œλ³΄μ • / Retrieve token, adjust URL path
const getToken = () => localStorage.getItem('access_token') || localStorage.getItem('token') || '';
const toAbsolute = (u: string) => {
  if (!u) return '';
  return u.startsWith('http')? u : `https://${DOMAIN}${u}`;
};

export function PinaforeTheme({ timeline : initialTimeline, username, initialView, onBoost, onLike, onShare }: any) {
... ...
  // βœ… myapp51 - image upload
  const [mediaIds, setMediaIds] = useState<string[]>([]);
  const [previews, setPreviews] = useState<string[]>([]);
  const [uploading, setUploading] = useState(false);

... ...

// βœ… myapp48 + myapp51 : write post + image
const handlePost = async () => {
... ...
      body: JSON.stringify({ 
        status: composeText, 
        visibility: composeVis, 
        media_ids: mediaIds // βœ… myapp51 - add media_ids
      })
... ...
    if (res.ok) {
      const newPost = await res.json();
... ...
        media_attachments: newPost.media_attachments || [] // βœ… myap51 - preview

    }
... ...
};
}

// βœ… myapp51 : image upload
 async function onFiles(e: React.ChangeEvent<HTMLInputElement>) {
    const files = Array.from(e.target.files || []);
    if (files.length === 0) return;
    if (mediaIds.length + files.length > 4) return alert('μ΄λ―Έμ§€λŠ” μ΅œλŒ€ 4κ°œκΉŒμ§€ / Up to 4 images.');

    setUploading(true);
    const token = getToken();
    try {
      for(const file of files) {
        const fd = new FormData();
        fd.append('file', file);
        const res = await fetch('/api/v1/media', {
          method: 'POST',
          body: fd,
          headers: {...(token? { Authorization: `Bearer ${token}` } : {}) },
          credentials: 'include'
        });
        if (!res.ok) {
          const err = await res.json().catch(()=>({}));
          throw new Error(err.error || 'upload fail');
        }
        const data = await res.json();
        // data = { id, url, preview_url... }
        setMediaIds(prev => [...prev, data.id]);
        setPreviews(prev => [...prev, toAbsolute(data.preview_url || data.url)]);
      }
    } catch (err: any) {
      console.error(err);
      alert(`μ—…λ‘œλ“œ μ‹€νŒ¨/upload failed: ${err.message}`);
    } finally {
      setUploading(false);
      // 같은 파일 λ‹€μ‹œ 선택 κ°€λŠ₯ν•˜λ„λ‘ reset
      e.target.value = '';
    }
  }

  return (
    <>
      <style>{`
... ...

       /* βœ… myapp51 - composer-choice + preview */
       .composer-choice { display: flex; gap: 8px; align-items: center; margin-top: 10px; }
       .composer-choice label { width: 32px; height: 32px; display: flex; align-items: center; justify-content: center; border: 1px solid #e6ecf0; border-radius: 50%; background: white; cursor: pointer; transition: background 0.15s; }
       .composer-choice label:hover { background: #f3f4ff; border-color: #6364ff; }
       .composer-previews { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; margin-top: 10px; }
       .preview-item { position: relative; border-radius: 10px; overflow: hidden; border: 1px solid #e6ecf0; background: #f9fafb; }
       .preview-item img { width: 100%; height: 90px; object-fit: cover; display: block; }
       .preview-remove { position: absolute; top: 4px;
... ... 
 `}</style>

          {isMe && (
            <div className="pinafore-composer">
              <textarea
                value={composeText}
                onChange={(e) => setComposeText(e.target.value)}
                placeholder="무슨 일이 μΌμ–΄λ‚˜κ³  μžˆλ‚˜μš”?"
                maxLength={500}
              />

              {/* βœ… myapp51 - 이미지 선택 λ²„νŠΌ / Image selection button*/}
              <div className="composer-choice">
                  <label style={{cursor:'pointer', fontSize:18, padding:'2px 6px', border:'1px solid #e6ecf0', borderRadius:6, background:'white'}}>
                    πŸ–ΌοΈ
                    <input type="file" multiple accept="image/*" onChange={onFiles} style={{display:'none'}} />
                  </label>
              </div>

              {/* βœ… myapp51 - image preview  */}
              {previews.length > 0 && (
                <div className="composer-previews">
                  {previews.map((src, idx) => (
                    <div key={idx} className="preview-item">
                      <img src={src} alt="preview" />
                      <button className="preview-remove" onClick={() => removePreview(idx)}>Γ—</button>
                    </div>
                  ))}
                </div>
              )}
              {uploading && <div style={{fontSize:12, color:'#6364ff', marginTop:6}}>πŸ“€ μ—…λ‘œλ“œ 쀑 / uploading...</div>}


              <div className="composer-foot">
... ...
              </div>{/* /div className=compose-foot */}
            </div>
          )}
... ...

{timeline.map((p: any) => (
... ...
      <div className="status-text" dangerouslySetInnerHTML={{ __html: p.content }} />

       {/* βœ… myapp51 - νƒ€μž„λΌμΈ 이미지 λ Œλ” / Timeline image render*/}
       {p.media_attachments && p.media_attachments.length > 0 && (
        <div className={`status-media ${p.media_attachments.length === 1? 'single' : ''}`}>
           {p.media_attachments.map((m: any) => (
            <img key={m.id} src={toAbsolute(m.preview_url || m.url)} alt="media" loading="lazy" />
             ))}
         </div>
        )}
... ...
))}
);

βœ”οΈ app/api/v1/statuses/[id]/route.ts DELETE

— κΈ€ μ‚­μ œ 및 이미지 μ‚­μ œ( λ‹€μŒ ν”„λ‘œμ νŠΈ)
Delete Post and Delete Image (Next Project)

πŸ“ ν…ŒμŠ€νŠΈ / Test

βœ”οΈ 이미지 선택 / Select image

이미지선택 / select image

βœ”οΈ 이미지 μ—…λ‘œλ“œ – λ‘œμ»¬μ„œλ²„ / image upload – local server

λ‘œμ»¬μ„œλ²„ / Local Server

βœ”οΈ 이미지 μ—…λ‘œλ“œ – 리λͺ¨νŠΈμ„œλ²„(κ³ νˆ¬μ†Œμ…œ) / image upload – remote server(gotosocial)

Screenshot

μš”ν•œλ³΅μŒ 8μž₯ 32절 / John 8:32

“그리고 λ„ˆν¬λŠ” 진리λ₯Ό μ•Œκ²Œ 될 것이며, 진리가 λ„ˆν¬λ₯Ό 자유둭게 ν•  것이닀.”

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

Leave a Reply