[nextjs]SNS Server-11(myapp21)

πŸ‘‰πŸ» @user1@aloy-horizon.duckdns.org의 follows에 같은 μ‚¬μš©μžκ°€ λ‘λ²ˆ μž…λ ₯λ˜λŠ” 문제λ₯Ό μˆ˜μ •ν•©λ‹ˆλ‹€.
Fixes an issue where the same user is entered twice in the ‘follows’ list for @user1@aloy-horizon.duckdns.org.

πŸ‘‰πŸ» followμš”μ²­μ‹œ followingκ³Ό followersν…Œμ΄λΈ”μ— κΈ°μ‘΄ μ‚¬μš©μž 있으면 μš”μ²­μ„ 보내지 μ•ŠμŠ΅λ‹ˆλ‹€.
When a follow request is made, the request is not sent if the user already exists in the ‘following’ or ‘followers’ tables.

πŸ“ 전체 ν”„λ‘œμ νŠΈ ꡬ쑰 / Overall Project Structure

myapp21/  
β”œβ”€β”€ app/  (Next.js App Router)
β”‚   β”œβ”€β”€ .well-known/webfinger/route.ts  -> webfinger
β”‚   β”œβ”€β”€ api/posts/route.ts     -> Writing API
β”‚   β”œβ”€β”€ api/follow/route.ts    -> Follow API(temporary)
β”‚   β”œβ”€β”€ api/timeline/route.ts  -> Timeline API
β”‚   β”œβ”€β”€ 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
β”‚   β”œβ”€β”€ layout.tsx, page.tsx, globals.css
β”‚   └── favicon.ico
β”œβ”€β”€ lib/
β”‚   β”œβ”€β”€ ap.ts                  -> Follow Accept
β”‚   └── db.ts                  -> DB connection
β”œβ”€β”€ data/
β”‚   └── keys/                  -> private.pem, public.pem
β”œβ”€β”€ data.sqlite                -> Database
β”œβ”€β”€ Caddyfile                  -> https 
└── package.json

πŸ“ ν”„λ‘œμ νŠΈ μ‹œμž‘(myapp21)
Project Start (myapp21)

npx create-next-app@latest

πŸ“ SQLiteμ„€μΉ˜ / Installing SQLite

cd ~/myapp20
npm install better-sqlite3
npm install -D @types/better-sqlite3

πŸ“ DDNS,httpsμ„€μ • / DDNS,https settings

πŸ“ λ„λ©”μΈν—ˆμš©, @userid μ‚¬μš©ν•˜κΈ°
Allowing domains, using @userid

— next.config.ts

import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  /* allow domain*/
  allowedDevOrigins: ['aloy-horizon.duckdns.org', '*.duckdns.org'],

  /* host.domain.org/@userid */
  async rewrites() {
    return [
      {
        source: '/@:username',
        destination: '/usersui/:username',
      },
    ]
  }
};


export default nextConfig;

πŸ“ Following 처리 λΆ€λΆ„ μˆ˜μ • / Modification to the ‘Following’ processing logic

βœ”οΈ @user1@aloy-horizon.duckdns.org κ³„μ •μ—μ„œ followersκ°€ 쀑볡 μš”μ²­ ν•˜μ§€ μ•Šλ„λ‘ν•©λ‹ˆλ‹€.
Prevent duplicate requests from followers for the @user1@aloy-horizon.duckdns.org account.

— μœ λ‹ˆν¬ 인덱슀 μΆ”κ°€(sqlite) / Add Unique Index (SQLite)

CREATE UNIQUE INDEX IF NOT EXISTS idx_following_actor_username ON following(actor, username);

— myapp21/app/api/follow/route.ts

... ...

export async function POST(req: Request) {

 ... ...

    // myapp21 βœ…
    // 이미 νŒ”λ‘œμž‰ 쀑이면 λ„€νŠΈμ›Œν¬ μš”μ²­μ„ 보내지 μ•ŠμŒ
    // Do not send a network request if already following
    const already = db.prepare('SELECT id FROM following WHERE actor = ? AND username = ?').get(target, username) as any;
    if (already) {
      console.log(`ℹ️ [${username}] 이미 νŒ”λ‘œμž‰ 쀑 / Already following - μš”μ²­ μŠ€ν‚΅ / Skip request: ${target}`);
      return Response.json({ 
        ok: true, 
        alreadyFollowing: true, 
        target, 
        username,
        message: '이미 νŒ”λ‘œμž‰ 쀑 / Already following'
      });

... ...

}  
  
... ...

  // myapp21 βœ… 
  // λ°μ΄ν„°λ² μ΄μŠ€μ— 쀑볡 μž…λ ₯ λ°©μ§€ / Preventing duplicate entries in the database
    if (result.ok) {
      try {
        db.prepare('INSERT INTO following (id, actor, username) VALUES (?, ?, ?)')
          .run(result.followDoc.id, target, username);
        console.log(`βœ… DB μ €μž₯ / DB Save: ${username} -> ${target}`);
      } catch (e: any) {
        if (e.code === 'SQLITE_CONSTRAINT_UNIQUE') {
          console.log(`ℹ️ 이미 μ €μž₯됨  / Already saved: ${target}`);
        } else {
          console.error('DB μ €μž₯ μ‹€νŒ¨ / DB save failed:', e);
        }
      }
    }

... ...

}

... ...

export async function DELETE(req: Request) {

  ... ...

    // βœ… myapp21 - μ–ΈνŒ”λ‘œμš° 쀑볡 체크 / 
    const exists = db.prepare('SELECT id FROM following WHERE actor = ? AND username = ?').get(target, username) as any;
    if (!exists) {
      console.log(`ℹ️ [${username}] νŒ”λ‘œμž‰ 쀑 μ•„λ‹˜ / Not Following - μ–ΈνŒ” μŠ€ν‚΅ /  Skip Unfollowing: ${target}`);
      return Response.json({ ok: true, alreadyNotFollowing: true, message: '이미 μ–ΈνŒ” μƒνƒœ / Already unfollowed.' });
    }

... ...

}

— myapp21/lib/db.ts

db.exec(`
  CREATE INDEX IF NOT EXISTS idx_followers_username ON followers(username);
  CREATE INDEX IF NOT EXISTS idx_followers_actor ON followers(actor);
  CREATE INDEX IF NOT EXISTS idx_following_username ON following(username);
  CREATE INDEX IF NOT EXISTS idx_following_actor ON following(actor);
  CREATE INDEX IF NOT EXISTS idx_posts_username_created ON posts(username, created_at DESC);
  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 UNIQUE INDEX IF NOT EXISTS idx_following_actor_username ON following(actor, username);
`);

πŸ“ Follower 처리 λΆ€λΆ„ μˆ˜μ • / Modified the follower handling logic.

— μœ λ‹ˆν¬ 인덱슀 μΆ”κ°€ / Add unique index (sqlite)

CREATE UNIQUE INDEX idx_followers_actor_username ON followers(actor, username);

— myapp21/app/users/[username]/inbox/route.ts

      
... ...

// βœ… myapp21 
// λ°μ΄ν„°λ² μ΄μŠ€μ— 쀑볡 μž…λ ₯ λ°©μ§€ / Preventing duplicate entries in the database      

const exists = db.prepare('SELECT 1 FROM followers WHERE actor = ? AND username = ?').get(actorId, username) as any;
      
      if (exists) {
        console.log(`ℹ️ [${username}] 이미 νŒ”λ‘œμ›Œ / Already follower: ${actorId} - 쀑볡 λ¬΄μ‹œ/Ignore duplicates`);
      } else {
        db.prepare('INSERT INTO followers (id, actor, inbox, username) VALUES (?,?,?,?)')
        .run(body.id, actorId, inboxUrl, username);
        console.log(`βœ… [${username}] νŒ”λ‘œμš° μ €μž₯ / follow save : ${actorId}`);
      }

— myapp21/lib/db.ts

CREATE UNIQUE INDEX IF NOT EXISTS idx_following_actor_username ON following(actor, username);
CREATE UNIQUE INDEX idx_followers_actor_username ON followers(actor, username);

πŸ“ gotosocial DB(docker compose)

βœ”οΈμ•„λž˜μ˜ κ³Όμ •μœΌλ‘œ κΈ°μ‘΄ DBλ₯Ό μ΄ˆκΈ°ν™” ν•  수 μžˆμŠ΅λ‹ˆλ‹€.
You can initialize the existing database by following the steps below.


# DB λ“€μ–΄κ°€κΈ° / Accessing the DB
sqlite3 sqlite.db

SELECT username, domain, followers_count, following_count FROM accounts WHERE domain='aloy-horizon.duckdns.org';

# μ‚­μ œ / delete
DELETE FROM accounts WHERE domain='aloy-horizon.duckdns.org' AND username='user1';

.quit

# μ„œλ²„ μž¬μ‹œμž‘ / Server restart
docker compose restart gotosocial
docker compose logs -f gotosocial

βœ”οΈ sqliteμ—μ„œ 였λ₯˜κ°€ μžˆλŠ” 데이터 μ‚­μ œ

— μ•„λž˜μ²˜λŸΌ followers,following,tootsλ₯Ό λͺ¨λ‘ 0으둜 μ΄ˆκΈ°ν™” μ‹œν‚΅λ‹ˆλ‹€.


sqlite> SELECT id, username, domain, uri FROM accounts WHERE domain='aloy-horizon.duckdns.org';
01KZWNMF7BFD5N6MAYV3068C81|user1|aloy-horizon.duckdns.org|https://aloy-horizon.duckdns.org/users/user1

sqlite> SELECT * FROM account_stats WHERE account_id='01KZWNMF7BFD5N6MAYV3068C81';
01KZWNMF7BFD5N6MAYV3068C81|2026-06-10 01:20:08.931+00:00|0|1|0|2|0|

-- follows ν…Œμ΄λΈ” 확인 / Check the 'follows' table.
sqlite>SELECT * FROM follows WHERE account_id='01KZWNMF7BFD5N6MAYV3068C81' OR target_account_id='01KZWNMF7BFD5N6MAYV3068C81';

sqlite>DELETE FROM account_stats WHERE account_id='01KZWNMF7BFD5N6MAYV3068C81';
sqlite>DELETE FROM accounts WHERE id='01KZWNMF7BFD5N6MAYV3068C81';

-- followsλŠ” 이미 λΉ„μ–΄μžˆμŒ / 'follows' is already empty.
-- toots 였λ₯˜ μžˆμ–΄μ„œ λͺ¨λ‘ μ‚­μ œ / Deleted everything due to errors with Toots.

sqlite> SELECT id FROM accounts WHERE domain='aloy-horizon.duckdns.org';
01M0RKD5BF8JEGA7HN2A66Z54C

sqlite> SELECT COUNT(*) FROM statuses WHERE account_id='01M0RKD5BF8JEGA7HN2A66Z54C';
0

sqlite> SELECT COUNT(*) FROM statuses WHERE account_id='01KZWNMF7BFD5N6MAYV3068C81';
3

sqlite> DELETE FROM statuses WHERE account_id='01KZWNMF7BFD5N6MAYV3068C81';
sqlite> DELETE FROM account_stats WHERE account_id='01KZWNMF7BFD5N6MAYV3068C81';
sqlite> DELETE FROM accounts WHERE id='01KZWNMF7BFD5N6MAYV3068C81';

sqlite> SELECT id, username, domain FROM accounts WHERE domain='aloy-horizon.duckdns.org';
01M0RKD5BF8JEGA7HN2A66Z54C|user1|aloy-horizon.duckdns.org

sqlite> DELETE FROM statuses WHERE account_id='01M0RKD5BF8JEGA7HN2A66Z54C';
sqlite> DELETE FROM account_stats WHERE account_id='01M0RKD5BF8JEGA7HN2A66Z54C';
sqlite> DELETE FROM accounts WHERE id='01M0RKD5BF8JEGA7HN2A66Z54C';
sqlite> DELETE FROM statuses WHERE id IN (SELECT id FROM statuses WHERE account_id NOT IN (SELECT id FROM accounts));
sqlite> .quit

-- 도컀 컴포즈λ₯Ό μž¬μ‹œμž‘ν•©λ‹ˆλ‹€.
docker compose down
docker compose up -d

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

βœ”οΈ ν„°λ―Έλ„μ—μ„œ μ•„λž˜μ˜ 라우트둜 ν…ŒμŠ€νŠΈ ν•  수 μžˆμŠ΅λ‹ˆλ‹€.
You can test using the route below in the terminal.

# νŒ”λ‘œμš° (following 1둜 증가)
# Follow (following increased to 1)

curl -X POST https://aloy-horizon.duckdns.org/api/follow \
  -H "Content-Type: application/json" \
  -d '{"username":"user1","target":"https://freelifemakers.com/users/user1"}'

# μ–ΈνŒ”λ‘œμš° (following 0으둜 κ°μ†Œ)
# Unfollow (following count reduced to 0)

curl -X DELETE https://aloy-horizon.duckdns.org/api/follow \
  -H "Content-Type: application/json" \
  -d '{"username":"user1","target":"https://freelifemakers.com/users/user1"}'

# κΈ€ μ“°κΈ° (outbox 7둜 증가 + νŒ”λ‘œμ›Œλ“€μ—κ²Œ 배달)
# Writing a post (added to outbox 7 + delivered to followers)

curl -X POST https://aloy-horizon.duckdns.org/api/posts \
  -H "Content-Type: application/json" \
  -d '{"username":"user1","content":"Hello Follow! #test"}'

βœ”οΈ νŒ”λ‘œμš° ν…ŒμŠ€νŠΈ / Follow Test

–νŒ”λ‘œμš° μš”μ²­μ„ λ‘λ²ˆν•˜λ©΄ μš”μ²­μ„ 보내지 μ•ŠμŠ΅λ‹ˆλ‹€.
If you send a follow request twice, the request will not be sent.

 ~ % curl -X POST https://aloy-horizon.duckdns.org/api/follow \
  -H "Content-Type: application/json" \
  -d '{"username":"user1","target":"https://freelifemakers.com/users/user1"}'
{"ok":true,"inbox":"https://freelifemakers.com/users/user1/inbox","target":"https://freelifemakers.com/users/user1","username":"user1","result":"{\"status\":\"Accepted\"}","follow":{"@context":"https://www.w3.org/ns/activitystreams","id":"https://aloy-horizon.duckdns.org/users/user1/follows/1787534744543","type":"Follow","actor":"https://aloy-horizon.duckdns.org/users/user1","object":"https://freelifemakers.com/users/user1"}}%                                                                                                                       ~ % curl -X POST https://aloy-horizon.duckdns.org/api/follow \
  -H "Content-Type: application/json" \
  -d '{"username":"user1","target":"https://freelifemakers.com/users/user1"}'
{"ok":true,"alreadyFollowing":true,"target":"https://freelifemakers.com/users/user1","username":"user1","message":"이미 νŒ”λ‘œμž‰ 쀑 / Already following"}%                                                                                                                             
~ % 

βœ”οΈ κΈ€ 배달 확인 / Confirming post delivery

https://aloy-horizon.duckdns.org/@user1
Timeline UI

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

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

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

Leave a Reply