[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