├── .gitignore ├── entrypoint.sh ├── keys └── readme.txt ├── go.mod ├── Dockerfile ├── types.go ├── general-motd.txt ├── db.go ├── LICENSE.md ├── go.sum ├── README.md ├── docs └── index.html ├── api.go └── main.go /.gitignore: -------------------------------------------------------------------------------- 1 | blacklist.txt 2 | general-motd.txt 3 | whitelist.txt 4 | shhhbb 5 | *.db 6 | BUILDS 7 | keys/ssh* -------------------------------------------------------------------------------- /entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | mkdir -p /keys 4 | [[ ! -f /keys/ssh_host_ed25519_key ]] && ssh-keygen -t ed25519 -f /keys/ssh_host_ed25519_key -N "" 5 | 6 | exec "$@" -------------------------------------------------------------------------------- /keys/readme.txt: -------------------------------------------------------------------------------- 1 | hello :) you might be here because you started the bbs and it complained that you didn't have any keys! 2 | place your keys in this directory. if you dont have keys, you can generate some with this command: 3 | 4 | ssh-keygen -t ed25519 -C "my shhhbb host key" 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/donuts-are-good/shhhbb 2 | 3 | go 1.20 4 | 5 | require ( 6 | github.com/jmoiron/sqlx v1.3.5 7 | github.com/mattn/go-sqlite3 v1.14.16 8 | golang.org/x/crypto v0.7.0 9 | golang.org/x/term v0.6.0 10 | ) 11 | 12 | require golang.org/x/sys v0.6.0 // indirect 13 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.20 AS builder 2 | 3 | 4 | WORKDIR /src 5 | COPY . . 6 | 7 | RUN CGO_ENABLED=1 GOOS=linux go build -o /app 8 | 9 | 10 | FROM ubuntu 11 | 12 | RUN apt-get update && apt-get -y install openssh-client 13 | 14 | COPY --from=builder /app /app 15 | COPY ./entrypoint.sh ./entrypoint.sh 16 | 17 | EXPOSE 2223 18 | 19 | ENTRYPOINT [ "./entrypoint.sh"] 20 | CMD [ "/app", "2223" ] -------------------------------------------------------------------------------- /types.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "container/list" 5 | "sync" 6 | 7 | "golang.org/x/crypto/ssh" 8 | ) 9 | 10 | var users = make(map[string]*user) 11 | var usersMutex sync.Mutex 12 | var messageCache *list.List 13 | var semverInfo = "v0.4.0" 14 | var motdFilePath = "./general-motd.txt" 15 | 16 | type user struct { 17 | Pubkey string `json:"pubkey" db:"pubkey"` 18 | Hash string `json:"hash" db:"hash"` 19 | Conn ssh.Channel `json:"-"` 20 | Ignored map[string]bool `json:"-"` 21 | } 22 | 23 | type discussion struct { 24 | ID int `json:"id" db:"id"` 25 | Author string `json:"author" db:"author"` 26 | Message string `json:"message" db:"message"` 27 | Replies []*reply `json:"replies"` 28 | } 29 | 30 | type reply struct { 31 | Author string `json:"author" db:"author"` 32 | Message string `json:"message" db:"message"` 33 | } 34 | -------------------------------------------------------------------------------- /general-motd.txt: -------------------------------------------------------------------------------- 1 | ============================================== 2 | = _ _ _ _ _ = 3 | = | |__ _ _ | || | ___ | |_ (_) _ __ = 4 | = | '_ \ | | | || || | / _ \| __|| || '_ \ = 5 | = | |_) || |_| || || || __/| |_ | || | | | = 6 | = |_.__/ \__,_||_||_| \___| \__||_||_| |_| = 7 | ============================================== 8 | 9 | Announcements: 10 | 11 | v0.4.0 12 | - test server is up! `ssh -p 2224 shhhbb.com` 13 | - api comments: https://github.com/donuts-are-good/shhhbb/issues/8 14 | - added API auth tokens 15 | - tokens List 16 | - tokens new 17 | - tokens revoke 18 | - added api 19 | - api has chat 20 | - api has board discussions 21 | 22 | v0.3.1 23 | - check it out we have MOTD's now :D 24 | - added /q, /quit, /x, /exit 25 | - added /? 26 | - added filtering for / commands in chat 27 | - added /motd, /bulletin 28 | - fixed chat scrolling disrupting input 29 | - added /history to reload the last 100 chat lines 30 | 31 | 32 | -------------------------------------------------------------------------------- /db.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "log" 5 | 6 | "github.com/jmoiron/sqlx" 7 | ) 8 | 9 | func initSqliteDB() *sqlx.DB { 10 | db, err := sqlx.Connect("sqlite3", "board.db") 11 | if err != nil { 12 | log.Fatalln(err) 13 | } 14 | return db 15 | } 16 | 17 | func initBoardSchema(db *sqlx.DB) { 18 | schema := ` 19 | CREATE TABLE IF NOT EXISTS discussions ( 20 | id INTEGER PRIMARY KEY, 21 | author TEXT NOT NULL, 22 | message TEXT NOT NULL 23 | ); 24 | 25 | CREATE TABLE IF NOT EXISTS replies ( 26 | id INTEGER PRIMARY KEY, 27 | discussion_id INTEGER NOT NULL, 28 | author TEXT NOT NULL, 29 | message TEXT NOT NULL, 30 | FOREIGN KEY (discussion_id) REFERENCES discussions(id) ON DELETE CASCADE 31 | ); 32 | ` 33 | _, err := db.Exec(schema) 34 | if err != nil { 35 | log.Fatalln(err) 36 | } 37 | } 38 | 39 | 40 | func createReply(db *sqlx.DB, postID int, authorHash string, replyBody string) error { 41 | _, err := db.Exec("INSERT INTO replies (discussion_id, author, message) VALUES (?, ?, ?)", postID, authorHash, replyBody) 42 | return err 43 | } 44 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | Copyright (c) 2023 donuts-are-good https://github.com/donuts-are-good 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | The above copyright notice and this permission notice shall be included in all 10 | copies or substantial portions of the Software. 11 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 12 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 13 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 14 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 15 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 16 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 17 | SOFTWARE. -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/go-sql-driver/mysql v1.6.0 h1:BCTh4TKNUYmOmMUcQ3IipzF5prigylS7XXjEkfCHuOE= 2 | github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= 3 | github.com/jmoiron/sqlx v1.3.5 h1:vFFPA71p1o5gAeqtEAwLU4dnX2napprKtHr7PYIcN3g= 4 | github.com/jmoiron/sqlx v1.3.5/go.mod h1:nRVWtLre0KfCLJvgxzCsLVMogSvQ1zNJtpYr2Ccp0mQ= 5 | github.com/lib/pq v1.2.0 h1:LXpIM/LZ5xGFhOpXAQUIMM1HdyqzVYM13zNdjCEEcA0= 6 | github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= 7 | github.com/mattn/go-sqlite3 v1.14.6/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU= 8 | github.com/mattn/go-sqlite3 v1.14.16 h1:yOQRA0RpS5PFz/oikGwBEqvAWhWg5ufRz4ETLjwpU1Y= 9 | github.com/mattn/go-sqlite3 v1.14.16/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= 10 | golang.org/x/crypto v0.7.0 h1:AvwMYaRytfdeVt3u6mLaxYtErKYjxA2OXjJ1HHq6t3A= 11 | golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU= 12 | golang.org/x/sys v0.6.0 h1:MVltZSvRTcU2ljQOhs94SXPftV6DCNnZViHeQps87pQ= 13 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 14 | golang.org/x/term v0.6.0 h1:clScbb1cHjoCkyRbWwBEUZ5H/tIFu5TAXIqaZD0Gcjw= 15 | golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U= 16 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | image 2 | 3 | ![donuts-are-good's followers](https://img.shields.io/github/followers/donuts-are-good?&color=555&style=for-the-badge&label=followers) ![donuts-are-good's stars](https://img.shields.io/github/stars/donuts-are-good?affiliations=OWNER%2CCOLLABORATOR&color=555&style=for-the-badge) ![donuts-are-good's visitors](https://komarev.com/ghpvc/?username=donuts-are-good&color=555555&style=for-the-badge&label=visitors) 4 | 5 | # shhhbb 6 | ssh based BBS & chat over SSH 7 | 8 | 11 | 12 | ![demo video link](https://user-images.githubusercontent.com/96031819/225815939-1e7c5837-30c9-4d5b-938e-4dcb1b710401.mp4) 13 | 14 | 15 | 16 | **instructions:** 17 | 1. create a directory called `./keys` 18 | 2. generate an ed25519 keypair in there without password 19 | `ssh-keygen -t ed25519 -C "my cool keypair" -f ./keys/ssh_host_ed25519_key` 20 | 3. launch with `./shhhbb 2223` where `2223` is the port 21 | 22 | ## api 23 | 24 | the api is designed to allow users to create and retrieve chat messages and posts. it is secured with token-based authentication using bearer tokens. 25 | 26 | ### base url 27 | 28 | http://localhost:8080 29 | 30 | ### authentication 31 | all endpoints require authentication with a bearer token. to obtain a bearer token, the user must first log in and then authenticate themselves with their token in subsequent requests. 32 | 33 | ``` 34 | /token new 35 | /token list 36 | /token revoke 37 | ``` 38 | 39 | ### endpoints 40 | 41 | **get /chat/messages** 42 | *retrieve the last 100 chat messages.* 43 | 44 | - parameters: none 45 | - authentication: bearer token required 46 | - response: a json object with a boolean success field and an array data field containing objects with the following properties: 47 | - sender: the hash of the message sender 48 | - message: the message body 49 | - timestamp: the time the message was sent in iso 8601 format 50 | 51 | **post /chat/create** 52 | *create a new chat message.* 53 | 54 | - parameters: 55 | - sender_hash: the hash of the message sender 56 | - message: the message body 57 | - authentication: bearer token required 58 | - response: a json object with a boolean success field 59 | 60 | **post /chat/direct/create** 61 | *create a new direct message.* 62 | 63 | - parameters: 64 | - sender: the hash of the message sender 65 | - recipient: the hash of the message recipient 66 | - message: the message body 67 | - authentication: bearer token required 68 | - response: a json object with a boolean success field 69 | 70 | **get /posts/list** 71 | *retrieve a list of posts.* 72 | 73 | - parameters: none 74 | - authentication: bearer token required 75 | - response: a json object with a boolean success field and an array data field containing objects with the following properties: 76 | - post_id: the id of the post 77 | - author_hash: the hash of the post author 78 | - post_body: the post body 79 | - timestamp: the time the post was created in iso 8601 format 80 | 81 | **post /posts/create** 82 | *create a new post.* 83 | 84 | - parameters: 85 | - author_hash: the hash of the post author 86 | - post_body: the post body 87 | - authentication: bearer token required 88 | - response: a json object with a boolean success field 89 | 90 | **get /posts/replies** 91 | *retrieve a list of replies to a post.* 92 | 93 | - parameters: 94 | - post_id: the id of the post to retrieve replies for 95 | - authentication: bearer token required 96 | - response: a json object with a boolean success field and an array data field containing objects with the following properties: 97 | - reply_id: the id of the reply 98 | - post_id: the id of the post being replied to 99 | - author_hash: the hash of the reply author 100 | - reply_body: the reply body 101 | - timestamp: the time the reply was created in iso 8601 format 102 | 103 | **post /posts/reply** 104 | *create a new reply to a post.* 105 | 106 | - parameters: 107 | - post_id: the id of the post being replied to 108 | - author_hash: the hash of the reply author 109 | - reply_body: the reply body 110 | - authentication: bearer token required 111 | - response: a json object with a boolean success field 112 | -------------------------------------------------------------------------------- /docs/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | shhhbb, an ssh bbs 8 | 34 | 35 | 36 |

shhhbb, an ssh bbs

37 |

shhhbb is a simple ssh based bbs.
host it with a single binary and connect with any ssh key.

38 |
view source code | download server
39 |

supports most cpus and operating systems

40 |
41 |
donuts-are-good's followers 42 | donuts-are-good's stars 43 | donuts-are-good's visitors 44 |
45 |

connect to the shhhbb.com bbs

46 |

use your normal ssh client. you don't need to sign up, just sign in with ssh and the bbs does the rest.

47 |

Note: If you need to generate a key, you can use this command to generate a good modern key. ssh-keygen -t ed25519 -C "shhhbb.com bbs key"

48 |
ssh -p 2223 shhhbb.com
49 | 50 |

how to host your own bbs

51 |

using shhhbb to host your bbs means anybody with any ssh key can interact with the bbs. as such, it's wise to run as a non-privileged user on a non-critical server, like a vps.

52 | 53 |

1. download the server program https://github.com/donuts-are-good/shhhbb/releases/latest

54 | 55 |

2. put your host keys in shhhbb/keys directory.
56 | Note: if you need to generate a new key, try this: ssh-keygen -t ed25519 -C "shhhbb host key"

57 | 58 |

3. specify a port and run the server like this:

59 |
./shhhbb 2223
60 | 61 | 62 |

how to edit the code

63 |

the server is MIT licensed. if you don't know what that means, don't worry about it. but the important part of what that means for this program is you can make any changes you like. here are some short instructions to get the code and build it yourself. it's easy, don't worry.

64 | 65 |

1. clone the repository

66 |
git clone https://github.com/donuts-are-good/shhhbb
67 |

2. make your changes and save the file. everything happens in main.go

68 |

3. build it. all you need installed is Go, which you can get here: https://golang.org

69 |
go build
70 |

4. Optional: i made a thing that will compile the server for every cpu and os it is compatible with, which is about 30-40 platforms. if you're into that, it's a simple bash tool you can try here: donuts-are-good/release.sh

71 |
./release.sh --name "shhhbb" --version "v0.0.2" 
72 | 73 |

api

74 |

the api is designed to allow users to create and retrieve chat messages and posts. it is secured with token-based authentication using bearer tokens.

75 |

base url

76 |

http://localhost:8080

77 |

authentication

78 |

all endpoints require authentication with a bearer token. to obtain a bearer token, the user must first log in and then authenticate themselves with their token in subsequent requests.

79 |
/token new
 80 |       /token list
 81 |       /token revoke
 82 |       

endpoints

83 |

get /chat/messages 84 | retrieve the last 100 chat messages.

85 | 95 |

post /chat/create 96 | create a new chat message.

97 | 106 |

post /chat/direct/create 107 | create a new direct message.

108 | 118 |

get /posts/list 119 | retrieve a list of posts.

120 | 131 |

post /posts/create 132 | create a new post.

133 | 142 |

get /posts/replies 143 | retrieve a list of replies to a post.

144 | 159 |

post /posts/reply 160 | create a new reply to a post.

161 | 171 | 172 | 173 | 174 | -------------------------------------------------------------------------------- /api.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "crypto/rand" 6 | "encoding/base32" 7 | "encoding/json" 8 | "fmt" 9 | "log" 10 | "net/http" 11 | "strconv" 12 | "strings" 13 | "time" 14 | 15 | "github.com/jmoiron/sqlx" 16 | "golang.org/x/term" 17 | ) 18 | 19 | type APIResponse struct { 20 | Success bool `json:"success"` 21 | Data interface{} `json:"data,omitempty"` 22 | Error string `json:"error,omitempty"` 23 | } 24 | 25 | func initAPISchema(db *sqlx.DB) { 26 | schema := ` 27 | CREATE TABLE IF NOT EXISTS auth_tokens ( 28 | id INTEGER PRIMARY KEY, 29 | user_hash TEXT NOT NULL, 30 | token TEXT NOT NULL UNIQUE, 31 | created_at TIMESTAMP NOT NULL 32 | ); 33 | ` 34 | _, err := db.Exec(schema) 35 | if err != nil { 36 | log.Fatalln(err) 37 | } 38 | } 39 | 40 | func api(db *sqlx.DB) { 41 | initAPISchema(db) 42 | 43 | http.HandleFunc("/chat/messages", tokenAuth(db)(chatMessagesHandler)) 44 | http.HandleFunc("/chat/create", tokenAuth(db)(chatCreateHandler)) 45 | http.HandleFunc("/chat/direct/create", tokenAuth(db)(directMessageHandler)) 46 | http.HandleFunc("/posts/list", tokenAuth(db)(func(w http.ResponseWriter, r *http.Request) { 47 | postsListHandler(w, r, db) 48 | })) 49 | http.HandleFunc("/posts/replies", tokenAuth(db)(func(w http.ResponseWriter, r *http.Request) { 50 | repliesListHandler(w, r, db) 51 | })) 52 | http.HandleFunc("/posts/reply", tokenAuth(db)(func(w http.ResponseWriter, r *http.Request) { 53 | replyCreateHandler(w, r, db) 54 | })) 55 | 56 | http.ListenAndServe(":8080", nil) 57 | } 58 | 59 | func tokenAuth(db *sqlx.DB) func(http.HandlerFunc) http.HandlerFunc { 60 | return func(next http.HandlerFunc) http.HandlerFunc { 61 | return func(w http.ResponseWriter, r *http.Request) { 62 | token := r.Header.Get("Authorization") 63 | if token == "" { 64 | resp := APIResponse{Success: false, Error: "missing Authorization header"} 65 | json.NewEncoder(w).Encode(resp) 66 | return 67 | } 68 | 69 | pubkeyHash, err := getPubkeyHash(db, token) 70 | if err != nil { 71 | resp := APIResponse{Success: false, Error: err.Error()} 72 | json.NewEncoder(w).Encode(resp) 73 | return 74 | } 75 | 76 | ctx := context.WithValue(r.Context(), "pubkey_hash", pubkeyHash) 77 | next(w, r.WithContext(ctx)) 78 | } 79 | } 80 | } 81 | 82 | func chatMessagesHandler(w http.ResponseWriter, r *http.Request) { 83 | pubkeyHash := r.Context().Value("pubkey_hash").(string) 84 | log.Printf("pubkeyHash: %s api: chatMessagesHandler\n", pubkeyHash) 85 | messages := getLast100ChatMessages() 86 | resp := APIResponse{Success: true, Data: messages} 87 | json.NewEncoder(w).Encode(resp) 88 | } 89 | 90 | func chatCreateHandler(w http.ResponseWriter, r *http.Request) { 91 | senderHash := r.FormValue("sender_hash") 92 | message := r.FormValue("message") 93 | if err := createChatMessage(senderHash, message); err == nil { 94 | json.NewEncoder(w).Encode(APIResponse{Success: true}) 95 | } else { 96 | json.NewEncoder(w).Encode(APIResponse{Success: false, Error: err.Error()}) 97 | } 98 | } 99 | 100 | func directMessageHandler(w http.ResponseWriter, r *http.Request) { 101 | senderHash := r.FormValue("sender") 102 | recipientHash := r.FormValue("recipient") 103 | message := r.FormValue("message") 104 | err := createDirectMessage(senderHash, recipientHash, message) 105 | if err == nil { 106 | json.NewEncoder(w).Encode(APIResponse{Success: true}) 107 | } else { 108 | json.NewEncoder(w).Encode(APIResponse{Success: false, Error: err.Error()}) 109 | } 110 | } 111 | 112 | func postsListHandler(w http.ResponseWriter, r *http.Request, db *sqlx.DB) { 113 | posts := listPosts(db) 114 | resp := APIResponse{Success: true, Data: posts} 115 | json.NewEncoder(w).Encode(resp) 116 | } 117 | 118 | func repliesListHandler(w http.ResponseWriter, r *http.Request, db *sqlx.DB) { 119 | postID, _ := strconv.Atoi(r.FormValue("post_id")) 120 | replies := getReplies(db, postID) 121 | resp := APIResponse{Success: true, Data: replies} 122 | json.NewEncoder(w).Encode(resp) 123 | } 124 | 125 | func replyCreateHandler(w http.ResponseWriter, r *http.Request, db *sqlx.DB) { 126 | postID, _ := strconv.Atoi(r.FormValue("post_id")) 127 | authorHash := r.FormValue("author_hash") 128 | replyBody := r.FormValue("reply") 129 | if err := createReply(db, postID, authorHash, replyBody); err == nil { 130 | json.NewEncoder(w).Encode(APIResponse{Success: true}) 131 | } else { 132 | json.NewEncoder(w).Encode(APIResponse{Success: false, Error: err.Error()}) 133 | } 134 | } 135 | 136 | func createChatMessage(senderHash, message string) error { 137 | broadcast(senderHash, message) 138 | return nil 139 | } 140 | 141 | func listPosts(db *sqlx.DB) []discussion { 142 | var posts []discussion 143 | err := db.Select(&posts, ` 144 | SELECT id, author, message 145 | FROM discussions 146 | ORDER BY id DESC 147 | `) 148 | if err != nil { 149 | log.Printf("Error retrieving posts: %v", err) 150 | return nil 151 | } 152 | return posts 153 | } 154 | 155 | func getReplies(db *sqlx.DB, postID int) []*reply { 156 | var replies []*reply 157 | err := db.Select(&replies, "SELECT author, message FROM replies WHERE discussion_id = ?", postID) 158 | if err != nil { 159 | log.Printf("Error retrieving replies: %v", err) 160 | return nil 161 | } 162 | return replies 163 | } 164 | func getLast100ChatMessages() []string { 165 | var messages []string 166 | for e := messageCache.Front(); e != nil; e = e.Next() { 167 | messages = append(messages, e.Value.(string)) 168 | } 169 | return messages 170 | } 171 | 172 | func createDirectMessage(senderHash, recipientHash, message string) error { 173 | usersMutex.Lock() 174 | defer usersMutex.Unlock() 175 | recipient, ok := users[recipientHash] 176 | if !ok { 177 | return fmt.Errorf("user not found") 178 | } 179 | if recipient.Conn == nil { 180 | return fmt.Errorf("user connection is not available") 181 | } 182 | formattedMessage := fmt.Sprintf("[DM from %s] %s\n", senderHash, message) 183 | fmt.Fprintln(recipient.Conn, formattedMessage) 184 | return nil 185 | } 186 | func handleTokenNew(db *sqlx.DB, term *term.Terminal, userHash string) { 187 | token, err := createToken(db, userHash) 188 | if err != nil { 189 | term.Write([]byte("Error generating token: " + err.Error() + "\n")) 190 | } else { 191 | term.Write([]byte("New token created: " + token + "\n")) 192 | } 193 | } 194 | 195 | func handleTokenList(db *sqlx.DB, term *term.Terminal, userHash string) { 196 | tokens, err := listTokens(db, userHash) 197 | if err != nil { 198 | term.Write([]byte("Error listing tokens: " + err.Error() + "\n")) 199 | } else { 200 | term.Write([]byte("Your tokens:\n")) 201 | for _, token := range tokens { 202 | term.Write([]byte(" - " + token + "\n")) 203 | } 204 | } 205 | } 206 | 207 | func handleTokenRevoke(db *sqlx.DB, input string, term *term.Terminal, userHash string) { 208 | parts := strings.Split(input, " ") 209 | if len(parts) < 3 { 210 | term.Write([]byte("Usage: /tokens revoke \n")) 211 | } else { 212 | token := parts[2] 213 | err := revokeToken(db, userHash, token) 214 | if err != nil { 215 | term.Write([]byte("Error revoking token: " + err.Error() + "\n")) 216 | } else { 217 | term.Write([]byte("Token revoked successfully.\n")) 218 | } 219 | } 220 | } 221 | 222 | func createToken(db *sqlx.DB, userHash string) (string, error) { 223 | token := generateRandomToken() 224 | _, err := db.Exec("INSERT INTO auth_tokens (user_hash, token, created_at) VALUES (?, ?, ?)", userHash, token, time.Now()) 225 | if err != nil { 226 | return "", err 227 | } 228 | return token, nil 229 | } 230 | 231 | func listTokens(db *sqlx.DB, userHash string) ([]string, error) { 232 | var tokens []string 233 | err := db.Select(&tokens, "SELECT token FROM auth_tokens WHERE user_hash = ?", userHash) 234 | if err != nil { 235 | return nil, err 236 | } 237 | return tokens, nil 238 | } 239 | 240 | func revokeToken(db *sqlx.DB, userHash, token string) error { 241 | result, err := db.Exec("DELETE FROM auth_tokens WHERE user_hash = ? AND token = ?", userHash, token) 242 | if err != nil { 243 | return err 244 | } 245 | 246 | rowsAffected, err := result.RowsAffected() 247 | if err != nil { 248 | return err 249 | } 250 | 251 | if rowsAffected == 0 { 252 | return fmt.Errorf("token not found or not owned by the user") 253 | } 254 | 255 | return nil 256 | } 257 | 258 | func generateRandomToken() string { 259 | token := make([]byte, 20) 260 | _, err := rand.Read(token) 261 | if err != nil { 262 | panic(err) 263 | } 264 | 265 | return base32.StdEncoding.EncodeToString(token) 266 | } 267 | 268 | func getPubkeyHash(db *sqlx.DB, token string) (string, error) { 269 | var userHash string 270 | err := db.Get(&userHash, "SELECT user_hash FROM auth_tokens WHERE token = ?", token) 271 | if err != nil { 272 | return "", fmt.Errorf("invalid token") 273 | } 274 | 275 | var pubkeyHash string 276 | err = db.Get(&pubkeyHash, "SELECT pubkey_hash FROM users WHERE hash = ?", userHash) 277 | if err != nil { 278 | return "", fmt.Errorf("failed to retrieve pubkey hash") 279 | } 280 | 281 | return pubkeyHash, nil 282 | } 283 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bufio" 5 | "container/list" 6 | "encoding/base64" 7 | "errors" 8 | "flag" 9 | "fmt" 10 | "io" 11 | "log" 12 | "net" 13 | "os" 14 | "sort" 15 | "strconv" 16 | "strings" 17 | "time" 18 | "unicode" 19 | 20 | "github.com/jmoiron/sqlx" 21 | _ "github.com/mattn/go-sqlite3" 22 | "golang.org/x/crypto/sha3" 23 | "golang.org/x/crypto/ssh" 24 | "golang.org/x/term" 25 | ) 26 | 27 | func init() { 28 | messageCache = list.New() 29 | } 30 | func main() { 31 | // whitelist, err := loadPubkeyList("whitelist.txt") 32 | // if err != nil { 33 | // log.Printf("Error loading whitelist: %v", err) 34 | // return 35 | // } 36 | 37 | // blacklist, err := loadPubkeyList("blacklist.txt") 38 | // if err != nil { 39 | // log.Printf("Error loading blacklist: %v", err) 40 | // return 41 | // } 42 | 43 | var pvar = 1 44 | 45 | db := initSqliteDB() 46 | if db == nil { 47 | log.Panic("couldn't load main db") 48 | return 49 | } 50 | defer db.Close() 51 | initBoardSchema(db) 52 | 53 | var privateKeyPath string 54 | flag.StringVar(&privateKeyPath, "key", "./keys/ssh_host_ed25519_key", "Path to the private key") 55 | flag.Parse() 56 | if _, err := os.Stat("./keys"); os.IsNotExist(err) { 57 | fmt.Println("Error: ./keys directory does not exist. Please create it and generate an ed25519 keypair.") 58 | return 59 | } 60 | if _, err := os.Stat(privateKeyPath); os.IsNotExist(err) { 61 | fmt.Printf("Error: private key file %s does not exist. Please generate an ed25519 keypair.\n", privateKeyPath) 62 | return 63 | } 64 | users = make(map[string]*user) 65 | if len(os.Args) <= 2 { 66 | fmt.Printf("Usage: %s [ -key /path/to/ed25519key ] \n", os.Args[0]) 67 | return 68 | } 69 | if len(os.Args) > 2 { 70 | pvar = len(os.Args) - 1 71 | } 72 | config, err := configureSSHServer(privateKeyPath) 73 | if err != nil { 74 | fmt.Println("Error configuring SSH server:", err.Error()) 75 | return 76 | } 77 | 78 | listener, err := net.Listen("tcp", ":"+os.Args[pvar]) 79 | if err != nil { 80 | fmt.Println("Error listening:", err.Error()) 81 | return 82 | } 83 | defer listener.Close() 84 | fmt.Println("Listening on :" + os.Args[pvar]) 85 | 86 | go api(db) 87 | 88 | for { 89 | conn, err := listener.Accept() 90 | if err != nil { 91 | fmt.Println("Error accepting:", err.Error()) 92 | continue 93 | } 94 | go func(conn net.Conn) { 95 | defer conn.Close() 96 | sshConn, chans, reqs, err := ssh.NewServerConn(conn, config) 97 | if err != nil { 98 | fmt.Println("Error upgrading connection to SSH:", err.Error()) 99 | return 100 | } 101 | defer sshConn.Close() 102 | go ssh.DiscardRequests(reqs) 103 | for newChannel := range chans { 104 | if newChannel.ChannelType() != "session" { 105 | newChannel.Reject(ssh.UnknownChannelType, "unknown channel type") 106 | continue 107 | } 108 | channel, requests, err := newChannel.Accept() 109 | if err != nil { 110 | fmt.Println("Error accepting channel:", err.Error()) 111 | return 112 | } 113 | // go handleConnection(db, channel, sshConn, requests, whitelist, blacklist) 114 | go handleConnection(db, channel, sshConn, requests) 115 | } 116 | }(conn) 117 | } 118 | } 119 | 120 | // func handleConnection(db *sqlx.DB, channel ssh.Channel, sshConn *ssh.ServerConn, requests <-chan *ssh.Request, whitelist map[string]bool, blacklist map[string]bool) { 121 | func handleConnection(db *sqlx.DB, channel ssh.Channel, sshConn *ssh.ServerConn, requests <-chan *ssh.Request) { 122 | defer channel.Close() 123 | if sshConn.Permissions == nil || sshConn.Permissions.Extensions == nil { 124 | fmt.Fprintln(channel, "Unable to retrieve your public key.") 125 | return 126 | } 127 | pubkey, ok := sshConn.Permissions.Extensions["pubkey"] 128 | if !ok { 129 | fmt.Fprintln(channel, "Unable to retrieve your public key.") 130 | return 131 | } 132 | hash := formatUsernameFromPubkey(pubkey) 133 | 134 | // if _, ok := whitelist[hash]; !ok { 135 | // fmt.Fprintln(channel, "You are not authorized to connect to this server.") 136 | // disconnect(hash) 137 | // return 138 | // } 139 | 140 | // if _, ok := blacklist[hash]; ok { 141 | // fmt.Fprintln(channel, "You have been banned from this server.") 142 | // disconnect(hash) 143 | // return 144 | // } 145 | 146 | addUser(hash, &user{Pubkey: pubkey, Hash: hash, Conn: channel}) 147 | 148 | term := term.NewTerminal(channel, "") 149 | term.SetPrompt("") 150 | saveCursorPos(channel) 151 | 152 | restoreCursorPos(channel) 153 | 154 | welcome := welcomeMessageAscii() 155 | 156 | term.Write([]byte(welcome)) 157 | printMOTD(loadMOTD(motdFilePath), term) 158 | printCachedMessages(term) 159 | term.Write([]byte("\nWelcome :) You are " + hash + "\n")) 160 | for { 161 | input, err := term.ReadLine() 162 | if err != nil { 163 | if err == io.EOF { 164 | disconnect(hash) 165 | return 166 | } 167 | readlineErrCheck(term, err, hash) 168 | return 169 | } 170 | 171 | switch { 172 | case strings.HasPrefix(input, "/ignore"): 173 | handleIgnore(input, term, hash) 174 | case strings.HasPrefix(input, "/help") || strings.HasPrefix(input, "/?"): 175 | writeHelpMenu(term) 176 | case strings.HasPrefix(input, "/license"): 177 | writeLicenseProse(term) 178 | case strings.HasPrefix(input, "/version"): 179 | writeVersionInfo(term) 180 | case strings.HasPrefix(input, "/users"): 181 | writeUsersOnline(term) 182 | case strings.HasPrefix(input, "/bulletin") || strings.HasPrefix(input, "/motd"): 183 | printMOTD(motdFilePath, term) 184 | case strings.HasPrefix(input, "/pubkey"): 185 | term.Write([]byte("Your pubkey hash: " + hash + "\n")) 186 | case strings.HasPrefix(input, "/message"): 187 | handleMessage(input, term, hash) 188 | case strings.HasPrefix(input, "/post"): 189 | handlePost(input, term, db, hash) 190 | case strings.HasPrefix(input, "/list"): 191 | listDiscussions(db, term) 192 | case strings.HasPrefix(input, "/history"): 193 | printCachedMessages(term) 194 | case strings.HasPrefix(input, "/tokens new"): 195 | handleTokenNew(db, term, hash) 196 | case strings.HasPrefix(input, "/tokens list"): 197 | handleTokenList(db, term, hash) 198 | case strings.HasPrefix(input, "/tokens revoke"): 199 | handleTokenRevoke(db, input, term, hash) 200 | case strings.HasPrefix(input, "/quit") || strings.HasPrefix(input, "/q") || 201 | strings.HasPrefix(input, "/exit") || strings.HasPrefix(input, "/x") || 202 | strings.HasPrefix(input, "/leave") || strings.HasPrefix(input, "/part"): 203 | disconnect(hash) 204 | case strings.HasPrefix(input, "/replies"): 205 | handleReplies(input, term, db) 206 | case strings.HasPrefix(input, "/reply"): 207 | handleReply(input, term, db, hash) 208 | default: 209 | if len(input) > 0 { 210 | if strings.HasPrefix(input, "/") { 211 | term.Write([]byte("Unrecognized command. Type /help for available commands.\n")) 212 | } else { 213 | message := fmt.Sprintf("[%s] %s: %s", time.Now().String()[11:16], hash, input) 214 | broadcast(hash, message+"\r") 215 | } 216 | } 217 | } 218 | } 219 | } 220 | 221 | // func loadPubkeyList(filename string) (map[string]bool, error) { 222 | // file, err := os.OpenFile(filename, os.O_RDWR|os.O_CREATE, 0644) 223 | // if err != nil { 224 | // return nil, fmt.Errorf("unable to open %s: %v", filename, err) 225 | // } 226 | // defer file.Close() 227 | 228 | // pubkeyList := make(map[string]bool) 229 | 230 | // scanner := bufio.NewScanner(file) 231 | // for scanner.Scan() { 232 | // pubkey := scanner.Text() 233 | // pubkeyList[pubkey] = true 234 | // } 235 | 236 | // if err := scanner.Err(); err != nil { 237 | // return nil, fmt.Errorf("error reading %s: %v", filename, err) 238 | // } 239 | 240 | // return pubkeyList, nil 241 | // } 242 | 243 | func saveCursorPos(channel ssh.Channel) { 244 | writeString(channel, "\033[s") 245 | } 246 | 247 | func restoreCursorPos(channel ssh.Channel) { 248 | writeString(channel, "\033[u") 249 | } 250 | 251 | func moveCursorUp(channel ssh.Channel, n int) { 252 | writeString(channel, fmt.Sprintf("\033[%dA", n)) 253 | } 254 | func moveCursorDown(w io.Writer, lines int) { 255 | fmt.Fprintf(w, "\033[%dB", lines) 256 | } 257 | func writeString(channel ssh.Channel, s string) { 258 | channel.Write([]byte(s)) 259 | } 260 | 261 | func generateHash(pubkey string) string { 262 | h := sha3.NewShake256() 263 | h.Write([]byte(pubkey)) 264 | checksum := make([]byte, 16) 265 | h.Read(checksum) 266 | return base64.StdEncoding.EncodeToString(checksum) 267 | } 268 | 269 | func disconnect(hash string) { 270 | usersMutex.Lock() 271 | user, exists := users[hash] 272 | usersMutex.Unlock() 273 | 274 | if exists { 275 | user.Conn.Close() 276 | } 277 | 278 | removeUser(hash) 279 | } 280 | 281 | func broadcast(senderHash, message string) { 282 | addToCache(message) 283 | for _, user := range getAllUsers() { 284 | if user.Hash == senderHash { 285 | continue 286 | } 287 | saveCursorPos(user.Conn) 288 | moveCursorUp(user.Conn, 1) 289 | fmt.Fprintln(user.Conn, message) 290 | restoreCursorPos(user.Conn) 291 | moveCursorDown(user.Conn, 1) 292 | fmt.Fprint(user.Conn, "\n") 293 | if user.Conn == nil { 294 | log.Printf("broadcast: user with hash %v has nil connection\n", user.Hash) 295 | continue 296 | } 297 | log.Printf("Broadcasted message to user with hash %v\n", user.Hash) 298 | } 299 | } 300 | 301 | func addDiscussion(db *sqlx.DB, author, message string) int { 302 | res, err := db.Exec("INSERT INTO discussions (author, message) VALUES (?, ?)", author, message) 303 | if err != nil { 304 | log.Println(err) 305 | return -1 306 | } 307 | id, err := res.LastInsertId() 308 | if err != nil { 309 | log.Println(err) 310 | return -1 311 | } 312 | return int(id) 313 | } 314 | 315 | func addReply(db *sqlx.DB, postNumber int, author, message string) bool { 316 | _, err := db.Exec("INSERT INTO replies (discussion_id, author, message) VALUES (?, ?, ?)", postNumber, author, message) 317 | if err != nil { 318 | log.Println(err) 319 | return false 320 | } 321 | return true 322 | 323 | } 324 | 325 | func listDiscussions(db *sqlx.DB, term *term.Terminal) { 326 | var discussions []struct { 327 | ID int `db:"id"` 328 | Author string `db:"author"` 329 | Message string `db:"message"` 330 | ReplyCount int `db:"reply_count"` 331 | } 332 | err := db.Select(&discussions, ` 333 | SELECT d.id, d.author, d.message, COUNT(r.id) as reply_count 334 | FROM discussions d 335 | LEFT JOIN replies r ON d.id = r.discussion_id 336 | GROUP BY d.id 337 | `) 338 | if err != nil { 339 | log.Printf("Error retrieving discussions: %v", err) 340 | term.Write([]byte("Error retrieving discussions.\n")) 341 | return 342 | } 343 | 344 | sort.Slice(discussions, func(i, j int) bool { 345 | weightID := 0.3 346 | weightReplyCount := 0.7 347 | 348 | scoreI := weightID*float64(discussions[i].ID) + weightReplyCount*float64(discussions[i].ReplyCount) 349 | scoreJ := weightID*float64(discussions[j].ID) + weightReplyCount*float64(discussions[j].ReplyCount) 350 | 351 | return scoreI > scoreJ 352 | }) 353 | 354 | term.Write([]byte("Discussions:\n\n[id.]\t[💬replies]\t[topic]\n\n")) 355 | for _, disc := range discussions { 356 | term.Write([]byte(fmt.Sprintf("%d.\t💬%d\t[%s] %s\n", disc.ID, disc.ReplyCount, disc.Author, disc.Message))) 357 | } 358 | } 359 | 360 | func listReplies(db *sqlx.DB, postNumber int, term *term.Terminal) { 361 | var disc discussion 362 | err := db.Get(&disc, "SELECT id, author, message FROM discussions WHERE id = ?", postNumber) 363 | if err != nil { 364 | log.Printf("Error retrieving discussion: %v", err) 365 | term.Write([]byte("Invalid post number.\n")) 366 | return 367 | } 368 | term.Write([]byte(fmt.Sprintf("Replies to post %d [%s]:\n", disc.ID, disc.Author))) 369 | 370 | var replies []*reply 371 | err = db.Select(&replies, "SELECT author, message FROM replies WHERE discussion_id = ?", postNumber) 372 | if err != nil { 373 | log.Printf("Error retrieving replies: %v", err) 374 | term.Write([]byte("Error retrieving replies.\n")) 375 | return 376 | } 377 | for i, rep := range replies { 378 | term.Write([]byte(fmt.Sprintf("%d. [%s] %s\n", i+1, rep.Author, rep.Message))) 379 | } 380 | } 381 | 382 | func addToCache(message string) { 383 | messageCache.PushBack(message) 384 | if messageCache.Len() > 100 { 385 | messageCache.Remove(messageCache.Front()) 386 | } 387 | } 388 | 389 | func printCachedMessages(term *term.Terminal) { 390 | for e := messageCache.Front(); e != nil; e = e.Next() { 391 | term.Write([]byte(e.Value.(string) + "\r\n")) 392 | } 393 | } 394 | func printMOTD(motd string, term *term.Terminal) { 395 | if motd != "" { 396 | term.Write([]byte(motd + "\r\n")) 397 | } 398 | 399 | } 400 | 401 | func configureSSHServer(privateKeyPath string) (*ssh.ServerConfig, error) { 402 | privateKeyBytes, err := os.ReadFile(privateKeyPath) 403 | if err != nil { 404 | return nil, fmt.Errorf("failed to read private key: %w", err) 405 | } 406 | privateKey, err := ssh.ParsePrivateKey(privateKeyBytes) 407 | if err != nil { 408 | return nil, fmt.Errorf("failed to parse private key: %w", err) 409 | } 410 | config := &ssh.ServerConfig{ 411 | PublicKeyCallback: func(conn ssh.ConnMetadata, key ssh.PublicKey) (*ssh.Permissions, error) { 412 | fmt.Printf("Received public key of type %s from user %s\n", key.Type(), conn.User()) 413 | return &ssh.Permissions{ 414 | Extensions: map[string]string{ 415 | "pubkey": string(key.Marshal()), 416 | }, 417 | }, nil 418 | }, 419 | } 420 | config.AddHostKey(privateKey) 421 | return config, nil 422 | } 423 | 424 | func addUser(hash string, u *user) { 425 | usersMutex.Lock() 426 | defer usersMutex.Unlock() 427 | u.Ignored = make(map[string]bool) 428 | users[hash] = u 429 | } 430 | 431 | func removeUser(hash string) { 432 | usersMutex.Lock() 433 | defer usersMutex.Unlock() 434 | delete(users, hash) 435 | } 436 | 437 | func getAllUsers() []*user { 438 | usersMutex.Lock() 439 | defer usersMutex.Unlock() 440 | allUsers := make([]*user, 0, len(users)) 441 | for _, user := range users { 442 | allUsers = append(allUsers, user) 443 | } 444 | return allUsers 445 | } 446 | 447 | func cleanString(dirtyString string) (string, error) { 448 | var clean strings.Builder 449 | for _, r := range dirtyString { 450 | if unicode.IsLetter(r) || unicode.IsDigit(r) { 451 | clean.WriteRune(r) 452 | } 453 | } 454 | 455 | if clean.Len() < 8 { 456 | return "", errors.New("not enough characters after cleaning") 457 | } 458 | 459 | return clean.String()[:8], nil 460 | } 461 | 462 | func sendMessage(senderHash, recipientHash, message string, term *term.Terminal) { 463 | usersMutex.Lock() 464 | recipient, ok := users[recipientHash] 465 | usersMutex.Unlock() 466 | if !ok { 467 | fmt.Fprintf(users[senderHash].Conn, "\n\rUser with hash %s not found\n", recipientHash) 468 | return 469 | } 470 | if recipient.Ignored[senderHash] { 471 | return 472 | } 473 | message = "\r\n[DirectMessage][" + senderHash + "] " + message + "\r\n" 474 | fmt.Fprintln(recipient.Conn, message) 475 | term.Write([]byte(message)) 476 | } 477 | 478 | func loadMOTD(motdFilePath string) string { 479 | 480 | var motdMessage string 481 | file, err := os.Open(motdFilePath) 482 | if err != nil { 483 | if os.IsNotExist(err) { 484 | os.Create(motdFilePath) 485 | if err != nil { 486 | log.Println("we weren't able to create it either: " + err.Error()) 487 | return "" 488 | } 489 | log.Println("motd didn't exist: " + err.Error()) 490 | return "" 491 | } 492 | log.Println("error opening motdFilePath: " + err.Error()) 493 | return "" 494 | } 495 | defer file.Close() 496 | scanner := bufio.NewScanner(file) 497 | for scanner.Scan() { 498 | line := scanner.Text() 499 | if !strings.HasPrefix(line, "#") { 500 | motdMessage += line + "\n" 501 | } 502 | } 503 | 504 | return motdMessage 505 | } 506 | 507 | func handleReply(input string, term *term.Terminal, db *sqlx.DB, hash string) error { 508 | parts := strings.SplitN(input, " ", 3) 509 | if len(parts) < 3 { 510 | return fmt.Errorf("usage: /reply ") 511 | } 512 | postNum, err := strconv.Atoi(parts[1]) 513 | if err != nil { 514 | return fmt.Errorf("invalid post number. Usage: /reply ") 515 | } 516 | replyBody := parts[2] 517 | replySuccess := addReply(db, postNum, hash, replyBody) 518 | if !replySuccess { 519 | return fmt.Errorf("failed to reply to post. Please check the post number and try again") 520 | } else { 521 | term.Write([]byte("Reply successfully added to post.\n")) 522 | return nil 523 | } 524 | } 525 | 526 | func handleReplies(input string, term *term.Terminal, db *sqlx.DB) { 527 | parts := strings.SplitN(input, " ", 2) 528 | if len(parts) < 2 { 529 | term.Write([]byte("Usage: /replies \n")) 530 | return 531 | } 532 | postNum, err := strconv.Atoi(parts[1]) 533 | if err != nil { 534 | term.Write([]byte("Invalid post number. Usage: /replies \n")) 535 | return 536 | } 537 | listReplies(db, postNum, term) 538 | } 539 | 540 | func handleIgnore(input string, term *term.Terminal, hash string) { 541 | parts := strings.Split(input, " ") 542 | if len(parts) != 2 { 543 | term.Write([]byte("Usage: /ignore \n")) 544 | return 545 | } 546 | ignoredUser := parts[1] 547 | usersMutex.Lock() 548 | _, exists := users[ignoredUser] 549 | usersMutex.Unlock() 550 | if !exists { 551 | term.Write([]byte("User " + ignoredUser + " not found.\n")) 552 | } else if ignoredUser == hash { 553 | term.Write([]byte("You cannot ignore yourself.\n")) 554 | } else { 555 | users[hash].Ignored[ignoredUser] = true 556 | term.Write([]byte("User " + ignoredUser + " is now ignored.\n")) 557 | } 558 | } 559 | 560 | func readlineErrCheck(term *term.Terminal, err error, hash string) { 561 | term.Write([]byte("Error reading input: ")) 562 | term.Write([]byte(err.Error())) 563 | term.Write([]byte("\n")) 564 | disconnect(hash) 565 | } 566 | 567 | func handlePost(input string, term *term.Terminal, db *sqlx.DB, hash string) { 568 | parts := strings.SplitN(input, " ", 2) 569 | if len(parts) < 2 { 570 | term.Write([]byte("Usage: /post \n")) 571 | } else { 572 | postNumber := addDiscussion(db, hash, parts[1]) 573 | term.Write([]byte(fmt.Sprintf("Posted new discussion with post number %d.\n", postNumber))) 574 | } 575 | } 576 | 577 | func handleMessage(input string, term *term.Terminal, hash string) { 578 | parts := strings.Split(input, " ") 579 | if len(parts) < 3 { 580 | term.Write([]byte("Usage: /message \n")) 581 | } else { 582 | recipientHash := parts[1] 583 | message := strings.Join(parts[2:], " ") 584 | sendMessage(hash, recipientHash, message, term) 585 | } 586 | } 587 | 588 | func formatUsernameFromPubkey(pubkey string) string { 589 | hash, err := cleanString(generateHash(pubkey)) 590 | if err != nil { 591 | log.Println("error generating username: ", err) 592 | } 593 | hash = "@" + hash 594 | return hash 595 | } 596 | 597 | func welcomeMessageAscii() string { 598 | welcome := ` 599 | 600 | BB BB BB BB BB 601 | ,adPPYba, BB,dPPYba, BB,dPPYba, BB,dPPYba, BB,dPPYba, BB,dPPYba, 602 | I8[ "" BBP' "8a BBP' "8a BBP' "8a BBP' "8a BBP' "8a 603 | '"Y8ba, BB BB BB BB BB BB BB d8 BB d8 604 | aa ]8I BB BB BB BB BB BB BBb, ,a8" BBb, ,a8" 605 | '"YbbdP"' BB BB BB BB BB BB 8Y"Ybbd8"' 8Y"Ybbd8"' BBS 606 | > MIT 2023, https://github.com/donuts-are-good/shhhbb ` + semverInfo + ` 607 | 608 | [RULES] [GOALS] 609 | - your words are your own - a space for hackers & devs 610 | - your eyes are your own - make cool things 611 | - no chat logs are kept - collaborate & share 612 | - have fun :) - evolve 613 | 614 | Say hello and press [enter] to chat 615 | Type /help for more commands. 616 | 617 | ` 618 | return welcome 619 | } 620 | 621 | func writeUsersOnline(term *term.Terminal) { 622 | term.Write([]byte("Connected users:\n")) 623 | for _, user := range users { 624 | term.Write([]byte("- " + user.Hash + "\n")) 625 | } 626 | } 627 | func writeHelpMenu(term *term.Terminal) { 628 | term.Write([]byte(` 629 | [General Commands] 630 | /help 631 | - show this help message 632 | /pubkey 633 | - show your pubkey hash, which is also your username 634 | /users 635 | - list all online users 636 | /message 637 | - ex: /message @A1Gla593 hey there friend 638 | - send a direct message to a user 639 | /quit, /q, /exit, /x 640 | - disconnect, exit, goodbye 641 | 642 | [Chat commands] 643 | /history 644 | - reloads the last 100 lines of chat history 645 | 646 | [Message Board] 647 | /post 648 | - ex: /post this is my cool title 649 | - posts a new discussion topic 650 | /list 651 | - list all discussions 652 | /replies 653 | - ex: /replies 1 654 | - list all replies to a discussion 655 | /reply 656 | - ex: /reply 1 hello everyone 657 | - reply to a discussion 658 | 659 | [API Commands] 660 | /tokens new 661 | - create a new shhhbb API token 662 | /tokens list 663 | - view your shhhbb API tokens 664 | /tokens revoke 665 | - revoke an shhhbb API token 666 | 667 | [Misc. Commands] 668 | /license 669 | - display the license text for shhhbb 670 | /version 671 | - display the shhhbb version information 672 | 673 | ` + "\n")) 674 | } 675 | func writeLicenseProse(term *term.Terminal) { 676 | term.Write([]byte(` 677 | MIT License 678 | Copyright (c) 2023 donuts-are-good https://github.com/donuts-are-good 679 | Permission is hereby granted, free of charge, to any person obtaining a copy 680 | of this software and associated documentation files (the "Software"), to deal 681 | in the Software without restriction, including without limitation the rights 682 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 683 | copies of the Software, and to permit persons to whom the Software is 684 | furnished to do so, subject to the following conditions: 685 | The above copyright notice and this permission notice shall be included in all 686 | copies or substantial portions of the Software. 687 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 688 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 689 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 690 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 691 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 692 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 693 | SOFTWARE. 694 | `)) 695 | } 696 | func writeVersionInfo(term *term.Terminal) { 697 | term.Write([]byte(` 698 | shhhbb bbs ` + semverInfo + ` 699 | MIT License 2023 donuts-are-good 700 | https://github.com/donuts-are-good/shhhbb 701 | `)) 702 | } 703 | --------------------------------------------------------------------------------