aboutsummaryrefslogtreecommitdiff
path: root/src/server
diff options
context:
space:
mode:
Diffstat (limited to 'src/server')
-rw-r--r--src/server/database/database.go74
-rw-r--r--src/server/database/image.go31
-rw-r--r--src/server/database/post.go28
-rw-r--r--src/server/database/posts.go63
-rw-r--r--src/server/database/user.go55
-rw-r--r--src/server/go.mod11
-rw-r--r--src/server/go.sum32
-rw-r--r--src/server/handlers/image.go41
-rw-r--r--src/server/handlers/login.go55
-rw-r--r--src/server/handlers/logout.go20
-rw-r--r--src/server/handlers/post.go121
-rw-r--r--src/server/handlers/posts.go60
-rw-r--r--src/server/handlers/refresh.go32
-rw-r--r--src/server/handlers/signup.go75
-rw-r--r--src/server/helper/clean.go37
-rw-r--r--src/server/helper/crypto.go46
-rw-r--r--src/server/helper/error.go36
-rw-r--r--src/server/helper/jwt.go66
-rw-r--r--src/server/helper/valid.go70
-rw-r--r--src/server/main.go42
-rw-r--r--src/server/spa.go37
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)
+}