@ryopc/koshi 0.2.0 โ 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +37 -3
- package/bin/cli.js +405 -3
- package/bin/server.js +19 -2
- package/package.json +3 -3
- package/src/auth/ed25519.js +6 -2
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# ๐ koshi โ Terminal-Native Decentralized SNS
|
|
2
2
|
|
|
3
|
-
**Version
|
|
3
|
+
**Version 1.1.0** ยท MIT License ยท by [game_ryo](https://github.com/ryopc)
|
|
4
4
|
|
|
5
5
|
> A terminal-native, decentralized social network powered by ed25519 cryptography.
|
|
6
6
|
|
|
@@ -12,7 +12,10 @@
|
|
|
12
12
|
- **๐ Posts** โ Create and view posts on the koshi board
|
|
13
13
|
- **๐ฅ Follow System** โ Follow/unfollow other users
|
|
14
14
|
- **โ๏ธ Direct Messages** โ Signed, private DMs
|
|
15
|
-
-
|
|
15
|
+
- **๐ฌ Real-time Chat** โ Interactive DM chat via WebSocket
|
|
16
|
+
- **๐ก Real-time Feed** โ WebSocket-powered live updates
|
|
17
|
+
- **โ๏ธ Profile Editing** โ Update your display name, bio, and avatar
|
|
18
|
+
- **๐ ๏ธ Admin Controls** โ User management, account deletion, admin grants
|
|
16
19
|
- **๐ป Terminal-native** โ Beautiful CLI with chalk colors and spinners
|
|
17
20
|
- **๐ณ Docker-ready** โ Containerized for easy deployment
|
|
18
21
|
- **โ๏ธ Render.com** โ One-click deploy to Render.com
|
|
@@ -96,9 +99,12 @@ The `kb` command is your gateway to the koshi board.
|
|
|
96
99
|
| `kb unfollow <username>` | Unfollow a user |
|
|
97
100
|
| `kb dm <username> <message>` | Send a direct message |
|
|
98
101
|
| `kb dms [--unread]` | View your direct messages |
|
|
102
|
+
| `kb chat <username>` | Start an interactive real-time DM chat |
|
|
103
|
+
| `kb edit-profile --display-name=... --bio=...` | Update your own profile |
|
|
99
104
|
| `kb profile [username]` | View a user profile |
|
|
100
105
|
| `kb search <query>` | Search users by username |
|
|
101
106
|
| `kb realtime` | Connect to the real-time event stream |
|
|
107
|
+
| `kb admin <command>` | Admin commands (users, delete-user, grant, revoke) |
|
|
102
108
|
| `kb help [command]` | Show help |
|
|
103
109
|
|
|
104
110
|
### Examples
|
|
@@ -122,11 +128,26 @@ kb follow bob
|
|
|
122
128
|
# Send a DM
|
|
123
129
|
kb dm bob "Hey, how's it going?"
|
|
124
130
|
|
|
131
|
+
# Start an interactive real-time DM chat
|
|
132
|
+
kb chat bob
|
|
133
|
+
|
|
134
|
+
# Edit your profile
|
|
135
|
+
kb edit-profile --display-name="Alice" --bio="Building the terminal future"
|
|
136
|
+
|
|
125
137
|
# Search for users
|
|
126
138
|
kb search alice
|
|
127
139
|
|
|
128
140
|
# Live stream
|
|
129
141
|
kb realtime
|
|
142
|
+
|
|
143
|
+
# Admin: list all users
|
|
144
|
+
kb admin users
|
|
145
|
+
|
|
146
|
+
# Admin: delete a user account (requires confirmation)
|
|
147
|
+
kb admin delete-user bob
|
|
148
|
+
|
|
149
|
+
# Admin: grant admin privileges
|
|
150
|
+
kb admin grant alice
|
|
130
151
|
```
|
|
131
152
|
|
|
132
153
|
### Configuration
|
|
@@ -240,6 +261,17 @@ Authorization: Bearer <token>
|
|
|
240
261
|
| PUT | `/api/dms/:id/read` | Yes | Mark DM as read |
|
|
241
262
|
| GET | `/api/dms/unread/count` | Yes | Count unread DMs |
|
|
242
263
|
|
|
264
|
+
#### Admin
|
|
265
|
+
|
|
266
|
+
| Method | Path | Auth | Description |
|
|
267
|
+
|--------|------|------|-------------|
|
|
268
|
+
| GET | `/api/admin/users` | Admin | List all users |
|
|
269
|
+
| GET | `/api/admin/users/:id` | Admin | Get user details (with keys, DM count) |
|
|
270
|
+
| DELETE | `/api/admin/users/:id` | Admin | Permanently delete a user account |
|
|
271
|
+
| PUT | `/api/admin/users/:id/admin` | Admin | Grant or revoke admin privileges |
|
|
272
|
+
|
|
273
|
+
> Admin privileges: Set `ADMIN_USERNAME` environment variable, or mark user as admin via `UPDATE users SET is_admin = TRUE WHERE username = '...';`
|
|
274
|
+
|
|
243
275
|
#### WebSocket
|
|
244
276
|
|
|
245
277
|
Connect: `ws://host:port/ws?token={jwt}`
|
|
@@ -339,6 +371,7 @@ volumes:
|
|
|
339
371
|
|
|
340
372
|
- **Ed25519 Signatures** โ All posts and DMs are signed for authenticity
|
|
341
373
|
- **JWT Auth** โ Tokens expire after 24 hours
|
|
374
|
+
- **Admin Auth** โ Admin-only endpoints protected by `requireAdmin` middleware
|
|
342
375
|
- **Rate Limiting** โ Auth endpoints limited to 10 req/min per IP
|
|
343
376
|
- **SQL Injection Prevention** โ Parameterized queries throughout
|
|
344
377
|
- **Input Validation** โ All endpoints validate input
|
|
@@ -376,7 +409,8 @@ koshi/
|
|
|
376
409
|
โ โ โโโ auth.js # Auth routes (register/login)
|
|
377
410
|
โ โ โโโ users.js # User management routes
|
|
378
411
|
โ โ โโโ posts.js # Posts routes (koshi board)
|
|
379
|
-
โ โ
|
|
412
|
+
โ โ โโโ dms.js # Direct messages routes
|
|
413
|
+
โ โ โโโ admin.js # Admin routes (users, delete, grant)
|
|
380
414
|
โ โโโ auth/
|
|
381
415
|
โ โ โโโ ed25519.js # Ed25519 crypto utilities
|
|
382
416
|
โ โ โโโ jwt.js # JWT token utilities
|
package/bin/cli.js
CHANGED
|
@@ -521,6 +521,56 @@ async function cmdProfile(args) {
|
|
|
521
521
|
}
|
|
522
522
|
}
|
|
523
523
|
|
|
524
|
+
// ============================================================================
|
|
525
|
+
// Command: edit-profile
|
|
526
|
+
// ============================================================================
|
|
527
|
+
async function cmdEditProfile(args) {
|
|
528
|
+
const config = loadConfig();
|
|
529
|
+
if (!config.token) {
|
|
530
|
+
console.error(chalk.red('โ Not logged in.'));
|
|
531
|
+
process.exit(1);
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
// Parse flags
|
|
535
|
+
let displayName, bio, avatarUrl;
|
|
536
|
+
for (const arg of args) {
|
|
537
|
+
if (arg.startsWith('--display-name=')) {
|
|
538
|
+
displayName = arg.slice('--display-name='.length);
|
|
539
|
+
} else if (arg.startsWith('--bio=')) {
|
|
540
|
+
bio = arg.slice('--bio='.length);
|
|
541
|
+
} else if (arg.startsWith('--avatar-url=')) {
|
|
542
|
+
avatarUrl = arg.slice('--avatar-url='.length);
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
if (!displayName && !bio && !avatarUrl) {
|
|
547
|
+
console.error(chalk.red('โ Error: Provide at least one field to update.'));
|
|
548
|
+
console.error(chalk.dim(' Usage: kb edit-profile --display-name="My Name" --bio="Hello!" --avatar-url="https://..."'));
|
|
549
|
+
process.exit(1);
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
const spinner = ora('Updating profile...').start();
|
|
553
|
+
|
|
554
|
+
try {
|
|
555
|
+
const body = {};
|
|
556
|
+
if (displayName !== undefined) body.displayName = displayName.trim();
|
|
557
|
+
if (bio !== undefined) body.bio = bio.trim();
|
|
558
|
+
if (avatarUrl !== undefined) body.avatarUrl = avatarUrl;
|
|
559
|
+
|
|
560
|
+
const data = await apiRequest('PUT', '/api/users/me', body, config.token);
|
|
561
|
+
|
|
562
|
+
spinner.succeed(chalk.green('Profile updated!'));
|
|
563
|
+
|
|
564
|
+
console.log(`\n ${chalk.bold('Username:')} ${chalk.cyan(data.username)}`);
|
|
565
|
+
if (data.displayName) console.log(` ${chalk.bold('Display Name:')} ${data.displayName}`);
|
|
566
|
+
if (data.bio) console.log(` ${chalk.bold('Bio:')} ${data.bio}`);
|
|
567
|
+
if (data.avatarUrl) console.log(` ${chalk.bold('Avatar:')} ${chalk.dim(data.avatarUrl)}`);
|
|
568
|
+
} catch (err) {
|
|
569
|
+
spinner.fail(chalk.red(`Failed to update profile: ${err.message}`));
|
|
570
|
+
process.exit(1);
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
|
|
524
574
|
// ============================================================================
|
|
525
575
|
// Command: search
|
|
526
576
|
// ============================================================================
|
|
@@ -600,12 +650,28 @@ function showHelp(command = null) {
|
|
|
600
650
|
},
|
|
601
651
|
profile: {
|
|
602
652
|
usage: 'kb profile [username]',
|
|
603
|
-
desc: 'View a user profile
|
|
653
|
+
desc: 'View a user profile',
|
|
654
|
+
},
|
|
655
|
+
'edit-profile': {
|
|
656
|
+
usage: 'kb edit-profile --display-name=... --bio=... --avatar-url=...',
|
|
657
|
+
desc: 'Update your own profile',
|
|
604
658
|
},
|
|
605
659
|
search: {
|
|
606
660
|
usage: 'kb search <query>',
|
|
607
661
|
desc: 'Search users by username or display name',
|
|
608
662
|
},
|
|
663
|
+
chat: {
|
|
664
|
+
usage: 'kb chat <username>',
|
|
665
|
+
desc: 'Real-time interactive DM chat',
|
|
666
|
+
},
|
|
667
|
+
realtime: {
|
|
668
|
+
usage: 'kb realtime',
|
|
669
|
+
desc: 'Stream live posts and events',
|
|
670
|
+
},
|
|
671
|
+
admin: {
|
|
672
|
+
usage: 'kb admin <command>',
|
|
673
|
+
desc: 'Admin: users, delete-user, grant, revoke',
|
|
674
|
+
},
|
|
609
675
|
help: {
|
|
610
676
|
usage: 'kb help [command]',
|
|
611
677
|
desc: 'Show this help message',
|
|
@@ -623,7 +689,7 @@ function showHelp(command = null) {
|
|
|
623
689
|
}
|
|
624
690
|
|
|
625
691
|
console.log(`\n ${chalk.bold.cyan('๐ koshi โ Terminal-Native Decentralized SNS')}`);
|
|
626
|
-
console.log(` ${chalk.dim('Version
|
|
692
|
+
console.log(` ${chalk.dim('Version 1.1.0')}`);
|
|
627
693
|
console.log(`\n ${chalk.bold('Usage:')} kb <command> [options]\n`);
|
|
628
694
|
console.log(` ${chalk.bold('Commands:')}\n`);
|
|
629
695
|
|
|
@@ -647,12 +713,336 @@ function showHelp(command = null) {
|
|
|
647
713
|
// Command: version
|
|
648
714
|
// ============================================================================
|
|
649
715
|
function showVersion() {
|
|
650
|
-
console.log('koshi
|
|
716
|
+
console.log('koshi v1.1.0');
|
|
651
717
|
console.log('Terminal-native decentralized SNS');
|
|
652
718
|
console.log('License: MIT');
|
|
653
719
|
console.log('Author: game_ryo');
|
|
654
720
|
}
|
|
655
721
|
|
|
722
|
+
// ============================================================================
|
|
723
|
+
// Command: admin
|
|
724
|
+
// ============================================================================
|
|
725
|
+
async function cmdAdmin(args) {
|
|
726
|
+
const config = loadConfig();
|
|
727
|
+
if (!config.token) {
|
|
728
|
+
console.error(chalk.red('โ Not logged in. Admin commands require authentication.'));
|
|
729
|
+
process.exit(1);
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
const subcommand = args[0];
|
|
733
|
+
|
|
734
|
+
if (!subcommand || subcommand === 'help') {
|
|
735
|
+
console.log(`\n ${chalk.bold.cyan('๐ ๏ธ Admin Commands')}`);
|
|
736
|
+
console.log(` ${chalk.dim('โ'.repeat(50))}`);
|
|
737
|
+
console.log(` ${chalk.cyan('kb admin users')} List all registered users`);
|
|
738
|
+
console.log(` ${chalk.cyan('kb admin user <id>')} View detailed user info`);
|
|
739
|
+
console.log(` ${chalk.cyan('kb admin delete-user <username>')} Permanently delete a user account`);
|
|
740
|
+
console.log(` ${chalk.cyan('kb admin grant <username>')} Grant admin privileges`);
|
|
741
|
+
console.log(` ${chalk.cyan('kb admin revoke <username>')} Revoke admin privileges`);
|
|
742
|
+
console.log(` ${chalk.dim('โ'.repeat(50))}\n`);
|
|
743
|
+
return;
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
switch (subcommand) {
|
|
747
|
+
case 'users':
|
|
748
|
+
await cmdAdminUsers(config);
|
|
749
|
+
break;
|
|
750
|
+
case 'user':
|
|
751
|
+
await cmdAdminUserDetail(config, args.slice(1));
|
|
752
|
+
break;
|
|
753
|
+
case 'delete-user':
|
|
754
|
+
await cmdAdminDeleteUser(config, args.slice(1));
|
|
755
|
+
break;
|
|
756
|
+
case 'grant':
|
|
757
|
+
await cmdAdminSetAdmin(config, args.slice(1), true);
|
|
758
|
+
break;
|
|
759
|
+
case 'revoke':
|
|
760
|
+
await cmdAdminSetAdmin(config, args.slice(1), false);
|
|
761
|
+
break;
|
|
762
|
+
default:
|
|
763
|
+
console.error(chalk.red(`โ Unknown admin command: "${subcommand}"`));
|
|
764
|
+
process.exit(1);
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
async function cmdAdminUsers(config) {
|
|
769
|
+
const spinner = ora('Fetching user list...').start();
|
|
770
|
+
try {
|
|
771
|
+
const data = await apiRequest('GET', '/api/admin/users', null, config.token);
|
|
772
|
+
spinner.stop();
|
|
773
|
+
|
|
774
|
+
console.log(`\n ${chalk.bold.cyan(`๐ฅ All Users (${data.total} total)`)}`);
|
|
775
|
+
console.log(` ${chalk.dim('โ'.repeat(70))}`);
|
|
776
|
+
|
|
777
|
+
for (const user of data.users) {
|
|
778
|
+
const adminBadge = user.isAdmin ? chalk.yellow(' [ADMIN]') : '';
|
|
779
|
+
const display = user.displayName || '(no display name)';
|
|
780
|
+
console.log(` ${chalk.bold(user.username)}${adminBadge} ${chalk.dim(`โ ${display}`)}`);
|
|
781
|
+
console.log(` ${chalk.dim(`ID: ${user.id} | Posts: ${user.postsCount} | Followers: ${user.followersCount} | Joined: ${new Date(user.createdAt).toLocaleDateString()}`)}`);
|
|
782
|
+
console.log();
|
|
783
|
+
}
|
|
784
|
+
} catch (err) {
|
|
785
|
+
spinner.fail(chalk.red(`Failed to list users: ${err.message}`));
|
|
786
|
+
process.exit(1);
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
async function cmdAdminUserDetail(config, args) {
|
|
791
|
+
const userId = args[0];
|
|
792
|
+
if (!userId) {
|
|
793
|
+
console.error(chalk.red('โ Error: User ID is required. Usage: kb admin user <id>'));
|
|
794
|
+
process.exit(1);
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
const spinner = ora('Fetching user details...').start();
|
|
798
|
+
try {
|
|
799
|
+
const data = await apiRequest('GET', `/api/admin/users/${userId}`, null, config.token);
|
|
800
|
+
spinner.stop();
|
|
801
|
+
|
|
802
|
+
console.log(`\n ${chalk.bold.cyan('๐ User Details')}`);
|
|
803
|
+
console.log(` ${chalk.dim('โ'.repeat(50))}`);
|
|
804
|
+
console.log(` ${chalk.bold('Username:')} ${chalk.cyan(data.username)}`);
|
|
805
|
+
console.log(` ${chalk.bold('ID:')} ${chalk.dim(data.id)}`);
|
|
806
|
+
console.log(` ${chalk.bold('Public Key:')} ${chalk.dim(data.publicKey ? data.publicKey.substring(0, 32) + '...' : 'N/A')}`);
|
|
807
|
+
if (data.displayName) console.log(` ${chalk.bold('Display Name:')} ${data.displayName}`);
|
|
808
|
+
if (data.bio) console.log(` ${chalk.bold('Bio:')} ${data.bio}`);
|
|
809
|
+
console.log(` ${chalk.bold('Admin:')} ${data.isAdmin ? chalk.green('โ Yes') : chalk.dim('No')}`);
|
|
810
|
+
console.log(` ${chalk.bold('Posts:')} ${data.postsCount}`);
|
|
811
|
+
console.log(` ${chalk.bold('DMs:')} ${data.dmsCount}`);
|
|
812
|
+
console.log(` ${chalk.bold('Followers:')} ${data.followersCount}`);
|
|
813
|
+
console.log(` ${chalk.bold('Following:')} ${data.followingCount}`);
|
|
814
|
+
console.log(` ${chalk.bold('Joined:')} ${new Date(data.createdAt).toLocaleDateString()}`);
|
|
815
|
+
} catch (err) {
|
|
816
|
+
spinner.fail(chalk.red(`Failed to fetch user: ${err.message}`));
|
|
817
|
+
process.exit(1);
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
async function cmdAdminDeleteUser(config, args) {
|
|
822
|
+
const username = args.find(a => !a.startsWith('--'));
|
|
823
|
+
const force = args.includes('--force');
|
|
824
|
+
|
|
825
|
+
if (!username) {
|
|
826
|
+
console.error(chalk.red('โ Error: Username is required. Usage: kb admin delete-user <username>'));
|
|
827
|
+
process.exit(1);
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
if (!force) {
|
|
831
|
+
// Confirm deletion interactively
|
|
832
|
+
console.log(`\n ${chalk.yellow('โ ๏ธ WARNING: This will permanently delete the user and all their data.')}`);
|
|
833
|
+
console.log(` ${chalk.yellow('This action cannot be undone.')}\n`);
|
|
834
|
+
console.log(` ${chalk.bold('Target:')} @${username}`);
|
|
835
|
+
console.log();
|
|
836
|
+
|
|
837
|
+
const confirmation = await new Promise((resolve) => {
|
|
838
|
+
process.stdout.write(` ${chalk.bold('Type the username to confirm')}: `);
|
|
839
|
+
process.stdin.once('data', (buf) => {
|
|
840
|
+
resolve(buf.toString().trim());
|
|
841
|
+
});
|
|
842
|
+
});
|
|
843
|
+
|
|
844
|
+
if (confirmation !== username) {
|
|
845
|
+
console.log(chalk.yellow('\n โ Confirmation does not match. Aborted.'));
|
|
846
|
+
process.exit(1);
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
const spinner = ora(`Deleting user @${username}...`).start();
|
|
851
|
+
|
|
852
|
+
try {
|
|
853
|
+
// Resolve username to ID first
|
|
854
|
+
const userData = await apiRequest('GET', `/api/users/${username}`, null, config.token);
|
|
855
|
+
|
|
856
|
+
const result = await apiRequest('DELETE', `/api/admin/users/${userData.id}`, {}, config.token);
|
|
857
|
+
|
|
858
|
+
spinner.succeed(chalk.green(`User @${username} has been permanently deleted.`));
|
|
859
|
+
} catch (err) {
|
|
860
|
+
spinner.fail(chalk.red(`Delete failed: ${err.message}`));
|
|
861
|
+
process.exit(1);
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
async function cmdAdminSetAdmin(config, args, makeAdmin) {
|
|
866
|
+
const username = args[0];
|
|
867
|
+
if (!username) {
|
|
868
|
+
const action = makeAdmin ? 'grant' : 'revoke';
|
|
869
|
+
console.error(chalk.red(`โ Error: Username is required. Usage: kb admin ${action} <username>`));
|
|
870
|
+
process.exit(1);
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
const actionLabel = makeAdmin ? 'Granting admin to' : 'Revoking admin from';
|
|
874
|
+
const spinner = ora(`${actionLabel} @${username}...`).start();
|
|
875
|
+
|
|
876
|
+
try {
|
|
877
|
+
const userData = await apiRequest('GET', `/api/users/${username}`, null, config.token);
|
|
878
|
+
|
|
879
|
+
const result = await apiRequest('PUT', `/api/admin/users/${userData.id}/admin`, {
|
|
880
|
+
isAdmin: makeAdmin,
|
|
881
|
+
}, config.token);
|
|
882
|
+
|
|
883
|
+
if (makeAdmin) {
|
|
884
|
+
spinner.succeed(chalk.green(`@${username} is now an admin!`));
|
|
885
|
+
} else {
|
|
886
|
+
spinner.succeed(chalk.yellow(`Admin privileges removed from @${username}.`));
|
|
887
|
+
}
|
|
888
|
+
} catch (err) {
|
|
889
|
+
spinner.fail(chalk.red(`Failed: ${err.message}`));
|
|
890
|
+
process.exit(1);
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
// ============================================================================
|
|
895
|
+
// Command: chat (interactive real-time DM mode)
|
|
896
|
+
// ============================================================================
|
|
897
|
+
async function cmdChat(args) {
|
|
898
|
+
const config = loadConfig();
|
|
899
|
+
if (!config.token || !config.secretKey) {
|
|
900
|
+
console.error(chalk.red('โ Not logged in. Use "kb login" or "kb register" first.'));
|
|
901
|
+
process.exit(1);
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
const targetUsername = args[0];
|
|
905
|
+
if (!targetUsername) {
|
|
906
|
+
console.error(chalk.red('โ Error: Usage: kb chat <username>'));
|
|
907
|
+
process.exit(1);
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
console.log(`\n ${chalk.bold.cyan('๐ฌ Live Chat with')} ${chalk.bold(targetUsername)}`);
|
|
911
|
+
console.log(` ${chalk.dim('Connect to real-time DMs. Type your message and press Enter.')}`);
|
|
912
|
+
console.log(` ${chalk.dim('Type /exit or press Ctrl+C to quit.')}\n`);
|
|
913
|
+
|
|
914
|
+
try {
|
|
915
|
+
// Resolve target user
|
|
916
|
+
const userData = await apiRequest('GET', `/api/users/${targetUsername}`, null, config.token);
|
|
917
|
+
const { default: WebSocket } = await import('ws');
|
|
918
|
+
|
|
919
|
+
// Connect to WebSocket
|
|
920
|
+
const ws = new WebSocket(`${WS_URL}/ws?token=${config.token}`);
|
|
921
|
+
|
|
922
|
+
ws.on('open', () => {
|
|
923
|
+
console.log(chalk.green(' โ Connected!\n'));
|
|
924
|
+
promptForInput();
|
|
925
|
+
});
|
|
926
|
+
|
|
927
|
+
// Handle incoming messages
|
|
928
|
+
ws.on('message', (data) => {
|
|
929
|
+
try {
|
|
930
|
+
const msg = JSON.parse(data.toString());
|
|
931
|
+
|
|
932
|
+
switch (msg.type) {
|
|
933
|
+
case 'connected':
|
|
934
|
+
// Initial connection confirmation
|
|
935
|
+
break;
|
|
936
|
+
|
|
937
|
+
case 'dm_received':
|
|
938
|
+
if (msg.payload.from.username === targetUsername) {
|
|
939
|
+
console.log(`\n ${chalk.bold.green(`${targetUsername}:`)} ${msg.payload.content}`);
|
|
940
|
+
promptForInput();
|
|
941
|
+
}
|
|
942
|
+
break;
|
|
943
|
+
|
|
944
|
+
case 'dm:sent':
|
|
945
|
+
console.log(`\n ${chalk.bold.cyan(`You:`)} ${msg.payload.content}`);
|
|
946
|
+
promptForInput();
|
|
947
|
+
break;
|
|
948
|
+
|
|
949
|
+
case 'error':
|
|
950
|
+
console.log(`\n ${chalk.red(`โ Error: ${msg.payload.message}`)}`);
|
|
951
|
+
promptForInput();
|
|
952
|
+
break;
|
|
953
|
+
|
|
954
|
+
case 'user_online':
|
|
955
|
+
if (msg.payload.username === targetUsername) {
|
|
956
|
+
console.log(`\n ${chalk.green('๐ข')} ${chalk.bold(targetUsername)} ${chalk.dim('is now online')}`);
|
|
957
|
+
}
|
|
958
|
+
break;
|
|
959
|
+
|
|
960
|
+
case 'user_offline':
|
|
961
|
+
if (msg.payload.userId === userData.id) {
|
|
962
|
+
console.log(`\n ${chalk.red('๐ด')} ${chalk.bold(targetUsername)} ${chalk.dim('went offline')}`);
|
|
963
|
+
}
|
|
964
|
+
break;
|
|
965
|
+
|
|
966
|
+
default:
|
|
967
|
+
break;
|
|
968
|
+
}
|
|
969
|
+
} catch {
|
|
970
|
+
// Ignore parse errors
|
|
971
|
+
}
|
|
972
|
+
});
|
|
973
|
+
|
|
974
|
+
ws.on('close', () => {
|
|
975
|
+
console.log(chalk.yellow('\n Disconnected.'));
|
|
976
|
+
process.exit(0);
|
|
977
|
+
});
|
|
978
|
+
|
|
979
|
+
ws.on('error', (err) => {
|
|
980
|
+
console.error(chalk.red(`\n WebSocket error: ${err.message}`));
|
|
981
|
+
process.exit(1);
|
|
982
|
+
});
|
|
983
|
+
|
|
984
|
+
// Handle user input
|
|
985
|
+
let inputBuffer = '';
|
|
986
|
+
function promptForInput() {
|
|
987
|
+
process.stdout.write(chalk.cyan(' > '));
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
process.stdin.setEncoding('utf-8');
|
|
991
|
+
process.stdin.on('data', async (chunk) => {
|
|
992
|
+
inputBuffer += chunk;
|
|
993
|
+
|
|
994
|
+
if (inputBuffer.includes('\n')) {
|
|
995
|
+
const lines = inputBuffer.split('\n');
|
|
996
|
+
inputBuffer = lines.pop(); // Keep incomplete line
|
|
997
|
+
|
|
998
|
+
for (const line of lines) {
|
|
999
|
+
const trimmed = line.trim();
|
|
1000
|
+
|
|
1001
|
+
if (!trimmed) {
|
|
1002
|
+
promptForInput();
|
|
1003
|
+
continue;
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
if (trimmed === '/exit') {
|
|
1007
|
+
console.log(chalk.dim(' Closing connection...'));
|
|
1008
|
+
ws.close();
|
|
1009
|
+
return;
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
// Sign and send the message
|
|
1013
|
+
try {
|
|
1014
|
+
const { signMessage } = await import('../src/auth/ed25519.js');
|
|
1015
|
+
const signature = await signMessage(trimmed, config.secretKey);
|
|
1016
|
+
|
|
1017
|
+
ws.send(JSON.stringify({
|
|
1018
|
+
type: 'dm:send',
|
|
1019
|
+
payload: {
|
|
1020
|
+
recipientId: userData.id,
|
|
1021
|
+
content: trimmed,
|
|
1022
|
+
signature,
|
|
1023
|
+
},
|
|
1024
|
+
}));
|
|
1025
|
+
} catch (err) {
|
|
1026
|
+
console.log(` ${chalk.red(`โ Failed to send: ${err.message}`)}`);
|
|
1027
|
+
promptForInput();
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
1031
|
+
});
|
|
1032
|
+
|
|
1033
|
+
// Graceful shutdown
|
|
1034
|
+
process.on('SIGINT', () => {
|
|
1035
|
+
console.log(chalk.dim('\n Closing connection...'));
|
|
1036
|
+
ws.close();
|
|
1037
|
+
process.exit(0);
|
|
1038
|
+
});
|
|
1039
|
+
|
|
1040
|
+
} catch (err) {
|
|
1041
|
+
console.error(chalk.red(`Failed to start chat: ${err.message}`));
|
|
1042
|
+
process.exit(1);
|
|
1043
|
+
}
|
|
1044
|
+
}
|
|
1045
|
+
|
|
656
1046
|
// ============================================================================
|
|
657
1047
|
// Command: realtime feed (stream mode)
|
|
658
1048
|
// ============================================================================
|
|
@@ -802,6 +1192,18 @@ async function main() {
|
|
|
802
1192
|
await cmdSearch(args.slice(1));
|
|
803
1193
|
break;
|
|
804
1194
|
|
|
1195
|
+
case 'edit-profile':
|
|
1196
|
+
await cmdEditProfile(args.slice(1));
|
|
1197
|
+
break;
|
|
1198
|
+
|
|
1199
|
+
case 'chat':
|
|
1200
|
+
await cmdChat(args.slice(1));
|
|
1201
|
+
break;
|
|
1202
|
+
|
|
1203
|
+
case 'admin':
|
|
1204
|
+
await cmdAdmin(args.slice(1));
|
|
1205
|
+
break;
|
|
1206
|
+
|
|
805
1207
|
case 'realtime':
|
|
806
1208
|
case 'stream':
|
|
807
1209
|
await cmdRealtime();
|
package/bin/server.js
CHANGED
|
@@ -23,6 +23,7 @@ import { app, logger } from '../src/index.js';
|
|
|
23
23
|
import { initWebSocket, startHeartbeat } from '../src/ws/index.js';
|
|
24
24
|
import { closePool } from '../src/db/pool.js';
|
|
25
25
|
import { getOnlineCount } from '../src/ws/index.js';
|
|
26
|
+
import { runMigration } from '../src/db/migrate.js';
|
|
26
27
|
|
|
27
28
|
// Load .env file in development
|
|
28
29
|
try {
|
|
@@ -52,6 +53,20 @@ if (missing.length > 0) {
|
|
|
52
53
|
process.exit(1);
|
|
53
54
|
}
|
|
54
55
|
|
|
56
|
+
// ============================================================================
|
|
57
|
+
// Auto-migration: ensure database schema is up to date on startup
|
|
58
|
+
// ============================================================================
|
|
59
|
+
// This is especially important for Render free-tier instances that hibernate.
|
|
60
|
+
// The preDeployCommand only runs on fresh deploys, not on wake-up, so we
|
|
61
|
+
// run the migration here to guarantee tables exist on every start.
|
|
62
|
+
// ============================================================================
|
|
63
|
+
const migrationResult = await runMigration();
|
|
64
|
+
if (!migrationResult.success) {
|
|
65
|
+
logger.warn({ message: migrationResult.message }, 'Auto-migration did not complete. Server will still start.');
|
|
66
|
+
} else {
|
|
67
|
+
logger.info('Auto-migration complete');
|
|
68
|
+
}
|
|
69
|
+
|
|
55
70
|
// Create HTTP server
|
|
56
71
|
const server = createServer(app);
|
|
57
72
|
|
|
@@ -60,13 +75,15 @@ const wss = initWebSocket(server, logger);
|
|
|
60
75
|
startHeartbeat();
|
|
61
76
|
|
|
62
77
|
// Start listening
|
|
63
|
-
|
|
78
|
+
// '0.0.0.0' ใๆ็คบ็ใซๆๅฎใใฆใRender ใฎๅค้จใใใฎ้ไฟกใๅใไปใใใใใใใซใใพใใ
|
|
79
|
+
server.listen(PORT, '0.0.0.0', () => {
|
|
64
80
|
logger.info(
|
|
81
|
+
|
|
65
82
|
{ port: PORT, env: process.env.NODE_ENV || 'development' },
|
|
66
83
|
`๐ koshi server running on port ${PORT}`
|
|
67
84
|
);
|
|
68
85
|
console.log(`\n โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ`);
|
|
69
|
-
console.log(` โ ๐ koshi board server
|
|
86
|
+
console.log(` โ ๐ koshi board server v1.1.0 โ`);
|
|
70
87
|
console.log(` โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฃ`);
|
|
71
88
|
console.log(` โ REST API : http://localhost:${PORT}/api โ`);
|
|
72
89
|
console.log(` โ WebSocket: ws://localhost:${PORT}/ws โ`);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ryopc/koshi",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"description": "Terminal-native decentralized SNS โ koshi board",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -34,13 +34,13 @@
|
|
|
34
34
|
"pg": "^8.11.3",
|
|
35
35
|
"pino": "^8.14.1",
|
|
36
36
|
"pino-pretty": "^10.2.0",
|
|
37
|
-
"uuid": "^
|
|
37
|
+
"uuid": "^14.0.0"
|
|
38
38
|
},
|
|
39
39
|
"license": "MIT",
|
|
40
40
|
"author": "game_ryo",
|
|
41
41
|
"repository": {
|
|
42
42
|
"type": "git",
|
|
43
|
-
"url": "https://github.com/ryopc/koshi"
|
|
43
|
+
"url": "git+https://github.com/ryopc/koshi.git"
|
|
44
44
|
},
|
|
45
45
|
"engines": {
|
|
46
46
|
"node": ">=18.0.0"
|
package/src/auth/ed25519.js
CHANGED
|
@@ -38,8 +38,11 @@ export function generateKeypair() {
|
|
|
38
38
|
*/
|
|
39
39
|
export async function signMessage(message, secretKey) {
|
|
40
40
|
const skBytes = hexToBytes(secretKey);
|
|
41
|
+
// @noble/ed25519 v2.x expects a 32-byte private key (seed), not the full
|
|
42
|
+
// 64-byte tweetnacl-format key. If given 64 bytes, take only the seed.
|
|
43
|
+
const seedBytes = skBytes.length === 64 ? skBytes.slice(0, 32) : skBytes;
|
|
41
44
|
const msgBytes = new TextEncoder().encode(message);
|
|
42
|
-
const signature = await ed.
|
|
45
|
+
const signature = await ed.signAsync(msgBytes, seedBytes);
|
|
43
46
|
return bytesToHex(signature);
|
|
44
47
|
}
|
|
45
48
|
|
|
@@ -56,7 +59,8 @@ export async function verifySignature(message, signature, publicKey) {
|
|
|
56
59
|
const msgBytes = new TextEncoder().encode(message);
|
|
57
60
|
const sigBytes = hexToBytes(signature);
|
|
58
61
|
const pkBytes = hexToBytes(publicKey);
|
|
59
|
-
|
|
62
|
+
// Use verifyAsync to avoid needing etc.sha512Sync
|
|
63
|
+
return await ed.verifyAsync(sigBytes, msgBytes, pkBytes);
|
|
60
64
|
} catch (err) {
|
|
61
65
|
// If any input is malformed (wrong length, invalid hex, etc.), return false
|
|
62
66
|
return false;
|