Update: August 28th, 2022 | Due to Heroku's recent policy of canceling its free tiers, the project and its database are down indefinitely. All non-PII information has been preserved in this repository, and I hope one day I would find the time to relocate it. Until then, you can check the images and videos below or set-up using the given anonymized DB in the backup folder. Best, Atanas :)
Update: February, 2022 | Currently live at: unpopular-bulgaria.com
Unpopular (Непопулярно) is a full-stack web application that allows people to share interesting places in Bulgaria. Users can upload text and images into cards that present places. Automatic weather fetching, note-taking, map, comment section, and more are part of each card. Every registered user can share content, like, save, suggest edits to existing places, download and report content, and more. The account controls allow for a change of password, username, email, upload an avatar, and more. Account protection is at the forefront with account locking, multiple mechanisms ensuring security, minimal data collection, and more. Admin controls allow for user data control, place modification, report managing, etc.
- ReactJS - front-end library
- MaterialUI - UI library
- HCaptcha for preventing bots
- JSON Web Token for authentication through cookies and Local Storage (more on that in How it works)
- React Router for routing components
- Node.js - server
- Express - server-side framework
- PostgreSQL - database
- Cloudinary for saving and serving images
- SendGrid for sending mails
Additional libraries can be found in packege.json, but the major ones are:
- axios for fetching data
- geolib for coordinate verification
- leo-profanity - profanity filter for uploaded content
- moment.js - date calculations and visualizations in a local format
- password-validator - validating passwords based on criterias
- pigeon-maps - an alternative of Google Maps for displaying data in an interactive way
- qrcode.react for generating QR codes
- tsParticles - creating particle animations
- Tilty - creating tilting animations on components
- toast.js - UI notification component
- react-typewriter-effect - for creating typewrite animation in the App Bar
- share-buttons - share places in social medias given data
- Clone the repository
- By default, the project is intended to work on unpopular-bulgaria.com, which is why you should use the replace in files command to set it to a localhost and the desired port. NB: In the future the project may be relocated, but the procedure is the same. Then do this correspondingly for the back-end by setting express to listen to, e.g., localhost:5000 and frontend to localhost:3000.
- In the CORS array, you should change the URLs to your localhost and IP, e.g., localhost:3000 otherwise no requests would pass through.
- Setup a database in PostgreSQL as per the .sql file provided. Connect it by creating a
DATABASE_URL
enviromental variable containing Postgres connection string. Alternatively, you may pass each properties individually. - Setup a Cloudinary account for image storage by creating an account. Then create 4 environmental variables:
cloud_name
, which would store the name of the cloud we are uploading to;api_key
for the API key;api_secret
for the API key;folder_upload
for the folder to which we are uploading. - Create an environmental variable
cookieSecret
, and enter your cookie secret paraphrase. Do the same withjwtSecret
variable. - The app uses email in order to send notifications to the users, so it is time to setup enviromental variables for that. Setup
emailUser
andemailPassword
where the former is the email address, the latter is the password of the Gmail account. Remember to turn less secure apps on. In order to actually send email, you need to get and setup a SendGrid enviromental variable namedsgAPI
. Remember to verify the email address before setting it, otherwise SendGrid would not work. You may easily switch to Nodemailer for local development. - The app checks for temporary emails on registration and email change, which is why
tempMail
API key is needed. You may obtain one from https://istempmail.com - Obtain an OpenWeather API key and put in a
weatherAPI
environmental variable. - Finally, go to HCaptcha, obtain a key and put it in a
captcha
enviromental variable. Keep in mind that it would not work locally, you should use demo keys for local development. - On heroku server run
npm run start
. On custom server runnpm run build
. On development runnpm run dev
. - Starts the back-end on server with
npm start
. You may use nodemon in developmnet.
Security Overview
For any authentication purposes, the website uses two JWT tokens. They are both protected by a jwt secret, and carry identical information, but have a random parameter that differentiates them. One of the tokens is stored within a secured httpOnly cookie, while the other is saved in the localStorage. The latter is used by the front-end to access email, username, user_id, and others as an alternative to sending requests each time user data is needed. The back-end checks if the tokens are not identical, which means that one cannot simply copy one of the jwt in another, e.g., copying the cookie token to the localStorage or vice versa. Admin endpoints work similarly, except that they check whether admin=true in the users
table. Additionally, each method is throttled by default to 5 requests per second. bodyParser ensures that no scripts pass through. Each request is limited to 5kb. Helmet ensures that no unencrypted requests pass through. Parametrized input in queries ensures that no SQL vector for attacks is open. Since React treats parameters and even HTML in variables as just text, XSS attacks are impossible.
Database
PostgreSQL has 14 tables that interact through primary and foreign keys.
Registration
The request is at first throttled to 2 requests per second. Next, there is a check if all required inputs are available and are the correct lengths and format. There is a check if the provided email is temporary. If yes, code 400 is returned. A token of length 100 characters with random symbols is generated. Then a password is encrypted with 15 Salt rounds. Should this be successful, the data is inserted in the users
database with the token in the verified field. Next, a check for a conflict with existing records is done. If there is, return code 409. ID provided is obtained from the serial field in the users
database. Two tokens, one of which would be stored in a secure cookie and one in localStorage are generated. Both have a random parameter that would differentiate them. A response with the JWT and the cookie are sent. Both tokens have verified=false
. Email with the verification token is sent. The user clicks on the link, the token is checked against the users
table verified field and if correct, verified is set to true and a new pair of JWT tokens with verified=true is sent.
Authentication
Function authorizeToken
and authorizeTokenFunc
are used. The first is a middleware that calls next()
when the presented token carries both localStorage JWT and cookie. The second outputs the data to an object, but in its essence, it works almost identically. Both methods first check if the cookie matches the localStorage token value. If so, that means that the user doesn't really have two different authentic tokens, but rather has copied one of them. Response 401 is returned. If the tokens are different they are decrypted with the private key . If they contain identical information, this means that the user is legitimate, otherwise, response 401 is returned.
Similar functions adminToken
and adminTokenFunc
exist for the admin tokens. They operate on a similar principle, but check the users
table if the user is actually an admin, thus sacrificing speed to security. If the user is not an admin, response 403 is returned.
Login
Similarly, as the registration endpoint, requests are limited to 2 per second. The first check is whether all needed data is present and at the required string format. The hash is then retrieved from the users
database. The provided password is encrypted and the hash generated is compared against the hash received. If no records exist for the currently provided data, response 409 for no associated records. Should the request be unsuccessful, a login attempt is added with the user IP address to the unsuccessfulAttempts
table. On each request to this API, it is checked whether the total number of attempts exceeds 5, if so, the profile is locked. If the profile is locked, an unlock URL is provided in an email containing an unlock token and the IP addresses of the attempts. If the user unlocks their profile, the IP attempts are deleted.
Comment/Reply
The server checks whether the accepted format is a string and whether a token is provided. Then, the data is put in the comments
table as a comment with a score of 0. If the same comment has already been published, code 409 is returned, otherwise, code 200 is returned. The reply method works similarly.
Notes
The notes component is a bit more special because it uses an external rich text editor. The data is verified for minimum length with the option to initialize the component with the default for the editor. The data is then sent to the server on a button click. On the server-side, the user token is authorized, the needed parameters are checked, if they don't exist, response 400 is sent. If the place_id exists and length does not exceed 5000 characters(this also could be HTML), the entry is inserted into the notes
table conditionally. If it already exists, an UPDATE statement is used, if not, an INSERT statement. If both queries are successful, response 200 is sent back. On the profile page, an aggregate of all notes can be previewed. This is done on the server-side first by throttling to 2 requests per second. Then required data is received and displayed on the front-end with the .map method.
Weather data
The weather component is dependent on a parent one for receiving the data. useEffect is called upon render and data from the server-side. The required parameters are latitude and longitude. Then, a request is done to the OpenWeather API in a one-call format. The received data is sent in its raw JSON format to the front end, where it is displayed through the .map method and the dates are converted to local with moment.js. This data is not saved on the server.
Map component
The Map component uses PigeonMaps, which is a component on top of OpenStreetMaps. The Map has a center, which is dynamically received from the getCenter function of geolib. The function receives the places and finds their center point on the map. User location and received places are displayed with the Marker
component, which receives its colors from functions that convert the different categories into colors. Sorting by location is done through a 2D-like array where the first element is the coordinates, and a second element is an object with the data of the place. This data is then transferred to the <Card components through the .map function.
Quotes/Poems
JSON containing all the poems and quotes is in the widgets folder. Both components choose one at random and load the data. Image components are passed onto img. Assuming an error, a failsafe quote, and poem are available to be presented. Both components are shown on a random basis. The check is whether each neighboring card can be divided by a random number between 10-20 without remainder. You may manually add more quotes and poems if you wish to or change the algorithm.
Places
On page load a useEffect calls for the search function, which assumes a default value of limit and empty search query. The places
table is then tasked with providing the most popular places by likes. Should a user decide to change the limit, sorting, or any of the categories, onBlur is called to save the data. Since the data is changed, new requests on the /search route are done. The fetched data from the table is then grouped based on the place name, which is not a primary key but has a unique constraint. Then, the presented data is converted into an array of objects, and on the search method, an additional function checks for missing data in the presented array, effectively limiting errors caused by undefined fields. This is more of an error handling should the database be set up incorrectly. The data is finally sent to the front end, where it is given in the form of cards. Each card upon opening retrieves data about the weather at the specific geographic coordinates, notes about the place as well as comments and replies. The <Map components are responsible for displaying the location on a map.
When uploading places, the user has to enter all categories, a description, and a title that is checked for profanities, lengths, and overall content. The user can upload up to 3 images with a limit of 3MB. The way image handling is done is by converting the FileObject array into a standard array and obtaining its properties. On the back-end, hcverify checkes if the provided token by HCaptcha is correct and multer handles the image upload to Cloudinary thorugh multer-storage-cloudinary. The rest is a check whether app parameters are the correct length, type and don't contain illegal characters. Finally, all parameters are inserted in the places
table.
The same component for uploading places is used for updating them, the only difference is that if the user doesn't own the place(check by id on the front-end and places
table on the backend), his request for an edit is put in the suggested_places
table. The user who owns the place is sent an email and can either accept the change or delete it. If the change is accepted, the user who suggested it receives an email. If the user indeed owns the place, his request directly updates the previous one. Upon page reload, all edited data can be seen. Places have a unique id, which is a primary key and is used when reporting the place, posting a comment on it, editing it, etc. All images upon edit overwrite the previous ones.
Report
All registered and verified users can submit reports about places, comments, and replies. This is incredibly useful because it helps with moderation. If the user has a verified account (checked by decrypting the localStorage JWT with jwt_decode), they are presented with a button to report the place. The report must a minimum of 20 characters and a maximum of 5000. All reports are submitted with the id of the place/comment/reply, the type of element that is reported, and the user who reported the place. In the admin panel, reports can be given a score, or sorted by the date presented. Each report provides a view button, which shows the place/comment/reply is a new tab. The admin can edit/delete the element or delete the user as a whole. If the admin can put a score on the reports between -1 and Infinity. If a score is -1, the report is immediately deleted. The bigger the score is, the higher the element appears when ordered.
Share
The share element allows the user to share a place to social medias, download the place, get a link and a QR code. In contrast to other elements, it doesn't require as much on a back-end, but rather takes the id of the place to be shared, converts it to base64 format, adds a link to it, and passes it as a prop to social shares buttons. QR code is then generated based on this url, and a download button is also present. When clicking the button, the back-end aggregates all the information in the places
table with regards to this place, adds the images and sends the file for download. The front-end creates a blob and downloads the file in a .json format.
User panels The main page Cards overview Bottom load more button Opened card Card map and categories Weather component Comment section Edit place/suggest edit funcitonality Share Report Filters in the search component Upload place Account notes Avatar change Profile page after an avatar change Settings panel Register Login Verify email Liked places component Saved places component About me page Change password
Admin-panel
Statistics Failed login attempts Comments Users Reports
Created by Atanas Bobev 2021-2022
MIT License applies
Custom terms and conditions apply for the production version on unpopular-bulgaria.com and unpopular-bulgaria.herokuapp.com