diff options
| author | Nicolas James <Eele1Ephe7uZahRie@tutanota.com> | 2025-02-13 18:04:18 +1100 |
|---|---|---|
| committer | Nicolas James <Eele1Ephe7uZahRie@tutanota.com> | 2025-02-13 18:04:18 +1100 |
| commit | 93dfe2be64e8658839bcfe5356adf35f8cde7075 (patch) | |
| tree | c60b1e20d569b74dbde85123e1b2bf3590c66244 /src/server | |
initial commit
Diffstat (limited to 'src/server')
| -rw-r--r-- | src/server/database/database.go | 74 | ||||
| -rw-r--r-- | src/server/database/image.go | 31 | ||||
| -rw-r--r-- | src/server/database/post.go | 28 | ||||
| -rw-r--r-- | src/server/database/posts.go | 63 | ||||
| -rw-r--r-- | src/server/database/user.go | 55 | ||||
| -rw-r--r-- | src/server/go.mod | 11 | ||||
| -rw-r--r-- | src/server/go.sum | 32 | ||||
| -rw-r--r-- | src/server/handlers/image.go | 41 | ||||
| -rw-r--r-- | src/server/handlers/login.go | 55 | ||||
| -rw-r--r-- | src/server/handlers/logout.go | 20 | ||||
| -rw-r--r-- | src/server/handlers/post.go | 121 | ||||
| -rw-r--r-- | src/server/handlers/posts.go | 60 | ||||
| -rw-r--r-- | src/server/handlers/refresh.go | 32 | ||||
| -rw-r--r-- | src/server/handlers/signup.go | 75 | ||||
| -rw-r--r-- | src/server/helper/clean.go | 37 | ||||
| -rw-r--r-- | src/server/helper/crypto.go | 46 | ||||
| -rw-r--r-- | src/server/helper/error.go | 36 | ||||
| -rw-r--r-- | src/server/helper/jwt.go | 66 | ||||
| -rw-r--r-- | src/server/helper/valid.go | 70 | ||||
| -rw-r--r-- | src/server/main.go | 42 | ||||
| -rw-r--r-- | src/server/spa.go | 37 |
21 files changed, 1032 insertions, 0 deletions
diff --git a/src/server/database/database.go b/src/server/database/database.go new file mode 100644 index 0000000..0b5bb4f --- /dev/null +++ b/src/server/database/database.go @@ -0,0 +1,74 @@ +package database + +import ( + "database/sql" + "log" + + // We're using sqlite3 because we want to make deploying the project easy. + // (ie, no need to set up a postgres server for a little forum application). + _ "github.com/mattn/go-sqlite3" +) + +var db *sql.DB + +// Initialises the tables in the database (if they do not exist). +func setup_db(db *sql.DB) error { + // FIXME executing multiple queries with one statement is definitely slower + // than a single query with multiple statments. IDK if our sqlite3 driver + // supports this, but a rewrite is needed if it does. + statements := [...]string{ + ("CREATE TABLE IF NOT EXISTS Users(" + + "uid INTEGER PRIMARY KEY AUTOINCREMENT," + + "email TEXT NOT NULL UNIQUE," + + "username TEXT," + + "password_hash TEXT NOT NULL," + + "password_salt TEXT NOT NULL);"), + ("CREATE TABLE IF NOT EXISTS Posts(" + + "uid INTEGER PRIMARY KEY AUTOINCREMENT," + + "author INTEGER NOT NULL," + + "parent INTEGER," + + "time INTEGER NOT NULL," + + "subreact TEXT NOT NULL," + + "title TEXT NOT NULL," + + "contents TEXT NOT NULL," + + "thumbnail BLOB NOT NULL," + + "image BLOB NOT NULL," + + + "FOREIGN KEY (author) REFERENCES Users (uid)," + + "FOREIGN KEY (parent) REFERENCES Posts (uid));"), + } + + for _, statement := range statements { + _, err := db.Exec(statement) + if err != nil { + return err + } + } + return nil +} + +// Call to initialise the database global var. +func Init(path string) { + var err error + db, err = sql.Open("sqlite3", path) + if err != nil { + log.Fatal(err) + } + err = db.Ping() + if err != nil { + log.Fatal(err) + } + err = setup_db(db) + if err != nil { + log.Fatal(err) + } +} + +func GetDb() *sql.DB { + return db +} + +// Call to close connection to database. +func Close() { + db.Close() +} diff --git a/src/server/database/image.go b/src/server/database/image.go new file mode 100644 index 0000000..d85063f --- /dev/null +++ b/src/server/database/image.go @@ -0,0 +1,31 @@ +package database + +import ( + "database/sql" +) + +func getThumbnailOrImage(thumbnail bool) string { + if thumbnail { + return "Posts.thumbnail " + } + return "Posts.image " +} + +func GetImage(uid int, thumbnail bool) ([]byte, error) { + var db = GetDb() + + var image []byte + row := db.QueryRow("SELECT "+getThumbnailOrImage(thumbnail)+ + "FROM Posts "+ + "WHERE Posts.uid = ?;", uid) + if err := row.Scan(&image); err != nil { + + if err == sql.ErrNoRows { + return nil, nil + } + + return nil, err + } + + return image, nil +} diff --git a/src/server/database/post.go b/src/server/database/post.go new file mode 100644 index 0000000..d73344a --- /dev/null +++ b/src/server/database/post.go @@ -0,0 +1,28 @@ +package database + +import ( + "time" +) + +// Writes a post to the database, returns the uid of the new post. +func WritePost(author int, + parent *int, + title string, + contents string, + subreact string, + image []byte, + thumbnail []byte) (int, error) { + + var db = GetDb() + + var uid int + row := db.QueryRow("INSERT INTO Posts "+ + "(author, parent, time, subreact, title, contents, thumbnail, image) "+ + "VALUES(?, ?, ?, ?, ?, ?, ?, ?) RETURNING Posts.uid;", + author, parent, time.Now().Unix(), subreact, title, contents, thumbnail, image) + if err := row.Scan(&uid); err != nil { + return 0, err + } + + return uid, nil +} diff --git a/src/server/database/posts.go b/src/server/database/posts.go new file mode 100644 index 0000000..033f5ae --- /dev/null +++ b/src/server/database/posts.go @@ -0,0 +1,63 @@ +package database + +import ( + "database/sql" +) + +type Post struct { + Uid int `json:"uid"` + Author string `json:"author"` + TimeUpdated int `json:"time_updated"` + Subreact string `json:"subreact"` + Title string `json:"title"` + Contents string `json:"contents"` +} + +func GetPosts(subreact string, page int, amount int) ([]Post, error) { + var db = GetDb() + + // This gets posts without parents (threads) and its latest updated time, + // which could be the post itself. + const query = "SELECT Parent.uid, Parent.author, COALESCE(Latest.updated_time, Parent.time) AS bump_time, Parent.subreact, " + + "Parent.title, Parent.contents " + + "FROM Posts as Parent " + + " LEFT OUTER JOIN ( " + + " SELECT Posts.parent, Posts.uid, MAX(Posts.time) AS updated_time " + + " FROM Posts " + + " WHERE Posts.parent IS NOT NULL " + + " ORDER BY Posts.time " + + " ) AS LATEST " + + " ON Latest.parent = Parent.uid " + + "WHERE Parent.subreact = (CASE WHEN (? = \"\") then Parent.subreact ELSE ? END) " + + "ORDER BY bump_time DESC " + + "LIMIT ? " + + "OFFSET ?;" + + rows, err := db.Query(query, subreact, subreact, amount, page*amount) + if err != nil { + return nil, err + } + defer rows.Close() + + posts := make([]Post, 0) + for rows.Next() { + var post Post + if err = rows.Scan( + &post.Uid, + &post.Author, + &post.TimeUpdated, + &post.Subreact, + &post.Title, + &post.Contents); err != nil { + + if err == sql.ErrNoRows { + return posts, nil + } + + return nil, err + } + posts = append(posts, post) + } + + return posts, nil +} diff --git a/src/server/database/user.go b/src/server/database/user.go new file mode 100644 index 0000000..14530ae --- /dev/null +++ b/src/server/database/user.go @@ -0,0 +1,55 @@ +package database + +import ( + "database/sql" +) + +type User struct { + Uid int + Email string + Username *string + Password_hash string + Password_salt string +} + +// Gets a user struct from an email, returns nil-nil if the user did not exist. +func MaybeGetUser(email string) (*User, error) { + var db = GetDb() + + row := db.QueryRow( + "SELECT Users.uid, Users.email, Users.username, Users.password_hash, Users.password_salt "+ + "FROM Users "+ + "WHERE Users.email = ?;", email) + + var user User + if err := row.Scan( + &user.Uid, + &user.Email, + &user.Username, + &user.Password_hash, + &user.Password_salt); err != nil { + + if err != sql.ErrNoRows { + return nil, err + } + + return nil, nil + } + + return &user, nil +} + +// Writes a new user into the database, returns the user's new uid if no error occurred. +func WriteNewUser(email string, password_hash string, password_salt string) (int, error) { + var db = GetDb() + + var uid int + row := db.QueryRow("INSERT INTO Users "+ + "(email, password_hash, password_salt) "+ + "VALUES(?, ?, ?) RETURNING Users.uid;", email, password_hash, password_salt) + if err := row.Scan(&uid); err != nil { + return 0, err + } + + return uid, nil +} diff --git a/src/server/go.mod b/src/server/go.mod new file mode 100644 index 0000000..14610ea --- /dev/null +++ b/src/server/go.mod @@ -0,0 +1,11 @@ +module server + +go 1.18 + +require github.com/mattn/go-sqlite3 v1.14.16 + +require ( + github.com/golang-jwt/jwt/v4 v4.4.3 // indirect + golang.org/x/crypto v0.4.0 // indirect + golang.org/x/image v0.4.0 // indirect +) diff --git a/src/server/go.sum b/src/server/go.sum new file mode 100644 index 0000000..78079a8 --- /dev/null +++ b/src/server/go.sum @@ -0,0 +1,32 @@ +github.com/golang-jwt/jwt/v4 v4.4.3 h1:Hxl6lhQFj4AnOX6MLrsCb/+7tCj7DxP7VA+2rDIq5AU= +github.com/golang-jwt/jwt/v4 v4.4.3/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= +github.com/mattn/go-sqlite3 v1.14.16 h1:yOQRA0RpS5PFz/oikGwBEqvAWhWg5ufRz4ETLjwpU1Y= +github.com/mattn/go-sqlite3 v1.14.16/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.4.0 h1:UVQgzMY87xqpKNgb+kDsll2Igd33HszWHFLmpaRMq/8= +golang.org/x/crypto v0.4.0/go.mod h1:3quD/ATkf6oY+rnes5c3ExXTbLc8mueNue5/DoinL80= +golang.org/x/image v0.4.0 h1:x1RWAiZIvERqkltrFjtQP1ycmiR5pmhjtCfVOtdURuQ= +golang.org/x/image v0.4.0/go.mod h1:FVC7BI/5Ym8R25iw5OLsgshdUBbT1h5jZTpA+mvAdZ4= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/src/server/handlers/image.go b/src/server/handlers/image.go new file mode 100644 index 0000000..99c9c18 --- /dev/null +++ b/src/server/handlers/image.go @@ -0,0 +1,41 @@ +package handlers + +import ( + "net/http" + "regexp" + "server/database" + "server/helper" + "strconv" +) + +func Image(writer http.ResponseWriter, request *http.Request) { + if request.Method != "GET" { + helper.WriteErrorJson("expected GET method", writer, http.StatusBadRequest) + return + } + + re := regexp.MustCompile(`^/image/([0-9]+).png$`) + submatches := re.FindStringSubmatch(request.URL.Path) + if len(submatches) != 2 { + http.Error(writer, "invalid URL", http.StatusBadRequest) + return + } + + uid, err := strconv.Atoi(submatches[1]) + if err != nil { + helper.WriteInternalError(err, writer) + return + } + + image_blob, err := database.GetImage(uid, request.URL.Query().Get("thumbnail") == "1") + if err != nil { + helper.WriteInternalError(err, writer) + return + } + if image_blob == nil { + http.Error(writer, "resource not found", http.StatusNotFound) + return + } + + writer.Write(image_blob) +} diff --git a/src/server/handlers/login.go b/src/server/handlers/login.go new file mode 100644 index 0000000..745e64a --- /dev/null +++ b/src/server/handlers/login.go @@ -0,0 +1,55 @@ +package handlers + +import ( + "encoding/json" + "net/http" + + "server/database" + "server/helper" +) + +type loginRequest struct { + Email string + Password string +} + +func Login(writer http.ResponseWriter, request *http.Request) { + if request.Method != "POST" { + helper.WriteErrorJson("expected POST method", writer, http.StatusBadRequest) + return + } + + var login_request loginRequest + err := json.NewDecoder(request.Body).Decode(&login_request) + if err != nil { + helper.WriteErrorJson(err.Error(), writer, http.StatusBadRequest) + return + } + + user, err := database.MaybeGetUser(login_request.Email) + if err != nil { + helper.WriteInternalErrorJson(err, writer) + return + } + if user == nil { + helper.WriteErrorJson("incorrect email or password", writer, http.StatusForbidden) + return + } + + hash, err := helper.GenerateHash(login_request.Password, user.Password_salt) + if err != nil { + helper.WriteInternalErrorJson(err, writer) + return + } + if hash != user.Password_hash { + helper.WriteErrorJson("incorrect email or password", writer, http.StatusForbidden) + return + } + + // Login is successful, issue a valid jwt. + err = helper.IssueToken(user.Uid, writer) + if err != nil { + helper.WriteInternalErrorJson(err, writer) + return + } +} diff --git a/src/server/handlers/logout.go b/src/server/handlers/logout.go new file mode 100644 index 0000000..d3c8b9b --- /dev/null +++ b/src/server/handlers/logout.go @@ -0,0 +1,20 @@ +package handlers + +import ( + "net/http" + "server/helper" + "time" +) + +func Logout(writer http.ResponseWriter, request *http.Request) { + if request.Method != "POST" { + helper.WriteErrorJson("expected POST method", writer, http.StatusBadRequest) + return + } + + http.SetCookie(writer, &http.Cookie{ + Name: "token", + Expires: time.Time{}, // zero value for time + Path: "/", + }) +} diff --git a/src/server/handlers/post.go b/src/server/handlers/post.go new file mode 100644 index 0000000..2c014ca --- /dev/null +++ b/src/server/handlers/post.go @@ -0,0 +1,121 @@ +package handlers + +import ( + "bytes" + "encoding/base64" + "encoding/json" + "image" + _ "image/gif" + _ "image/jpeg" + "image/png" + "strconv" + + "golang.org/x/image/draw" + + "net/http" + "server/database" + "server/helper" +) + +const thumbnail_size = 128 + +func makeThumbnail(source image.Image) ([]byte, error) { + dest := image.NewRGBA(image.Rect(0, 0, thumbnail_size, thumbnail_size)) + draw.NearestNeighbor.Scale(dest, dest.Rect, source, source.Bounds(), draw.Over, nil) + + var buffer bytes.Buffer + err := png.Encode(&buffer, dest) + if err != nil { + return nil, err + } + return buffer.Bytes(), nil +} + +type postRequest struct { + Title string + Contents string + Subreact string + File string +} + +type postResponse struct { + Uid int `json:"uid"` +} + +func Post(writer http.ResponseWriter, request *http.Request) { + if request.Method != "POST" { + helper.WriteErrorJson("expected GET method", writer, http.StatusBadRequest) + return + } + + claims := helper.GetValidClaims(writer, request) + if claims == nil { + return + } + user_uid, err := strconv.Atoi(claims.Subject) // TODO UID + if err != nil { + helper.WriteInternalErrorJson(err, writer) + return + } + + var post_request postRequest + err = json.NewDecoder(request.Body).Decode(&post_request) + if err != nil { + helper.WriteErrorJson(err.Error(), writer, http.StatusBadRequest) + return + } + + post_request.Title = helper.CleanTitle(post_request.Title) + if !helper.IsValidRange(post_request.Title, "title", 8, 128, writer) { + return + } + + post_request.Contents = helper.CleanContents(post_request.Contents) + if !helper.IsValidRange(post_request.Contents, "contents", 8, 2048, writer) { + return + } + + if !helper.IsValidSubreact(post_request.Subreact, writer) { + return + } + + image_blob, err := base64.StdEncoding.DecodeString(post_request.File) + if err != nil { + helper.WriteErrorJson("failed to decode image from base64", writer, http.StatusBadRequest) + return + } + image, format, err := image.Decode(bytes.NewReader(image_blob)) + if err != nil { + helper.WriteErrorJson("failed to decode image", writer, http.StatusBadRequest) + return + } + if !helper.IsImageSupported(image, format, writer) { + return + } + + thumbnail_blob, err := makeThumbnail(image) + if err != nil { + helper.WriteInternalErrorJson(err, writer) + return + } + + image_uid, err := database.WritePost(user_uid, + nil, + post_request.Title, + post_request.Contents, + post_request.Subreact, + image_blob, + thumbnail_blob) + if err != nil { + helper.WriteInternalErrorJson(err, writer) + return + } + + resp, err := json.Marshal(postResponse{Uid: image_uid}) + if err != nil { + helper.WriteInternalErrorJson(err, writer) + return + } + + writer.Write(resp) +} diff --git a/src/server/handlers/posts.go b/src/server/handlers/posts.go new file mode 100644 index 0000000..00144db --- /dev/null +++ b/src/server/handlers/posts.go @@ -0,0 +1,60 @@ +package handlers + +import ( + "encoding/json" + "net/http" + "strconv" + + "server/database" + "server/helper" +) + +type postsResponse struct { + Posts []database.Post `json:"posts"` +} + +func Posts(writer http.ResponseWriter, request *http.Request) { + if request.Method != "GET" { + helper.WriteErrorJson("expected GET method", writer, http.StatusBadRequest) + return + } + + page, err := strconv.Atoi(request.URL.Query().Get("page")) + if err != nil { + helper.WriteErrorJson("expected page parameter", writer, http.StatusBadRequest) + return + } + if page < 0 { + helper.WriteErrorJson("expected page parameter >= 0", writer, http.StatusBadRequest) + return + } + + amount, err := strconv.Atoi(request.URL.Query().Get("amount")) + if err != nil { + helper.WriteErrorJson("expected amount parameter", writer, http.StatusBadRequest) + return + } + if amount <= 0 { + helper.WriteErrorJson("expected amount parameter > 0", writer, http.StatusBadRequest) + return + } + + subreact := request.URL.Query().Get("subreact") + if !helper.IsValidSubreact(subreact, writer) { + return + } + + posts, err := database.GetPosts(subreact, page, amount) + if err != nil { + helper.WriteInternalErrorJson(err, writer) + return + } + + resp, err := json.Marshal(postsResponse{Posts: posts}) + if err != nil { + helper.WriteInternalErrorJson(err, writer) + return + } + + writer.Write(resp) +} diff --git a/src/server/handlers/refresh.go b/src/server/handlers/refresh.go new file mode 100644 index 0000000..bc60aa7 --- /dev/null +++ b/src/server/handlers/refresh.go @@ -0,0 +1,32 @@ +package handlers + +import ( + "net/http" + "strconv" + + "server/helper" +) + +// Extends the current token's lifetime (by replacing it with a newer one). +func Refresh(writer http.ResponseWriter, request *http.Request) { + if request.Method != "POST" { + helper.WriteErrorJson("expected POST method", writer, http.StatusBadRequest) + return + } + + claims := helper.GetValidClaims(writer, request) + if claims == nil { + return + } + + uid, err := strconv.Atoi(claims.Subject) + if err != nil { + helper.WriteInternalErrorJson(err, writer) + return + } + err = helper.IssueToken(uid, writer) + if err != nil { + helper.WriteInternalErrorJson(err, writer) + return + } +} diff --git a/src/server/handlers/signup.go b/src/server/handlers/signup.go new file mode 100644 index 0000000..e92b869 --- /dev/null +++ b/src/server/handlers/signup.go @@ -0,0 +1,75 @@ +package handlers + +import ( + "encoding/json" + "net/http" + + "server/database" + "server/helper" +) + +type signupRequest struct { + Email string + Password string +} + +func Signup(writer http.ResponseWriter, request *http.Request) { + if request.Method != "POST" { + helper.WriteErrorJson("expected POST method", writer, http.StatusBadRequest) + return + } + + var signup_request signupRequest + err := json.NewDecoder(request.Body).Decode(&signup_request) + if err != nil { + helper.WriteErrorJson(err.Error(), writer, http.StatusBadRequest) + return + } + + if len(signup_request.Email) < 3 || len(signup_request.Email) > 254 { + helper.WriteErrorJson("invalid email address", writer, http.StatusBadRequest) + return + } + if len(signup_request.Password) < 8 { + helper.WriteErrorJson("password too short", writer, http.StatusBadRequest) + return + } + if len(signup_request.Password) > 64 { + helper.WriteErrorJson("password too long", writer, http.StatusBadRequest) + return + } + + user, err := database.MaybeGetUser(signup_request.Email) + if err != nil { + helper.WriteInternalErrorJson(err, writer) + return + } + if user != nil { + helper.WriteErrorJson("a user with that email already exists", writer, http.StatusForbidden) + return + } + + salt, err := helper.GenerateSalt() + if err != nil { + helper.WriteInternalErrorJson(err, writer) + return + } + + hash, err := helper.GenerateHash(signup_request.Password, salt) + if err != nil { + helper.WriteInternalErrorJson(err, writer) + return + } + + uid, err := database.WriteNewUser(signup_request.Email, hash, salt) + if err != nil { + helper.WriteInternalErrorJson(err, writer) + return + } + + err = helper.IssueToken(uid, writer) + if err != nil { + helper.WriteInternalErrorJson(err, writer) + return + } +} diff --git a/src/server/helper/clean.go b/src/server/helper/clean.go new file mode 100644 index 0000000..35937de --- /dev/null +++ b/src/server/helper/clean.go @@ -0,0 +1,37 @@ +package helper + +import ( + "regexp" +) + +func removeTrailingWhitespace(str string) string { + re := regexp.MustCompile(`(^\s+)|(\s+$)`) + return re.ReplaceAllString(str, "") +} + +func removeDuplicateWhitespace(str string) string { + re := regexp.MustCompile(`\s{2,}`) + return re.ReplaceAllString(str, " ") +} + +func removeNewlines(str string) string { + re := regexp.MustCompile(`\n+`) + return re.ReplaceAllString(str, "") +} + +func removeDuplicateNewlines(str string) string { + re := regexp.MustCompile(`\n{2,}`) + return re.ReplaceAllString(str, "\n") +} + +func CleanTitle(title string) string { + title = removeDuplicateWhitespace(title) + title = removeNewlines(title) + return removeTrailingWhitespace(title) +} + +func CleanContents(contents string) string { + contents = removeDuplicateWhitespace(contents) + contents = removeDuplicateNewlines(contents) + return removeTrailingWhitespace(contents) +} diff --git a/src/server/helper/crypto.go b/src/server/helper/crypto.go new file mode 100644 index 0000000..d676fba --- /dev/null +++ b/src/server/helper/crypto.go @@ -0,0 +1,46 @@ +package helper + +import ( + "crypto/rand" + "encoding/base64" + + "golang.org/x/crypto/scrypt" +) + +// Recommended https://pkg.go.dev/golang.org/x/crypto/scrypt +const SCRYPT_N = 32768 +const SCRYPT_R = 8 +const SCRYPT_P = 1 +const SCRYPT_L = 64 + +const SALT_LENGTH = SCRYPT_L + +func getEncoding() *base64.Encoding { + return base64.StdEncoding.WithPadding(base64.NoPadding) +} + +// Generates truly random bytes. +func generateRandomBytes(len int) ([]byte, error) { + salt := make([]byte, len) + _, err := rand.Read(salt) + if err != nil { + return nil, err + } + return salt, nil +} + +func GenerateSalt() (string, error) { + salt, err := generateRandomBytes(SALT_LENGTH) + if err != nil { + return "", err + } + return getEncoding().EncodeToString(salt), nil +} + +func GenerateHash(password string, salt string) (string, error) { + hash, err := scrypt.Key([]byte(password), []byte(salt), SCRYPT_N, SCRYPT_R, SCRYPT_P, SCRYPT_L) + if err != nil { + return "", err + } + return getEncoding().EncodeToString(hash), nil +} diff --git a/src/server/helper/error.go b/src/server/helper/error.go new file mode 100644 index 0000000..8c76304 --- /dev/null +++ b/src/server/helper/error.go @@ -0,0 +1,36 @@ +package helper + +import ( + "encoding/json" + "log" + "net/http" +) + +type jsonError struct { + Message string `json:"error"` +} + +func WriteInternalError(err error, writer http.ResponseWriter) { + log.Printf("internal server error: %s\n", err) + http.Error(writer, "internal server error", http.StatusInternalServerError) +} + +func WriteErrorJson(message string, writer http.ResponseWriter, code int) { + json_error := &jsonError{ + Message: message, + } + + marshal, err := json.Marshal(json_error) + if err != nil { + http.Error(writer, err.Error(), http.StatusInternalServerError) + return + } + + http.Error(writer, string(marshal), code) +} + +// Logs the error and responds with a generic server error. +func WriteInternalErrorJson(err error, writer http.ResponseWriter) { + log.Printf("internal server error: %s\n", err) + WriteErrorJson("internal server error", writer, http.StatusInternalServerError) +} diff --git a/src/server/helper/jwt.go b/src/server/helper/jwt.go new file mode 100644 index 0000000..ae127bd --- /dev/null +++ b/src/server/helper/jwt.go @@ -0,0 +1,66 @@ +package helper + +import ( + "net/http" + "strconv" + "time" + + "github.com/golang-jwt/jwt/v4" +) + +// WARNING this key should be secret and constant between deployments. +// If you use this software in the wild, at the very least change this value! +// https://www.sohamkamani.com/golang/jwt-authentication/ +var jwt_key = []byte("iph7noo1ohQuam5sou5wa2aeChixo7") + +func IssueToken(uid int, writer http.ResponseWriter) error { + expiration := time.Now().Add(60 * time.Minute) + token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.RegisteredClaims{ + Subject: strconv.Itoa(uid), + ExpiresAt: jwt.NewNumericDate(expiration), + Issuer: "react-go-forum", + }) + token_string, err := token.SignedString(jwt_key) + if err != nil { + return err + } + + http.SetCookie(writer, &http.Cookie{ + Name: "token", + Value: token_string, + Expires: expiration, + SameSite: http.SameSiteStrictMode, + Path: "/", + }) + return nil +} + +// Returns a non-nil RegisteredClaims if valid, nil otherwise. Handles responding to bad tokens. +func GetValidClaims(writer http.ResponseWriter, request *http.Request) *jwt.RegisteredClaims { + cookie, err := request.Cookie("token") + if err != nil { + if err == http.ErrNoCookie { + WriteErrorJson("access denied", writer, http.StatusUnauthorized) + return nil + } + + WriteInternalErrorJson(err, writer) + return nil + } + + claims := &jwt.RegisteredClaims{} + token, err := jwt.ParseWithClaims(cookie.Value, claims, func(token *jwt.Token) (interface{}, error) { + return jwt_key, nil + }) + if err != nil { + WriteInternalErrorJson(err, writer) + return nil + } + + if !token.Valid { + WriteErrorJson("access denied", writer, http.StatusUnauthorized) + return nil + } + + return claims +} diff --git a/src/server/helper/valid.go b/src/server/helper/valid.go new file mode 100644 index 0000000..9363747 --- /dev/null +++ b/src/server/helper/valid.go @@ -0,0 +1,70 @@ +package helper + +import ( + "image" + "net/http" +) + +// Checks if the subreact exists, responds with a bad status when false. +func IsValidSubreact(subreact string, writer http.ResponseWriter) bool { + switch subreact { + case "": + fallthrough + case "t": + fallthrough + case "g": + fallthrough + case "k": + fallthrough + case "p": + fallthrough + case "a": + fallthrough + case "pr": + fallthrough + case "m": + break + default: + WriteErrorJson("invalid subreact", writer, http.StatusBadRequest) + return false + } + return true +} + +// Range checks the length of the argument, responds with a bad status when false. +func IsValidRange(str string, name string, min int, max int, writer http.ResponseWriter) bool { + if len(str) < min { + WriteErrorJson(name+" too short", writer, http.StatusBadRequest) + return false + } + if len(str) > max { + WriteErrorJson(name+" too long", writer, http.StatusBadRequest) + return false + } + return true +} + +// Checks that the image has supported properties, responds with a bad status when false. +func IsImageSupported(image image.Image, format string, writer http.ResponseWriter) bool { + if bounds := image.Bounds(); bounds.Dx() > 4096 || bounds.Dy() > 4096 { + WriteErrorJson("image dimensions too large", writer, http.StatusBadRequest) + return false + } else if bounds.Dx() < 256 || bounds.Dy() < 256 { + WriteErrorJson("image dimensions too small", writer, http.StatusBadRequest) + return false + } + + switch format { + case "png": + fallthrough + case "jpeg": + fallthrough + case "gif": + break + default: + WriteErrorJson("image format no recognised", writer, http.StatusBadRequest) + return false + } + + return true +} diff --git a/src/server/main.go b/src/server/main.go new file mode 100644 index 0000000..126edeb --- /dev/null +++ b/src/server/main.go @@ -0,0 +1,42 @@ +package main + +import ( + "log" + "net/http" + "time" + + "server/database" + "server/handlers" +) + +func main() { + const database_path string = "./react_go.db" + const address string = "0.0.0.0" + const port string = "80" + + database.Init(database_path) + defer database.Close() + log.Printf("database initialised at %s\n", database_path) + + mux := http.NewServeMux() + spa := spaHandler{staticPath: "./dist"} + mux.Handle("/", spa) + mux.HandleFunc("/api/login", handlers.Login) + mux.HandleFunc("/api/logout", handlers.Logout) + mux.HandleFunc("/api/signup", handlers.Signup) + mux.HandleFunc("/api/refresh", handlers.Refresh) + mux.HandleFunc("/api/posts", handlers.Posts) + mux.HandleFunc("/api/post", handlers.Post) + mux.HandleFunc("/image/", handlers.Image) + + server := &http.Server{ + Addr: address + ":" + port, + Handler: mux, + /* This is good practice: https://github.com/gorilla/mux */ + WriteTimeout: 15 * time.Second, + ReadTimeout: 15 * time.Second, + } + + log.Printf("server listening on %s:%s\n", address, port) + log.Fatal(server.ListenAndServe()) +} diff --git a/src/server/spa.go b/src/server/spa.go new file mode 100644 index 0000000..1dd1e96 --- /dev/null +++ b/src/server/spa.go @@ -0,0 +1,37 @@ +package main + +import ( + "net/http" + "os" + "path/filepath" +) + +// https://github.com/gorilla/mux#serving-single-page-applications +type spaHandler struct { + staticPath string +} + +func (h spaHandler) ServeHTTP(writer http.ResponseWriter, request *http.Request) { + path, err := filepath.Abs(request.URL.Path) + if err != nil { + http.Error(writer, err.Error(), http.StatusBadRequest) + } + + path = filepath.Join(h.staticPath, path) + if _, err = os.Stat(path); os.IsNotExist(err) { + + serve_path := filepath.Base(path) + if len(filepath.Ext(path)) == 0 { + serve_path = "index.html" + } + serve_path = filepath.Join(h.staticPath, serve_path) + + http.ServeFile(writer, request, serve_path) + return + } else if err != nil { + http.Error(writer, err.Error(), http.StatusInternalServerError) + return + } + + http.FileServer(http.Dir(h.staticPath)).ServeHTTP(writer, request) +} |
