[nextjs]SNS Server-38(myapp48)ย 

๐Ÿ‘‰๐Ÿป myapp48์—์„œ๋Š” ๋กœ์ปฌ SNSํŽ˜์ด์ง€์—์„œ ๊ธ€์“ฐ๊ธฐ ํผ์„ ์ถ”๊ฐ€ํ•ฉ๋‹ˆ๋‹ค.
In myapp48, a post creation form is added to the local social media page.

๐Ÿ‘‰๐Ÿป ์ž‘์„ฑํ•œ ๊ธ€์ด public / unlisted / private๋กœ ๊ตฌ๋ถ„ํ•ด์„œ ์ €์žฅ๋˜๋„๋ก ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ํ…Œ์ด๋ธ”์„ ์ˆ˜์ •ํ•ฉ๋‹ˆ๋‹ค.
Modify the database table so that written posts are saved with a classification of public, unlisted, or private.

๐Ÿ‘‰๐Ÿป ์ „์ฒด ์ฝ”๋“œ๋Š” ๊นƒํ—ˆ๋ธŒ์—์„œ ํ™•์ธ ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.
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
โ”‚   โ”œโ”€โ”€ 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


๐Ÿ“ visibility

๊ธ€ ์ƒํƒœ๊ฒŒ์ŠคํŠธ๋กœ๊ทธ์ธ(๋‚จ)ํŒ”๋กœ์›Œ
publicOOO
unlistedX (ํ”„๋กœํ•„)O (ํ”„๋กœํ•„)O / ํ™ˆ์€ ์„ค์ •์— ๋”ฐ๋ผ
privateXXO (๋ณธ์ธ+ํŒ”๋กœ์›Œ)

๐Ÿ“ ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ํ…Œ์ด๋ธ” ์ˆ˜์ •
Modify database table

— ์•„๋ž˜์˜ SQL๋ฌธ์„ Sqlite์—์„œ ํ•œ์ค„์”ฉ ์‹คํ–‰ํ•ฉ๋‹ˆ๋‹ค.
Execute the SQL statements below in SQLite one line at a time.

-- 1. posts์— visibility + ํ•„์š”ํ•œ ์ปฌ๋Ÿผ ์ถ”๊ฐ€
Add visibility and necessary columns to the `posts` table.

ALTER TABLE posts ADD COLUMN visibility TEXT DEFAULT 'public' CHECK(visibility IN ('public','unlisted','private','direct'));
ALTER TABLE posts ADD COLUMN original_id TEXT;
ALTER TABLE posts ADD COLUMN sensitive INTEGER DEFAULT 0;

-- 2. inbox_posts์— visibility + type + sensitive ์ถ”๊ฐ€ (๋ฆฌ๋ชจํŠธ ๊ธ€ ๊ตฌ๋ถ„)
Added `visibility`, `type`, and `sensitive` to `inbox_posts` (to distinguish remote posts).

ALTER TABLE inbox_posts ADD COLUMN visibility TEXT DEFAULT 'public' CHECK(visibility IN ('public','unlisted','private','direct'));
ALTER TABLE inbox_posts ADD COLUMN type TEXT DEFAULT 'Note';
ALTER TABLE inbox_posts ADD COLUMN sensitive INTEGER DEFAULT 0;

-- 3. outbox์—๋„ visibility ์ถ”๊ฐ€ (๋ฐฐ๋‹ฌํ• ๋•Œ to/cc ๊ตฌ๋ถ„์šฉ)
Added visibility to the outbox as well (to distinguish between 'To' and 'CC' during delivery).

ALTER TABLE outbox ADD COLUMN visibility TEXT DEFAULT 'public';

-- 4. ์ธ๋ฑ์Šค ๋ณด๊ฐ• (ํƒ€์ž„๋ผ์ธ ์†๋„)
Index Reinforcement (Timeline Speed)

CREATE INDEX IF NOT EXISTS idx_posts_visibility ON posts(visibility);
CREATE INDEX IF NOT EXISTS idx_inbox_visibility ON inbox_posts(visibility);
CREATE INDEX IF NOT EXISTS idx_outbox_visibility ON outbox(visibility);

-- ํ™•์ธ/Check
.schema posts
.schema inbox_posts
.schema outbox

— ๋ณ€๊ฒฝ๋œ ์Šคํ‚ค๋งˆ / Modified schema

sqlite> .schema posts
CREATE TABLE posts (
    id TEXT PRIMARY KEY,
    content TEXT NOT NULL,
    created_at DATETIME DEFAULT CURRENT_TIMESTAMP
  , username TEXT DEFAULT 'user1', visibility TEXT DEFAULT 'public' CHECK(visibility IN ('public','unlisted','private','direct')), original_id TEXT, sensitive INTEGER DEFAULT 0);
CREATE INDEX idx_posts_username_created ON posts(username, created_at DESC);
CREATE INDEX idx_posts_visibility ON posts(visibility);

sqlite> .schema inbox_posts
CREATE TABLE inbox_posts (
  id TEXT PRIMARY KEY,
  actor TEXT,
  content TEXT,
  username TEXT,
  created_at DATETIME DEFAULT CURRENT_TIMESTAMP
, original_id TEXT, visibility TEXT DEFAULT 'public' CHECK(visibility IN ('public','unlisted','private','direct')), type TEXT DEFAULT 'Note', sensitive INTEGER DEFAULT 0);
CREATE INDEX idx_inbox_username_created ON inbox_posts(username, created_at DESC);
CREATE INDEX idx_inbox_actor ON inbox_posts(actor);
CREATE INDEX idx_inbox_visibility ON inbox_posts(visibility);

sqlite> .schema outbox
CREATE TABLE outbox (
          id TEXT PRIMARY KEY,
          type TEXT,
          actor TEXT,
          object TEXT,
          created_at INTEGER
        , visibility TEXT DEFAULT 'public');
CREATE INDEX idx_outbox_visibility ON outbox(visibility);

๐Ÿ“ ํŒŒ์ผ ์ถ”๊ฐ€ / Add file – visibility

โœ”๏ธ lib/visibility.ts

— ์ฟ ํ‚ค์— ์žˆ๋Š” ๊ฐ’์œผ๋กœ sessionํ…Œ์ด๋ธ”์˜ ์œ ์ €๋ฅผ ๊ฒ€์ƒ‰ํ•˜๊ณ  SNSํŽ˜์ด์ง€์˜ ๊ธ€์„ public’ | ‘unlisted’ | ‘private’ ๋ณ„๋กœ ํ•„ํ„ฐ๋ง ํ•˜๋Š” ๊ธฐ๋Šฅ์ž…๋‹ˆ๋‹ค.
This feature searches for a user in the session table using a value from a cookie and filters posts on the SNS page based on visibility settings: ‘public’, ‘unlisted’, or ‘private’.

— ์ž์„ธํ•œ ์ฝ”๋“œ๋‚ด์šฉ์€ ๊นƒํ—ˆ๋ธŒ ์ฝ”๋“œ๋ฅผ ์ฐธ์กฐ ํ•˜์„ธ์š”
Please refer to the GitHub repository for detailed code.

export type Visibility = 'public' | 'unlisted' | 'private' | 'direct';

function decodeJwtUsername(jwt: string): string | null { ... }
export function getViewerFromRequest(req: Request): string | null { ... }
export function isFollower(viewer: string, author: string): boolean { ... }
export function canSee(viewer: string | null, post: any): boolean { ... }

๐Ÿ“ ์ฝ”๋“œ ์ˆ˜์ • / Code Modification – visibility ์ €์žฅ ๋ฐ ๊ธฐ์กด ๊ธฐ๋Šฅ ํ™•์ธ

โœ”๏ธ lib/db.ts

— ๋ณ€๊ฒฝ๋œ ์Šคํ‚ค๋งˆ์— ๋งž๊ฒŒ ์ฝ”๋“œ๋ฅผ ์ˆ˜์ •ํ•ฉ๋‹ˆ๋‹ค.
Modify the code to match the updated schema.

db.exec(`
... ...

  -- โœ… myapp48 posts - visibility ์ถ”๊ฐ€! (public/unlisted/private/direct)
  CREATE TABLE IF NOT EXISTS posts (
    id TEXT PRIMARY KEY,
    content TEXT NOT NULL,
    username TEXT DEFAULT 'user1',
    visibility TEXT DEFAULT 'public' CHECK(visibility IN ('public','unlisted','private','direct')),
    sensitive INTEGER DEFAULT 0,
    original_id TEXT,
    created_at DATETIME DEFAULT CURRENT_TIMESTAMP
  );

... ...

  -- โœ… myapp48 inbox_posts - visibility + type ์ถ”๊ฐ€/Added
  CREATE TABLE IF NOT EXISTS inbox_posts (
    id TEXT PRIMARY KEY,
    original_id TEXT,
    actor TEXT,
    content TEXT,
    username TEXT,
    visibility TEXT DEFAULT 'public' CHECK(visibility IN ('public','unlisted','private','direct')),
    type TEXT DEFAULT 'Note',
    sensitive INTEGER DEFAULT 0,
    created_at DATETIME DEFAULT CURRENT_TIMESTAMP
  );

... ...

  -- โœ… myapp46 outbox - visibility ์ถ”๊ฐ€/added!
  CREATE TABLE IF NOT EXISTS outbox (
    id TEXT PRIMARY KEY,
    type TEXT,
    actor TEXT,
    object TEXT,
    visibility TEXT DEFAULT 'public',
    created_at INTEGER
  );

... ...
`);

db.exec(`
... ...

  -- posts
  CREATE INDEX IF NOT EXISTS idx_posts_username_created ON posts(username, created_at DESC);
  CREATE INDEX IF NOT EXISTS idx_posts_visibility ON posts(visibility);
  
  -- inbox_posts
  CREATE INDEX IF NOT EXISTS idx_inbox_username_created ON inbox_posts(username, created_at DESC);
  CREATE INDEX IF NOT EXISTS idx_inbox_actor ON inbox_posts(actor);
  CREATE INDEX IF NOT EXISTS idx_inbox_visibility ON inbox_posts(visibility);

... ...

  -- outbox
  CREATE INDEX IF NOT EXISTS idx_outbox_visibility ON outbox(visibility);
`);

โœ”๏ธapp/api/v1/statuses/route.ts – remote server

— ์™ธ๋ถ€ ์•ฑ์—์„œ ๋‚ด ์„œ๋ฒ„์— ๊ธ€ ์ž‘์„ฑํ• ๋•Œ visibility ํ•„๋“œ ์ ์šฉ
Apply the visibility field when posting to my server from an external app.

... ...
export async function POST(req: Request) {
... ...    
/*-- ํ…Œ์ด๋ธ” ์ƒ์„ฑ ๋ถ€๋ถ„ ์‚ญ์ œ / Delete the table creation section. */

    // posts ์ €์žฅ / save posts
    // โœ… myapp48 - add visibility
    try {
      db.prepare(`INSERT INTO posts (id, content, created_at, username, visibility) VALUES (?,?,?,?,?)`)
        .run(id, content, now, username, visibility);
    } catch (e) {
      console.error('posts ์ €์žฅ ์—๋Ÿฌ / posts save error', e);
    }

    //โœ… myapp48 -  to,cc ๊ตฌ๋ถ„ / to,CC distinction
    const followersUrl = `https://${DOMAIN}/users/${username}/followers`;
    let to: string[] = [];
    let cc: string[] = [];

    if (visibility === 'public') {
      to = ['https://www.w3.org/ns/activitystreams#Public'];
      cc = [followersUrl];
    } else if (visibility === 'unlisted') {
      to = [followersUrl];
      cc = ['https://www.w3.org/ns/activitystreams#Public'];
    } else if (visibility === 'private') {
      to = [followersUrl];
      cc = [];
    }
    // โœ… myapp48
    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,
    };

    /*-- ํ…Œ์ด๋ธ” ์ƒ์„ฑ ๋ถ€๋ถ„ ์‚ญ์ œ / Delete the table creation section. */
    // outbox ์ €์žฅ
    // โœ… myapp48 - add visibility
    try {
      db.prepare(`INSERT INTO outbox (id, type, actor, object, created_at, visibility) VALUES (?,?,?,?,?,?)`)
        .run(randomUUID(), 'Create', `https://${DOMAIN}/users/${username}`, JSON.stringify(note), now, visibility);
    } catch (e) {
      console.error('outbox ์ €์žฅ ์—๋Ÿฌ', e);
    }
... ...
}
... ...

โœ”๏ธapp/api/posts/route.ts – local server

— ๋กœ์ปฌ ์„œ๋ฒ„ ๊ธ€์“ฐ๊ธฐ ํ•  ๋•Œ visibility ํ•„๋“œ ์ ์šฉ
Apply the visibility field when writing on the local server.

... ...
// โœ… myapp48 
import { getViewerFromRequest, canSee } from '@/lib/visibility';

export async function GET(req: Request) {
  const { searchParams } = new URL(req.url);
   let targetUsername = searchParams.get('username'); // โœ… myapp48


  // โœ… myapp48 - viewer / target ๋ถ„๋ฆฌ (๊ธฐ์กด ์ฝ”๋“œ๋Š” token ์žˆ์œผ๋ฉด username ๋ฎ์–ด์จ์„œ ํ”„๋กœํ•„ ์กฐํšŒ๊ฐ€ ๋ง๊ฐ€์ง)
  // targetUsername = ํ”„๋กœํ•„ ์ฃผ์ธ, viewer = ์ง€๊ธˆ ๋กœ๊ทธ์ธํ•œ ์‚ฌ๋žŒ
  const viewer = getViewerFromRequest(req); // login user
  const profileOwner = targetUsername; // search user

  // โœ… myapp48 - target ์ฒดํฌ
  if (!profileOwner) return NextResponse.json({ error: 'username required' }, { status: 400 });

  const allPosts = db.prepare('SELECT * FROM posts WHERE username =? ORDER BY created_at DESC').all(profileOwner) as any[];

  // โœ… myapp48 - visibility ํ•„ํ„ฐ๋ง ์ถ”๊ฐ€
  // public: ๋ˆ„๊ตฌ๋‚˜ / unlisted: ๋กœ๊ทธ์ธ ํ•„์š” / private: ๋ณธ์ธ+ํŒ”๋กœ์›Œ๋งŒ
  const isOwnerView = viewer && viewer === profileOwner;
  const filtered = allPosts.filter((p: any) => {
    if (isOwnerView) return true; // ๋ณธ์ธ์ด๋ฉด ์ „๋ถ€ ๋ณด๊ธฐ
    return canSee(viewer, p); 
  });

  return NextResponse.json(filtered);
}

export async function POST(req: Request) {
... ...
  // โœ… myapp48 - visibility default value
  visibility = visibility || 'public';
... ...
  // โœ… myapp48 - add visibility
  const id = randomUUID();
  const now = Date.now();
  //db.prepare('INSERT INTO posts (id, content, username) VALUES (?, ?, ?)').run(id, content, username);
  db.prepare('INSERT INTO posts (id, content, username, created_at, visibility) VALUES (?,?,?,?,?)').run(id, content, username, now, visibility);

  // โœ… myapp48
  // - to/cc distinction
  const followersUrl = `https://${DOMAIN}/users/${username}/followers`;
  let to: string[] = [];
  let cc: string[] = [];
  if (visibility === 'public') {
    to = ['https://www.w3.org/ns/activitystreams#Public'];
    cc = [followersUrl];
  } else if (visibility === 'unlisted') {
    to = [followersUrl];
    cc = ['https://www.w3.org/ns/activitystreams#Public'];
  } else if (visibility === 'private') {
    to = [followersUrl];
    cc = [];
  }
  // โœ… myapp48
  const noteId = `https://${DOMAIN}/users/${username}/posts/${id}`;
  const note = {
    id: noteId,
    type: 'Note',
    attributedTo: `https://${DOMAIN}/users/${username}`,
    content: content.startsWith('<p>')? content : `<p>${content}</p>`,
    published: new Date().toISOString(),
    to,
    cc
  };

// โœ… myapp48 - add visibility
  try {
    db.prepare('INSERT INTO outbox (id, type, actor, object, created_at, visibility) VALUES (?,?,?,?,?,?)')
     .run(randomUUID(), 'Create', `https://${DOMAIN}/users/${username}`, JSON.stringify(note), now, visibility);
  } catch (e) {
    console.error('outbox ์ €์žฅ ์—๋Ÿฌ', e);
  }
... ...

}
... ...

โœ”๏ธ app/api/timeline/route.ts – local UI

— ๋‚ด ์„œ๋ฒ„ UIํŽ˜์ด์ง€์—์„œ visibility ์ ์šฉ
Apply visibility on my server UI page.

... ...
// โœ… myapp48 
import { getViewerFromRequest, canSee } from '@/lib/visibility';

export async function GET(req: Request) {
... ...
  // โœ… myapp48
  const viewer = getViewerFromRequest(req);
  const isOwnerView = viewer && viewer === username;
... ...
  // โœ… myapp48 - visibility ์ปฌ๋Ÿผ ์ถ”๊ฐ€ ์กฐํšŒ ( visibility ๊ฒ€์ƒ‰)
  const rawTimeline = db.prepare(`
    SELECT id, content, username, username as actor, created_at, visibility, 'mine' as source, id as original_id
    FROM posts WHERE username =?
    UNION ALL
    SELECT id, content, username, actor, created_at, visibility, 'inbox' as source, original_id
    FROM inbox_posts WHERE username =?
    ORDER BY created_at DESC
    LIMIT 80
  `).all(username, username) as any[];

  // โœ… myapp48 - visibility ํ•„ํ„ฐ๋ง ๋กœ์ง ์ถ”๊ฐ€
  // public: ๋ˆ„๊ตฌ๋‚˜ / unlisted: ๋กœ๊ทธ์ธ ํ•„์š” / private: ๋ณธ์ธ + ํŒ”๋กœ์›Œ๋งŒ
  const timeline = rawTimeline.filter((p: any) => {
    // ๋‚ด ํƒ€์ž„๋ผ์ธ์„ ๋‚ด๊ฐ€ ๋ณด๋Š” ๊ฒฝ์šฐ -> ์ „๋ถ€ ๋ณด์—ฌ์คŒ (private ํฌํ•จ)
    if (isOwnerView) return true;
    // ๋‚จ์˜ ํƒ€์ž„๋ผ์ธ์„ ๋ณด๊ฑฐ๋‚˜ ๊ฒŒ์ŠคํŠธ์ธ ๊ฒฝ์šฐ -> canSee๋กœ ํ•„ํ„ฐ
    return canSee(viewer, p);
  });

}

โœ”๏ธ app/api/v1/timelines/home/route.ts – External UI(etc. Pinafore)

— ์™ธ๋ถ€์•ฑ์—์„œ ๋‚ด ์„œ๋ฒ„ ํƒ€์ž„๋ผ์ธ ๋ณผ๋•Œ visibility ์ ์šฉ
Apply visibility settings when viewing my server’s timeline from an external app.

// โœ… myapp48 - visibility ๊ณตํ†ต ํ—ฌํผ import ์ถ”๊ฐ€
import { getViewerFromRequest, canSee } from '@/lib/visibility';
... ...
export async function GET(req: Request) { 
... ...
  // โœ… myapp48 
  const viewer = getViewerFromRequest(req);
  const username = viewer || 'user1'; // ๊ฒŒ์ŠคํŠธ๋ฉด ๋นˆ ํƒ€์ž„๋ผ์ธ ๋ฐ˜ํ™˜๋˜๋„๋ก ์•„๋ž˜์—์„œ ์ฒ˜๋ฆฌ

  // โœ… myapp48 - ๋น„๋กœ๊ทธ์ธ์ด๋ฉด ํ™ˆ ํƒ€์ž„๋ผ์ธ ์ ‘๊ทผ ์ฐจ๋‹จ (๋งˆ์Šคํ† ๋ˆ ์ŠคํŽ™)
  // Access to the home timeline is blocked for non-logged-in users (Mastodon specification).
  if (!viewer) {
    return NextResponse.json([], { headers: { 'Access-Control-Allow-Origin': '*', 'Content-Type': 'application/json' } });
  }
... ...
  // โœ… myapp48 - visibility ํ•„ํ„ฐ๋ง ์ ์šฉ
  // ์ •์ฑ…: public = ๋ˆ„๊ตฌ๋‚˜, unlisted = ํ™ˆ์—์„œ๋Š” ๋ณด์—ฌ์คŒ (ํ”„๋กœํ•„์—์„œ๋งŒ ์ˆจ๊ธฐ๋ ค๋ฉด false๋กœ ๋ณ€๊ฒฝ), private = ๋ณธ์ธ+ํŒ”๋กœ์›Œ๋งŒ
  // Policy: public = visible to everyone, unlisted = visible on Home (change to false to hide only from profile), private = visible only to self and followers
  const rawRows = [...myPosts,...inboxPosts];
  const filteredRows = rawRows.filter((p: any) => {
    const vis = p.visibility || 'public';
    if (vis === 'public') return true;
    if (vis === 'unlisted') {
      // ๋งˆ์Šคํ† ๋ˆ ์ •์„์€ ํ™ˆ์— unlisted ํ‘œ์‹œํ•จ. 
      return true; // <- false๋กœ ๋ฐ”๊พธ๋ฉด ํ™ˆ์—์„œ unlisted ์ˆจ๊น€
    }
    if (vis === 'private') {
      return canSee(viewer, p);
    }
    return true;
  });
  filteredRows.sort((a,b) => parseDate(b.created_at) - parseDate(a.created_at));
... ...
}

โœ”๏ธapp/users/[username]/inbox/route.ts

— ๊ธ€ ์ €์žฅ์‹œ visibility ํ•„๋“œ ์ถ”๊ฐ€ / Add a visibility field when saving the post.

... ...
export async function POST(req: Request, { params }: { params: Promise<{ username: string }> }) {
... ...
if (body.type === 'Create') {
... ...
          // โœ… myapp48 - for visibility
          const to = note.to || [];
          const cc = note.cc || [];
          const isPublicInTo = Array.isArray(to)? to.includes('https://www.w3.org/ns/activitystreams#Public') : to === 'https://www.w3.org/ns/activitystreams#Public';
          const isPublicInCc = Array.isArray(cc)? cc.includes('https://www.w3.org/ns/activitystreams#Public') : cc === 'https://www.w3.org/ns/activitystreams#Public';
          let visibility = 'public';
          if (!isPublicInTo &&!isPublicInCc) visibility = 'private';
          else if (!isPublicInTo && isPublicInCc) visibility = 'unlisted';
          else visibility = 'public';

          console.log(` -> visibility: ${visibility} to=${JSON.stringify(to)} cc=${JSON.stringify(cc)}`);


          // db.prepare(`INSERT OR IGNORE INTO inbox_posts (id, actor, content, username, original_id, created_at) VALUES (?,?,?,?,?,?)`)
          //  .run(shortId, actorId, content, username, longId, note.published || new Date().toISOString());

          // โœ… myapp48 - add visibility
          db.prepare(`INSERT OR IGNORE INTO inbox_posts (id, actor, content, username, original_id, created_at, visibility, type) VALUES (?,?,?,?,?,?,?,?)`)
          .run(shortId, actorId, content, username, longId, note.published || new Date().toISOString(), visibility, note.type || 'Note');
          
... ...
}
... ...
}

โœ”๏ธ app/usersui/[username]/_components/themes/pinafore/PinaforeTheme.tsx

— ๊ธ€์“ฐ๊ธฐ ํผ ์ ์šฉ / Apply writing form

   
... ...
export function PinaforeTheme({ timeline : initialTimeline, username, initialView, onBoost, onLike }: any) {  

... ...     
  // โœ… myapp48 - writing status
  const [composeText, setComposeText] = useState('');
  const [composeVis, setComposeVis] = useState<'public'|'unlisted'|'private'>('public');
  const [posting, setPosting] = useState(false);
... ...
// โœ… myapp48 : write post
const handlePost = async () => {
  if (!composeText.trim() || posting) return;
  setPosting(true);
  try {
    // ํ† ํฐ ์žˆ์œผ๋ฉด ํ—ค๋”์— ๋„ฃ๊ณ  ์—†์œผ๋ฉด ์ฟ ํ‚ค ์ธ์ฆ์œผ๋กœ!
    const token = localStorage.getItem('access_token') || localStorage.getItem('token') || '';
    const res = await fetch('/api/v1/statuses', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
         ...(token? { Authorization: `Bearer ${token}` } : {})
      },
      credentials: 'include',
      body: JSON.stringify({ status: composeText, visibility: composeVis })
    });
    if (res.ok) {
      const newPost = await res.json();
      setComposeText('');
      // ํƒ€์ž„๋ผ์ธ ๋งจ ์œ„์— ์ฆ‰์‹œ ์ถ”๊ฐ€!
      setTimeline((prev: any[]) => [{
        id: newPost.id, content: newPost.content, actor: `https://${DOMAIN}/users/${username}`,
        username: username, created_at: new Date().toISOString(), source: 'local', isMine: true, visibility: composeVis
      },...prev]);
      setCounts(c => ({...c, posts: c.posts + 1}));
    } else alert('๊ฒŒ์‹œ ์‹คํŒจ');
    } catch (e) { console.error(e); alert('๊ฒŒ์‹œ ์‹คํŒจ'); }
  setPosting(false);
};
... ...
return (
<>
<style>{`
... ...
       /* โœ… myapp48 - composer(write form) */
       .pinafore-composer { margin-top: auto; background: white; border: 1px solid #e6ecf0; border-radius: 12px; padding: 12px; box-shadow: 0 4px 12px rgba(0,0,0,0.05); }
       .pinafore-composer textarea { width: 100%; height: 80px; resize: none; border: 1px solid #e6ecf0; border-radius: 8px; padding: 8px; font-size: 14px; outline: none; }
       .pinafore-composer textarea:focus { border-color: #6364ff; }
       .composer-foot { display: flex; justify-content: space-between; align-items: center; margin-top: 8px; }
       .composer-foot select { padding: 4px 6px; font-size: 12px; border-radius: 6px; }
       .composer-foot button { background: #6364ff; color: white; border: none; padding: 6px 16px; border-radius: 20px; font-weight: bold; cursor: pointer; font-size: 13px; }
       .composer-foot button:disabled { opacity: 0.4; cursor: not-allowed; }
... ...
`}</style>
     {/* โœ… myapp48 - ์ขŒ์ธก ํ•˜๋‹จ ๊ธ€์“ฐ๊ธฐ ํผ(๋กœ๊ทธ์ธ ํ›„) / Writing form at the bottom left (after logging in) */}
          {isMe && (
            <div className="pinafore-composer">
              <textarea
                value={composeText}
                onChange={(e) => setComposeText(e.target.value)}
                placeholder="๋ฌด์Šจ ์ผ์ด ์ผ์–ด๋‚˜๊ณ  ์žˆ๋‚˜์š”?"
                maxLength={500}
              />
              <div className="composer-foot">
                <select value={composeVis} onChange={(e) => setComposeVis(e.target.value as any)}>
                  <option value="public">๐ŸŒ ๊ณต๊ฐœ/public</option>
                  <option value="unlisted">๐Ÿ”“ ๋ฏธ๋“ฑ๋ก/unlisted</option>
                  <option value="private">๐Ÿ”’ ํŒ”๋กœ์›Œ๋งŒ/private</option>
                </select>
                <button onClick={handlePost} disabled={posting ||!composeText.trim()}>
                  {posting? '...' : 'submit'}
                </button>
              </div>
            </div>
          )}
... ...

</>
)
}

๐Ÿ“ ์ฝ”๋“œ ์ˆ˜์ • – ํƒ€์ž„๋ผ์ธ์—์„œ visibility์— ๋”ฐ๋ผ ๋ณด์—ฌ์ฃผ๊ธฐ

โœ”๏ธ visibility์— ๋”ฐ๋ผ ๊ตฌ๋ถ„ํ•ด์„œ ๋ณด์—ฌ์ฃผ๋Š” ๋ถ€๋ถ„์€ ์•„๋ž˜์˜ ํŒŒ์ผ ๋ถ€๋ถ„์ž…๋‹ˆ๋‹ค.
The section that displays content based on visibility is the file section below.

— lib/visibility.ts – visibility ํ•จ์ˆ˜ / function

— app/api/posts/route.ts ํ”„๋กœํ•„ ํƒ€์ž„๋ผ์ธ / profile timeline

— app/api/timelines/home/route.ts – ํƒ€์ž„๋ผ์ธ API / Timeline API

๐Ÿ“ ํ…Œ์ŠคํŠธ / Test

โœ”๏ธ user1์œผ๋กœ ๋กœ๊ทธ์ธํ•˜๊ณ  @user1ํŽ˜์ด์ง€ ๋ฐฉ๋ฌธ
Log in as user1 and visit the @user1 page.

user1 Login

โœ”๏ธ user2๋กœ ๋กœ๊ทธ์ธํ•˜๊ณ  @user1ํŽ˜์ด์ง€ ๋ฐฉ๋ฌธ
Log in as user2 and visit @user1’s page.

User2 Login

โœ”๏ธ ๋กœ๊ทธ์•„์›ƒํ•˜๊ณ  @user1ํŽ˜์ด์ง€ ๋ฐฉ๋ฌธ
Log out and visit @user1’s page.

Guest

โœ”๏ธ pinafore – user1@freelifemakers.com์œผ๋กœ ๋กœ๊ทธ์ธ
pinafore – Log in as user1@freelifemakers.com

Pinafore

โœ”๏ธ DB ํ™•์ธ / Check DB

myapp48 % sqlite3 data.sqlite
SQLite version 3.51.0 2025-06-12 13:14:41
Enter ".help" for usage hints.
sqlite> SELECT id,visibility,content FROM posts ORDER BY created_at DESC LIMIT 3
   ...> ;
8ae0380a-6c14-4507-8caf-cf0411264cb6|unlisted|unlisted test
a2435541-db09-4b65-ad08-ae90d84dffdd|private|private test
cd3f6242-9b9b-4000-b648-0040e9dcbf40|public|form test
sqlite> 

โœ”๏ธ ๊ธ€ ์ง์ ‘ ์‚ญ์ œ / Delete post directly

curl -X DELETE "https://aloy-horizon.duckdns.org/api/cleanup-orphan?id=cd3f6242-9b9b-4000-b648-0040e9dcbf40&username=user1"

์š”ํ•œ๋ณต์Œ 8์žฅ 32์ ˆ / John 8:32

“๊ทธ๋ฆฌ๊ณ  ๋„ˆํฌ๋Š” ์ง„๋ฆฌ๋ฅผ ์•Œ๊ฒŒ ๋  ๊ฒƒ์ด๋ฉฐ, ์ง„๋ฆฌ๊ฐ€ ๋„ˆํฌ๋ฅผ ์ž์œ ๋กญ๊ฒŒ ํ•  ๊ฒƒ์ด๋‹ค.”

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

Leave a Reply