From 93dfe2be64e8658839bcfe5356adf35f8cde7075 Mon Sep 17 00:00:00 2001 From: Nicolas James Date: Thu, 13 Feb 2025 18:04:18 +1100 Subject: initial commit --- .gitignore | 5 + .vscode/tasks.json | 24 +++ README.md | 31 +++ package.json | 32 +++ run_server.sh | 23 ++ src/server/database/database.go | 74 +++++++ src/server/database/image.go | 31 +++ src/server/database/post.go | 28 +++ src/server/database/posts.go | 63 ++++++ src/server/database/user.go | 55 +++++ src/server/go.mod | 11 + src/server/go.sum | 32 +++ src/server/handlers/image.go | 41 ++++ src/server/handlers/login.go | 55 +++++ src/server/handlers/logout.go | 20 ++ src/server/handlers/post.go | 121 +++++++++++ src/server/handlers/posts.go | 60 ++++++ src/server/handlers/refresh.go | 32 +++ src/server/handlers/signup.go | 75 +++++++ src/server/helper/clean.go | 37 ++++ src/server/helper/crypto.go | 46 ++++ src/server/helper/error.go | 36 ++++ src/server/helper/jwt.go | 66 ++++++ src/server/helper/valid.go | 70 ++++++ src/server/main.go | 42 ++++ src/server/spa.go | 37 ++++ src/web/assets/favicon.ico | Bin 0 -> 766 bytes src/web/assets/images/logo.png | Bin 0 -> 718 bytes src/web/assets/src/favicon.xcf | Bin 0 -> 1697 bytes src/web/assets/svg/burger.svg | 1 + src/web/assets/svg/half_circle.svg | 64 ++++++ src/web/components/app/App.css | 19 ++ src/web/components/app/App.jsx | 29 +++ src/web/components/button/Button.css | 15 ++ src/web/components/button/Button.jsx | 10 + src/web/components/card/Card.css | 5 + src/web/components/card/Card.jsx | 10 + src/web/components/comments/Comments.css | 30 +++ src/web/components/comments/Comments.jsx | 44 ++++ src/web/components/darken/Darken.css | 14 ++ src/web/components/darken/Darken.jsx | 13 ++ src/web/components/forum/Forum.css | 21 ++ src/web/components/forum/Forum.jsx | 14 ++ src/web/components/input/Input.css | 31 +++ src/web/components/input/Input.jsx | 38 ++++ src/web/components/loading/Loading.css | 13 ++ src/web/components/loading/Loading.jsx | 13 ++ src/web/components/login/Login.css | 118 +++++++++++ src/web/components/login/Login.jsx | 218 +++++++++++++++++++ src/web/components/nav_bar/NavBar.css | 304 +++++++++++++++++++++++++++ src/web/components/nav_bar/NavBar.jsx | 174 +++++++++++++++ src/web/components/not_found/NotFound.css | 0 src/web/components/not_found/NotFound.jsx | 10 + src/web/components/post/Post.css | 91 ++++++++ src/web/components/post/Post.jsx | 63 ++++++ src/web/components/posts/Posts.css | 84 ++++++++ src/web/components/posts/Posts.jsx | 163 ++++++++++++++ src/web/components/settings/Settings.css | 0 src/web/components/settings/Settings.jsx | 9 + src/web/components/submission/Submission.css | 146 +++++++++++++ src/web/components/submission/Submission.jsx | 221 +++++++++++++++++++ src/web/contexts/StateContext.jsx | 17 ++ src/web/contexts/UserContext.jsx | 16 ++ src/web/helpers/Auth.jsx | 66 ++++++ src/web/helpers/Input.jsx | 31 +++ src/web/helpers/Location.jsx | 29 +++ src/web/index.css | 60 ++++++ src/web/index.html | 16 ++ src/web/index.jsx | 24 +++ src/web/reset.css | 47 +++++ src/web/styles/animations.css | 21 ++ webpack.config.js | 59 ++++++ 72 files changed, 3518 insertions(+) create mode 100644 .gitignore create mode 100644 .vscode/tasks.json create mode 100644 README.md create mode 100644 package.json create mode 100755 run_server.sh create mode 100644 src/server/database/database.go create mode 100644 src/server/database/image.go create mode 100644 src/server/database/post.go create mode 100644 src/server/database/posts.go create mode 100644 src/server/database/user.go create mode 100644 src/server/go.mod create mode 100644 src/server/go.sum create mode 100644 src/server/handlers/image.go create mode 100644 src/server/handlers/login.go create mode 100644 src/server/handlers/logout.go create mode 100644 src/server/handlers/post.go create mode 100644 src/server/handlers/posts.go create mode 100644 src/server/handlers/refresh.go create mode 100644 src/server/handlers/signup.go create mode 100644 src/server/helper/clean.go create mode 100644 src/server/helper/crypto.go create mode 100644 src/server/helper/error.go create mode 100644 src/server/helper/jwt.go create mode 100644 src/server/helper/valid.go create mode 100644 src/server/main.go create mode 100644 src/server/spa.go create mode 100644 src/web/assets/favicon.ico create mode 100644 src/web/assets/images/logo.png create mode 100644 src/web/assets/src/favicon.xcf create mode 100644 src/web/assets/svg/burger.svg create mode 100644 src/web/assets/svg/half_circle.svg create mode 100644 src/web/components/app/App.css create mode 100644 src/web/components/app/App.jsx create mode 100644 src/web/components/button/Button.css create mode 100644 src/web/components/button/Button.jsx create mode 100644 src/web/components/card/Card.css create mode 100644 src/web/components/card/Card.jsx create mode 100644 src/web/components/comments/Comments.css create mode 100644 src/web/components/comments/Comments.jsx create mode 100644 src/web/components/darken/Darken.css create mode 100644 src/web/components/darken/Darken.jsx create mode 100644 src/web/components/forum/Forum.css create mode 100644 src/web/components/forum/Forum.jsx create mode 100644 src/web/components/input/Input.css create mode 100644 src/web/components/input/Input.jsx create mode 100644 src/web/components/loading/Loading.css create mode 100644 src/web/components/loading/Loading.jsx create mode 100644 src/web/components/login/Login.css create mode 100644 src/web/components/login/Login.jsx create mode 100644 src/web/components/nav_bar/NavBar.css create mode 100644 src/web/components/nav_bar/NavBar.jsx create mode 100644 src/web/components/not_found/NotFound.css create mode 100644 src/web/components/not_found/NotFound.jsx create mode 100644 src/web/components/post/Post.css create mode 100644 src/web/components/post/Post.jsx create mode 100644 src/web/components/posts/Posts.css create mode 100644 src/web/components/posts/Posts.jsx create mode 100644 src/web/components/settings/Settings.css create mode 100644 src/web/components/settings/Settings.jsx create mode 100644 src/web/components/submission/Submission.css create mode 100644 src/web/components/submission/Submission.jsx create mode 100644 src/web/contexts/StateContext.jsx create mode 100644 src/web/contexts/UserContext.jsx create mode 100644 src/web/helpers/Auth.jsx create mode 100644 src/web/helpers/Input.jsx create mode 100644 src/web/helpers/Location.jsx create mode 100644 src/web/index.css create mode 100644 src/web/index.html create mode 100644 src/web/index.jsx create mode 100644 src/web/reset.css create mode 100644 src/web/styles/animations.css create mode 100644 webpack.config.js diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..46470df --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +/dist +/node_modules +/server +package-lock.json +react_go.db \ No newline at end of file diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 0000000..df5f984 --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,24 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "type": "shell", + "group": { + "kind": "build", + "isDefault": true + }, + "command": "./run_server.sh", + "label": "./run_server.sh", + "detail": "webpack --mode=production", + "problemMatcher": [] + }, + { + "type": "npm", + "script": "build_release", + "group": "build", + "problemMatcher": [], + "label": "npm: build_release", + "detail": "webpack --mode=production" + } + ] +} \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..f744995 --- /dev/null +++ b/README.md @@ -0,0 +1,31 @@ +# react-go-forum + +A self-contained mobile-first forum written in React and Go. + +## Dependencies + +- [npm](https://npmjs.com) : npm is a package manager for the JavaScript programming language. +- [Go](https://go.dev) : Go is a statically typed, compiled programming language. + +## Building from Source + +First, clone the repository using Git. +```console + $ git clone https://salvestromartin.com:443/nJ3ahxac/react-go-forum.git +``` + +Then compile from source via npm after moving into the project directory. + +```console + $ cd ./react-go-forum + $ npm update +``` + +## Usage + +A helper script has been provided which compiles the React and Go code and runs +the server. + +```console + $ ./run_server.sh +``` diff --git a/package.json b/package.json new file mode 100644 index 0000000..b6be086 --- /dev/null +++ b/package.json @@ -0,0 +1,32 @@ +{ + "private": true, + "scripts": { + "build": "webpack --mode=development", + "build_release": "webpack --mode=production" + }, + "devDependencies": { + "@babel/plugin-transform-modules-amd": "^7.16.5", + "@babel/preset-env": "^7.16.5", + "@babel/preset-react": "^7.16.5", + "babel-loader": "^8.2.3", + "css-loader": "^6.7.1", + "html-webpack-plugin": "^4.5.2", + "style-loader": "^3.3.1", + "webpack": "^5.65.0", + "webpack-cli": "^4.9.1" + }, + "dependencies": { + "@babel/plugin-syntax-dynamic-import": "^7.8.3", + "@fontsource/heebo": "^4.5.14", + "@fortawesome/free-solid-svg-icons": "^6.2.1", + "@fortawesome/react-fontawesome": "^0.2.0", + "base64-arraybuffer": "^1.0.2", + "dayjs": "^1.11.7", + "jwt-decode": "^3.1.2", + "prop-types": "^15.8.1", + "react": "^17.0.2", + "react-dom": "^17.0.2", + "react-router-dom": "^6.2.1", + "react-use-cookie": "^1.4.0" + } +} diff --git a/run_server.sh b/run_server.sh new file mode 100755 index 0000000..cc74379 --- /dev/null +++ b/run_server.sh @@ -0,0 +1,23 @@ +#!/bin/bash + +SERVER_DIR=$"./src/server/" +SERVER_NAME=$"server" +SCRIPT_DIR=$(dirname "${BASH_SOURCE[0]}") + +cd "${SCRIPT_DIR}" || exit +if ! (npm run build) ; then + printf "\x1b[31mnpm build failed! stopping\n\x1b[0m" >&2 + exit 1 +fi + +if ! (cd "${SERVER_DIR}" && go build && mv "${SERVER_NAME}" ../../); then + printf "\x1b[31mgo build failed! stopping\n\x1b[0m" >&2 + exit 1 +fi + +if ! (sudo setcap 'cap_net_bind_service=+ep' "${SERVER_NAME}"); then + # We don't return here because this might not be necessary. + printf "\x1b[31mfailed to provide setcap privileges to binary\x1b[0m" >&2 +fi + +./${SERVER_NAME} 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) +} diff --git a/src/web/assets/favicon.ico b/src/web/assets/favicon.ico new file mode 100644 index 0000000..4dda993 Binary files /dev/null and b/src/web/assets/favicon.ico differ diff --git a/src/web/assets/images/logo.png b/src/web/assets/images/logo.png new file mode 100644 index 0000000..c4a8c86 Binary files /dev/null and b/src/web/assets/images/logo.png differ diff --git a/src/web/assets/src/favicon.xcf b/src/web/assets/src/favicon.xcf new file mode 100644 index 0000000..a62432f Binary files /dev/null and b/src/web/assets/src/favicon.xcf differ diff --git a/src/web/assets/svg/burger.svg b/src/web/assets/svg/burger.svg new file mode 100644 index 0000000..233bf21 --- /dev/null +++ b/src/web/assets/svg/burger.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/web/assets/svg/half_circle.svg b/src/web/assets/svg/half_circle.svg new file mode 100644 index 0000000..a31b646 --- /dev/null +++ b/src/web/assets/svg/half_circle.svg @@ -0,0 +1,64 @@ + + + + + + + + + + + + + + diff --git a/src/web/components/app/App.css b/src/web/components/app/App.css new file mode 100644 index 0000000..e211974 --- /dev/null +++ b/src/web/components/app/App.css @@ -0,0 +1,19 @@ + +.content { + /* sticky footer */ + flex-grow: 1; + flex-shrink: 0; + + display: flex; + flex-direction: column; + align-items: left; + + padding: 0; + background-color: var(--black-10); +} + +@media (min-width: 40em) { + .content { + padding: 0em 0em 0em var(--menu-width); + } +} \ No newline at end of file diff --git a/src/web/components/app/App.jsx b/src/web/components/app/App.jsx new file mode 100644 index 0000000..ffd2cc7 --- /dev/null +++ b/src/web/components/app/App.jsx @@ -0,0 +1,29 @@ +import React from "react"; +import {Route, Routes} from "react-router-dom"; + +import NavBar from "components/nav_bar/NavBar.jsx"; +import Forum from "components/forum/Forum.jsx"; +import NotFound from "components/not_found/NotFound.jsx"; +import Settings from "components/settings/Settings.jsx"; +import {UserContextProvider} from "contexts/UserContext.jsx"; +import {StateContextProvider} from "contexts/StateContext.jsx"; + +import styles from "./App.css"; + +export default function App() { + return ( + + + +
+ + } /> + } /> + + } /> + +
+
+
+ ); +} diff --git a/src/web/components/button/Button.css b/src/web/components/button/Button.css new file mode 100644 index 0000000..76c5450 --- /dev/null +++ b/src/web/components/button/Button.css @@ -0,0 +1,15 @@ +.button { + padding: 0.75em 1em; + + color: var(--black-98); + background-color: var(--brand-80); + border: 2px solid var(--brand-80); + border-radius: 2em; + font-family: inherit; + letter-spacing: inherit; + cursor: pointer; +} +.button:hover { + color: white; + background-color: var(--brand-90); +} \ No newline at end of file diff --git a/src/web/components/button/Button.jsx b/src/web/components/button/Button.jsx new file mode 100644 index 0000000..8d7de02 --- /dev/null +++ b/src/web/components/button/Button.jsx @@ -0,0 +1,10 @@ +import React from "react"; +import styles from "./Button.css"; + +export default function Button({className, children, ...others}) { + return ( + + ) +} \ No newline at end of file diff --git a/src/web/components/card/Card.css b/src/web/components/card/Card.css new file mode 100644 index 0000000..79d38ac --- /dev/null +++ b/src/web/components/card/Card.css @@ -0,0 +1,5 @@ +.card { + box-shadow: 0 0 0.2em 0.01em var(--black-10); + border-radius: 1.0em; + background-color: var(--black-10); +} \ No newline at end of file diff --git a/src/web/components/card/Card.jsx b/src/web/components/card/Card.jsx new file mode 100644 index 0000000..36c4f1d --- /dev/null +++ b/src/web/components/card/Card.jsx @@ -0,0 +1,10 @@ +import React from "react"; +import styles from "./Card.css"; + +export default function Card({children, className, ...others}) { + return ( +
+ {children} +
+ ); +} \ No newline at end of file diff --git a/src/web/components/comments/Comments.css b/src/web/components/comments/Comments.css new file mode 100644 index 0000000..402e4a2 --- /dev/null +++ b/src/web/components/comments/Comments.css @@ -0,0 +1,30 @@ +.container { + width: 100%; + position: relative; + + /*TODO*/ + display: none; + + justify-content: center; + padding: 1em 1em 0em 0em; + overflow: auto; + + color: var(--black-98); +} + +.card { + padding: 1em; +} + +.originalPost { + +} +.originalPost > h1 { + font-size: 1.8em; +} + +@media (min-width: 80em) { + .container { + display: flex; + } +} \ No newline at end of file diff --git a/src/web/components/comments/Comments.jsx b/src/web/components/comments/Comments.jsx new file mode 100644 index 0000000..66e0f06 --- /dev/null +++ b/src/web/components/comments/Comments.jsx @@ -0,0 +1,44 @@ +import React, {Fragment} from "react"; + +import Card from "components/card/Card.jsx"; + +import styles from "./Comments.css"; + +function OtherComments() { + return ( + + ); +} + +function OriginalComment() { + return ( +
+

Galahs in Australia

+

+ The galah is very common as a companion parrot or avicultural specimen around the world, although generally + absent from Australian aviaries, although permits are available to take a limited number of galahs from + the wild per year for avicultural purposes. When tame, it can be an affectionate and friendly bird that + can learn to talk, as well as mimic other sounds heard in its environment. While it is a noisy bird that may + be unsuitable for apartment living, it is comparatively quieter than other cockatoo species. Like most + parrots, the galah requires plenty of exercise and play time out of its cage as well as several hours of daily + social interaction with humans or other birds in order to thrive in captivity. It may also be prone to obesity + if not provided with a suitable, nutritionally-balanced diet. The World Parrot Trust recommends that captive + galahs should be kept in an aviary with a minimum length of 7 metres. + The breeding requirements include the use upright or tilted logs with a hollow some twenty to thirty + centimetres in diameter. Sand and finer grades of wood material are used to construct their nest, the + availability of eucalypt leaves for the nest lining is also suggested for captive breeding. +

+
+ ); +} + +export default function Comments() { + return ( +
+ + + + +
+ ); +} \ No newline at end of file diff --git a/src/web/components/darken/Darken.css b/src/web/components/darken/Darken.css new file mode 100644 index 0000000..bd3b151 --- /dev/null +++ b/src/web/components/darken/Darken.css @@ -0,0 +1,14 @@ +.darken { + visibility: hidden; + + position: fixed; + inset: 0; + + background-color: rgba(0, 0, 0, 0.0); + cursor: pointer; + transition: background-color var(--transition-time) ease-in-out; +} +.darkenActive { + visibility: initial; + background-color: rgba(0, 0, 0, 0.5); +} \ No newline at end of file diff --git a/src/web/components/darken/Darken.jsx b/src/web/components/darken/Darken.jsx new file mode 100644 index 0000000..7f46365 --- /dev/null +++ b/src/web/components/darken/Darken.jsx @@ -0,0 +1,13 @@ +import React from "react"; +import styles from "./Darken.css"; + +export default function Darken({active, className, ...others}) { + + return ( +
+ ); +} \ No newline at end of file diff --git a/src/web/components/forum/Forum.css b/src/web/components/forum/Forum.css new file mode 100644 index 0000000..4ba6dea --- /dev/null +++ b/src/web/components/forum/Forum.css @@ -0,0 +1,21 @@ +.container { + width: 100%; + + /* + We're using dvh, which is a newish feature that excludes the size of mobile + added menus. If we don't use this and instead use 100vh, we have to scroll + before we see some elements at the bottom of the page. + */ + height: calc(100vh - var(--nav-bar-height)); + height: calc(100dvh - var(--nav-bar-height)); + + display: flex; + + background-color: var(--black-15); +} + +@media(min-width: 40em) { + .container { + border-radius: 1em 0 0 0; + } +} diff --git a/src/web/components/forum/Forum.jsx b/src/web/components/forum/Forum.jsx new file mode 100644 index 0000000..eed0fe9 --- /dev/null +++ b/src/web/components/forum/Forum.jsx @@ -0,0 +1,14 @@ +import React from "react"; + +import Posts from "components/posts/Posts.jsx"; +import Comments from "components/comments/Comments.jsx"; +import styles from "./Forum.css"; + +export default function Forum() { + return ( +
+ + +
+ ); +} \ No newline at end of file diff --git a/src/web/components/input/Input.css b/src/web/components/input/Input.css new file mode 100644 index 0000000..a7da62d --- /dev/null +++ b/src/web/components/input/Input.css @@ -0,0 +1,31 @@ +.container { + display: flex; + flex-direction: column; +} + +.row { + width: 100%; + + display: flex; + justify-content: space-between; + align-items: flex-end; +} + +.input { + padding: 0.2rem 1rem; + margin: 0.5rem 0 1rem; + + background-color: transparent; + border-radius: 2em; + border: 2px solid var(--brand-80); + color: var(--black-98); + font-size: inherit; + font-family: inherit; + letter-spacing: inherit; + resize: none; +} + +.label { + font-size: 1.35em; + font-style: italic; +} diff --git a/src/web/components/input/Input.jsx b/src/web/components/input/Input.jsx new file mode 100644 index 0000000..2c7bc6e --- /dev/null +++ b/src/web/components/input/Input.jsx @@ -0,0 +1,38 @@ +import React from "react"; + +import styles from "./Input.css"; +import animations from "styles/animations.css"; + +function InputField({className, type, id, value, onChange, inputType}) { + // idk how to do this less uglily + switch (inputType) { + case "textarea": + return