Skip to content

Commit

Permalink
preliminary API draft
Browse files Browse the repository at this point in the history
fixes #8

adding api

this adds the preliminary rough draft for the third party client api, solves #8

remove whitelist/blacklist

the person who requested it hasn't been around, and it has a bug so im commenting it for now

v0.4.0 - pushing version release

wiring up tokenAuth to the API requests

adding api docs

adding api docs

wiring up tokenAuth to the API requests

wiring up tokenAuth to the API requests

updating MOTD to announce test server

updating MOTD to announce test server

pubkeyhash auth bugfix
  • Loading branch information
donuts-are-good committed Apr 21, 2023
1 parent cc879ac commit 66feb78
Show file tree
Hide file tree
Showing 7 changed files with 568 additions and 54 deletions.
98 changes: 94 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,11 +1,10 @@
<img width="737" alt="image" src="https://user-images.githubusercontent.com/96031819/228712817-54829adf-1dd3-48b4-ba14-16fc53d0e7fd.png">

# shhhbb
ed25519 based BBS & chat over SSH


![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)

# shhhbb
ssh based BBS & chat over SSH

<video controls>
<source src="https://user-images.githubusercontent.com/96031819/225815939-1e7c5837-30c9-4d5b-938e-4dcb1b710401.mp4" type="video/mp4">
</video>
Expand All @@ -22,3 +21,94 @@ ed25519 based BBS & chat over SSH
3. launch with `./shhhbb 2223` where `2223` is the port

connect with `ssh -o "ForwardAgent=no" -o "IdentitiesOnly=yes" -p 2223 shhhbb.com` where shhhbb.com is your domain or ip

## api

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.

### base url

http://localhost:8080

### authentication
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.

```
/token new
/token list
/token revoke
```

### endpoints

**get /chat/messages**
*retrieve the last 100 chat messages.*

- parameters: none
- authentication: bearer token required
- response: a json object with a boolean success field and an array data field containing objects with the following properties:
- sender: the hash of the message sender
- message: the message body
- timestamp: the time the message was sent in iso 8601 format

**post /chat/create**
*create a new chat message.*

- parameters:
- sender_hash: the hash of the message sender
- message: the message body
- authentication: bearer token required
- response: a json object with a boolean success field

**post /chat/direct/create**
*create a new direct message.*

- parameters:
- sender: the hash of the message sender
- recipient: the hash of the message recipient
- message: the message body
- authentication: bearer token required
- response: a json object with a boolean success field

**get /posts/list**
*retrieve a list of posts.*

- parameters: none
- authentication: bearer token required
- response: a json object with a boolean success field and an array data field containing objects with the following properties:
- post_id: the id of the post
- author_hash: the hash of the post author
- post_body: the post body
- timestamp: the time the post was created in iso 8601 format

**post /posts/create**
*create a new post.*

- parameters:
- author_hash: the hash of the post author
- post_body: the post body
- authentication: bearer token required
- response: a json object with a boolean success field

**get /posts/replies**
*retrieve a list of replies to a post.*

- parameters:
- post_id: the id of the post to retrieve replies for
- authentication: bearer token required
- response: a json object with a boolean success field and an array data field containing objects with the following properties:
- reply_id: the id of the reply
- post_id: the id of the post being replied to
- author_hash: the hash of the reply author
- reply_body: the reply body
- timestamp: the time the reply was created in iso 8601 format

**post /posts/reply**
*create a new reply to a post.*

- parameters:
- post_id: the id of the post being replied to
- author_hash: the hash of the reply author
- reply_body: the reply body
- authentication: bearer token required
- response: a json object with a boolean success field
282 changes: 282 additions & 0 deletions api.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,282 @@
package main

import (
"context"
"crypto/rand"
"encoding/base32"
"encoding/json"
"fmt"
"log"
"net/http"
"strconv"
"strings"
"time"

"github.com/jmoiron/sqlx"
"golang.org/x/term"
)

type APIResponse struct {
Success bool `json:"success"`
Data interface{} `json:"data,omitempty"`
Error string `json:"error,omitempty"`
}

func initAPISchema(db *sqlx.DB) {
schema := `
CREATE TABLE IF NOT EXISTS auth_tokens (
id INTEGER PRIMARY KEY,
user_hash TEXT NOT NULL,
token TEXT NOT NULL UNIQUE,
created_at TIMESTAMP NOT NULL
);
`
_, err := db.Exec(schema)
if err != nil {
log.Fatalln(err)
}
}

func api(db *sqlx.DB) {
initAPISchema(db)

http.HandleFunc("/chat/messages", tokenAuth(db)(chatMessagesHandler))
http.HandleFunc("/chat/create", tokenAuth(db)(chatCreateHandler))
http.HandleFunc("/chat/direct/create", tokenAuth(db)(directMessageHandler))
http.HandleFunc("/posts/list", tokenAuth(db)(func(w http.ResponseWriter, r *http.Request) {
postsListHandler(w, r, db)
}))
http.HandleFunc("/posts/replies", tokenAuth(db)(func(w http.ResponseWriter, r *http.Request) {
repliesListHandler(w, r, db)
}))
http.HandleFunc("/posts/reply", tokenAuth(db)(func(w http.ResponseWriter, r *http.Request) {
replyCreateHandler(w, r, db)
}))

http.ListenAndServe(":8080", nil)
}

func tokenAuth(db *sqlx.DB) func(http.HandlerFunc) http.HandlerFunc {
return func(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
token := r.Header.Get("Authorization")
if token == "" {
resp := APIResponse{Success: false, Error: "missing Authorization header"}
json.NewEncoder(w).Encode(resp)
return
}

pubkeyHash, err := getPubkeyHash(db, token)
if err != nil {
resp := APIResponse{Success: false, Error: err.Error()}
json.NewEncoder(w).Encode(resp)
return
}

ctx := context.WithValue(r.Context(), "pubkey_hash", pubkeyHash)
next(w, r.WithContext(ctx))
}
}
}

func chatMessagesHandler(w http.ResponseWriter, r *http.Request) {
pubkeyHash := r.Context().Value("pubkey_hash").(string)
log.Printf("pubkeyHash: %s api: chatMessagesHandler\n", pubkeyHash)
messages := getLast100ChatMessages()
resp := APIResponse{Success: true, Data: messages}
json.NewEncoder(w).Encode(resp)
}

func chatCreateHandler(w http.ResponseWriter, r *http.Request) {
senderHash := r.FormValue("sender_hash")
message := r.FormValue("message")
if err := createChatMessage(senderHash, message); err == nil {
json.NewEncoder(w).Encode(APIResponse{Success: true})
} else {
json.NewEncoder(w).Encode(APIResponse{Success: false, Error: err.Error()})
}
}

func directMessageHandler(w http.ResponseWriter, r *http.Request) {
senderHash := r.FormValue("sender")
recipientHash := r.FormValue("recipient")
message := r.FormValue("message")
err := createDirectMessage(senderHash, recipientHash, message)
if err == nil {
json.NewEncoder(w).Encode(APIResponse{Success: true})
} else {
json.NewEncoder(w).Encode(APIResponse{Success: false, Error: err.Error()})
}
}

func postsListHandler(w http.ResponseWriter, r *http.Request, db *sqlx.DB) {
posts := listPosts(db)
resp := APIResponse{Success: true, Data: posts}
json.NewEncoder(w).Encode(resp)
}

func repliesListHandler(w http.ResponseWriter, r *http.Request, db *sqlx.DB) {
postID, _ := strconv.Atoi(r.FormValue("post_id"))
replies := getReplies(db, postID)
resp := APIResponse{Success: true, Data: replies}
json.NewEncoder(w).Encode(resp)
}

func replyCreateHandler(w http.ResponseWriter, r *http.Request, db *sqlx.DB) {
postID, _ := strconv.Atoi(r.FormValue("post_id"))
authorHash := r.FormValue("author_hash")
replyBody := r.FormValue("reply")
if err := createReply(db, postID, authorHash, replyBody); err == nil {
json.NewEncoder(w).Encode(APIResponse{Success: true})
} else {
json.NewEncoder(w).Encode(APIResponse{Success: false, Error: err.Error()})
}
}

func createChatMessage(senderHash, message string) error {
broadcast(senderHash, message)
return nil
}

func listPosts(db *sqlx.DB) []discussion {
var posts []discussion
err := db.Select(&posts, `
SELECT id, author, message
FROM discussions
ORDER BY id DESC
`)
if err != nil {
log.Printf("Error retrieving posts: %v", err)
return nil
}
return posts
}

func getReplies(db *sqlx.DB, postID int) []*reply {
var replies []*reply
err := db.Select(&replies, "SELECT author, message FROM replies WHERE discussion_id = ?", postID)
if err != nil {
log.Printf("Error retrieving replies: %v", err)
return nil
}
return replies
}
func getLast100ChatMessages() []string {
var messages []string
for e := messageCache.Front(); e != nil; e = e.Next() {
messages = append(messages, e.Value.(string))
}
return messages
}

func createDirectMessage(senderHash, recipientHash, message string) error {
usersMutex.Lock()
defer usersMutex.Unlock()
recipient, ok := users[recipientHash]
if !ok {
return fmt.Errorf("user not found")
}
if recipient.Conn == nil {
return fmt.Errorf("user connection is not available")
}
formattedMessage := fmt.Sprintf("[DM from %s] %s\n", senderHash, message)
fmt.Fprintln(recipient.Conn, formattedMessage)
return nil
}
func handleTokenNew(db *sqlx.DB, term *term.Terminal, userHash string) {
token, err := createToken(db, userHash)
if err != nil {
term.Write([]byte("Error generating token: " + err.Error() + "\n"))
} else {
term.Write([]byte("New token created: " + token + "\n"))
}
}

func handleTokenList(db *sqlx.DB, term *term.Terminal, userHash string) {
tokens, err := listTokens(db, userHash)
if err != nil {
term.Write([]byte("Error listing tokens: " + err.Error() + "\n"))
} else {
term.Write([]byte("Your tokens:\n"))
for _, token := range tokens {
term.Write([]byte(" - " + token + "\n"))
}
}
}

func handleTokenRevoke(db *sqlx.DB, input string, term *term.Terminal, userHash string) {
parts := strings.Split(input, " ")
if len(parts) < 3 {
term.Write([]byte("Usage: /tokens revoke <token>\n"))
} else {
token := parts[2]
err := revokeToken(db, userHash, token)
if err != nil {
term.Write([]byte("Error revoking token: " + err.Error() + "\n"))
} else {
term.Write([]byte("Token revoked successfully.\n"))
}
}
}

func createToken(db *sqlx.DB, userHash string) (string, error) {
token := generateRandomToken()
_, err := db.Exec("INSERT INTO auth_tokens (user_hash, token, created_at) VALUES (?, ?, ?)", userHash, token, time.Now())
if err != nil {
return "", err
}
return token, nil
}

func listTokens(db *sqlx.DB, userHash string) ([]string, error) {
var tokens []string
err := db.Select(&tokens, "SELECT token FROM auth_tokens WHERE user_hash = ?", userHash)
if err != nil {
return nil, err
}
return tokens, nil
}

func revokeToken(db *sqlx.DB, userHash, token string) error {
result, err := db.Exec("DELETE FROM auth_tokens WHERE user_hash = ? AND token = ?", userHash, token)
if err != nil {
return err
}

rowsAffected, err := result.RowsAffected()
if err != nil {
return err
}

if rowsAffected == 0 {
return fmt.Errorf("token not found or not owned by the user")
}

return nil
}

func generateRandomToken() string {
token := make([]byte, 20)
_, err := rand.Read(token)
if err != nil {
panic(err)
}

return base32.StdEncoding.EncodeToString(token)
}

func getPubkeyHash(db *sqlx.DB, token string) (string, error) {
var userHash string
err := db.Get(&userHash, "SELECT user_hash FROM auth_tokens WHERE token = ?", token)
if err != nil {
return "", fmt.Errorf("invalid token")
}

var pubkeyHash string
err = db.Get(&pubkeyHash, "SELECT pubkey_hash FROM users WHERE hash = ?", userHash)
if err != nil {
return "", fmt.Errorf("failed to retrieve pubkey hash")
}

return pubkeyHash, nil
}
Loading

0 comments on commit 66feb78

Please sign in to comment.