├── .gitignore ├── README.md ├── api └── api.go ├── db ├── db.go └── mongo.go ├── frontend ├── next.config.js ├── package.json ├── public │ ├── drive.gif │ ├── favicon.png │ └── image.gif ├── src │ ├── components │ │ ├── Card.js │ │ ├── Content.js │ │ ├── Entry.js │ │ ├── Footer.js │ │ ├── Header.js │ │ ├── Info.js │ │ ├── Intro.js │ │ ├── Letter.js │ │ ├── PopUp.js │ │ ├── Text.js │ │ └── UploadImage.js │ ├── https │ │ ├── createLetter.js │ │ └── getLetter.js │ ├── pages │ │ ├── [hash].js │ │ ├── _app.js │ │ ├── api │ │ │ └── hello.js │ │ └── index.js │ └── styles │ │ ├── Card.module.css │ │ ├── Letter.module.css │ │ ├── blocks.css │ │ └── globals.css └── yarn.lock ├── go.mod ├── go.sum ├── main.go ├── schema └── schema.go └── security ├── encrypt.go └── hash.go /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | #executable 4 | carly 5 | 6 | # dependencies 7 | frontend/node_modules 8 | frontend/.pnp 9 | frontend/.pnp.js 10 | 11 | # testing 12 | /coverage 13 | 14 | # next.js 15 | frontend/.next/* 16 | frontend/out/ 17 | 18 | # production 19 | /build 20 | 21 | # misc 22 | .DS_Store 23 | *.pem 24 | 25 | # debug 26 | npm-debug.log* 27 | yarn-debug.log* 28 | yarn-error.log* 29 | 30 | # local env files 31 | frontend/.env.local 32 | frontend/.env.development.local 33 | frontend/.env.test.local 34 | frontend/.env.production.local 35 | 36 | # vercel 37 | .vercel 38 | 39 | #env 40 | .env -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Carly ✉️ 2 | #### Genereate beautiful letters for your loved ones that can be shared in seconds 3 | Carly was written to help me send a birthday letter to my mum halfway around the world. Now anyone can do it too! 4 | 5 | 6 | ![letter1](https://user-images.githubusercontent.com/7995105/116254324-3fcb5300-a73f-11eb-8398-fcf53bd9d1dc.png) 7 | ![letter2](https://user-images.githubusercontent.com/7995105/116254322-3f32bc80-a73f-11eb-8f33-059b34550093.png) 8 | ![letter3](https://user-images.githubusercontent.com/7995105/116254321-3f32bc80-a73f-11eb-955c-64624f298e41.png) 9 | ![letter4](https://user-images.githubusercontent.com/7995105/116254320-3f32bc80-a73f-11eb-984d-0e01913439f5.png) 10 | 11 | ## Details 12 | Carly is written in Next.js + React on the frontend (hosted on Vercel) and Go with MongoDB Atlas on the backend (hosted as a systemd file with nginx on Digital Ocean). 13 | 14 | To run this locally, you will need to run the web server in Go (using `go run main.go`) as well as the frontend (navigate inside the frontend directory and run `yarn dev`). 15 | 16 | You will also need a MongoDB atlas account (free tier M0 can be accessed by anyone). Once you've made an account, configure a user with admin access and store the username, password, and shared URL in a .env file with MONGO_USER, MONGO_PASS, MONGO_SHARD_URL as the variable names respectively. Make sure that this .env file is located in the same directory as the go.mod file. 17 | 18 | You will also need to create a .env.local file inside the frontend folder and populate it with two variables 19 | `NEXT_PUBLIC_HOST=localhost:3000 20 | NEXT_PUBLIC_HOSTAPI=127.0.0.1:port/api` 21 | You can select any port like 8998. 22 | 23 | ## API 24 | The API provides two endpoints 25 | ### `POST /api` 26 | - Accepts a JSON body that looks like this: 27 | `{ 28 | "title": "titleLetter", 29 | "expiry": "getExpiryDate()", 30 | "password": "", 31 | "content": [ 32 | { 33 | "person": "person1", 34 | "msg": "msg1", 35 | "imgAdd": "imgAdd1" 36 | }, 37 | { 38 | "person":"person2", 39 | "msg": "msg2", 40 | "imgAdd": "imgAdd2" 41 | }... 42 | ] 43 | }` 44 | 45 | The API returns the genereated hash if successful 46 | `#200 OK 47 | #{ "hash": "166989a"}` 48 | 49 | ### `GET /api/{hash}` 50 | The API returns 51 | `#401 Unauthorized` if the letter is password protected 52 | 53 | OR 54 | 55 | `#404 Bad request` if it does not find the hash in the database (or for any other possible error) 56 | 57 | ## Contributing 58 | 59 | Contributions are what make the open source community such an amazing place to be learn, inspire, and create. Any contributions you make are **greatly appreciated**. 60 | 61 | 1. Fork the Project 62 | 2. Create your Feature Branch (`git checkout -b feature/AmazingFeature`) 63 | 3. Commit your Changes (`git commit -m 'Add some AmazingFeature'`) 64 | 4. Push to the Branch (`git push origin feature/AmazingFeature`) 65 | 5. Open a Pull Request 66 | 67 | 68 | ## Acknowledgements 69 | * [Ctrl-v](https://github.com/jackyzha0/ctrl-v) 70 | * [Block CSS](https://thesephist.github.io/blocks.css/) 71 | -------------------------------------------------------------------------------- /api/api.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "net/http" 7 | 8 | "github.com/amirgamil/carly/db" 9 | "github.com/amirgamil/carly/schema" 10 | "github.com/gorilla/mux" 11 | ) 12 | 13 | func preflightResponse(w *http.ResponseWriter, r *http.Request) { 14 | //if in production, replace with https://carly.amirbolous.com 15 | (*w).Header().Set("Access-Control-Allow-Origin", "http://localhost:3000") 16 | (*w).Header().Set("Access-Control-Allow-Methods", "POST") 17 | (*w).Header().Set("Access-Control-Allow-Headers", "Accept, Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization") 18 | } 19 | 20 | func WriteDB(w http.ResponseWriter, r *http.Request) { 21 | // Allow CORS 22 | preflightResponse(&w, r) 23 | fmt.Println((*r).Method) 24 | if (*r).Method == "OPTIONS" { 25 | return 26 | } 27 | //generic data structure to decode JSON data into (strings to any data types) 28 | var letterCard schema.JSONLetter 29 | err := json.NewDecoder(r.Body).Decode(&letterCard) 30 | if err != nil { 31 | fmt.Println("Error parsing json data from frontend ", err) 32 | } 33 | // make a call to db with letter to write 34 | urlHash := db.AddNew(letterCard.Title, letterCard.Data, letterCard.Expiry, letterCard.Password) 35 | toReturn := map[string]string{ 36 | "hash": urlHash, 37 | } 38 | w.Header().Set("Content-Type", "application/json") 39 | json.NewEncoder(w).Encode(toReturn) 40 | } 41 | 42 | func HandleLetter(w http.ResponseWriter, r *http.Request) { 43 | getLetterWithPassword(w, r, "") 44 | } 45 | 46 | func HandleLetterWithPassword(w http.ResponseWriter, r *http.Request) { 47 | _ = r.ParseMultipartForm(0) 48 | password := r.FormValue("password") 49 | fmt.Println("Attempted password ", password) 50 | getLetterWithPassword(w, r, password) 51 | } 52 | 53 | func getLetterWithPassword(w http.ResponseWriter, r *http.Request, password string) { 54 | // Allow CORS 55 | w.Header().Set("Access-Control-Allow-Origin", "*") 56 | vars := mux.Vars(r) 57 | fmt.Println(vars["hash"]) 58 | res, err := db.LookUp(vars["hash"], password) 59 | if err != nil { 60 | switch err { 61 | case db.UnauthorizedUser: 62 | w.WriteHeader(http.StatusUnauthorized) 63 | fmt.Println("User is not authorized my dude ", err) 64 | default: 65 | w.WriteHeader(http.StatusBadRequest) 66 | fmt.Println("Couldn't find the letter for %s", vars["hash"]) 67 | } 68 | 69 | return 70 | } 71 | w.Header().Set("Content-Type", "application/json") 72 | json.NewEncoder(w).Encode(res) 73 | } 74 | -------------------------------------------------------------------------------- /db/db.go: -------------------------------------------------------------------------------- 1 | package db 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "log" 7 | "os" 8 | "time" 9 | 10 | "github.com/amirgamil/carly/schema" 11 | "github.com/amirgamil/carly/security" 12 | "github.com/joho/godotenv" 13 | ) 14 | 15 | const TitleLimit = 100 16 | const CardLimit = 25 17 | const ContentLimit = 100000 18 | 19 | func init() { 20 | err := godotenv.Load() 21 | if err != nil { 22 | log.Println("Error loading .env file: %s", err.Error()) 23 | } 24 | 25 | mUser := os.Getenv("MONGO_USER") 26 | mPass := os.Getenv("MONGO_PASS") 27 | mIP := os.Getenv("MONGO_SHARD_URL") 28 | initSession(mUser, mPass, mIP) 29 | } 30 | 31 | func AddNew(title string, content []schema.LetterData, expiry string, password string) string { 32 | titleArr := []byte(title) 33 | err := checkLengths(title, content) 34 | if err != nil { 35 | log.Println(err) 36 | } 37 | 38 | urlHash := security.GenerateUniqueHash(string(titleArr[:20])) 39 | expiryDate, err := time.Parse(time.RFC3339, expiry) 40 | if err != nil { 41 | log.Println("Error parsing the date %s", err) 42 | } 43 | marshalledContent, err := json.Marshal(content) 44 | if err != nil { 45 | log.Println("Error marshalling content") 46 | } 47 | new := schema.Letter{ 48 | Hash: urlHash, 49 | Title: title, 50 | Data: string(marshalledContent), 51 | Expiry: expiryDate, 52 | Password: password, 53 | } 54 | 55 | if password != "" { 56 | keyDer, salt, err := security.DeriveKey(password, nil) 57 | if err != nil { 58 | log.Println(err) 59 | } 60 | new.Salt = salt 61 | 62 | encryptedContent, err := security.Encrypt(keyDer, content) 63 | if err != nil { 64 | log.Println("Error encrypting the content ", err) 65 | } 66 | new.Data = encryptedContent 67 | 68 | hashedPassword, err := security.HashPassword(password) 69 | if err != nil { 70 | log.Println("Error hashing the password ", err) 71 | } 72 | new.Password = hashedPassword 73 | 74 | } 75 | 76 | // fmt.Printf("%+v\n", new) 77 | _, insertErr := insert(new) 78 | if insertErr != nil { 79 | fmt.Println("Error inserting a new letter in the db: ", insertErr) 80 | } 81 | return urlHash 82 | 83 | } 84 | 85 | func checkLengths(title string, content []schema.LetterData) error { 86 | if len(title) > TitleLimit { 87 | return fmt.Errorf("title is longer than character limit of %d\n", TitleLimit) 88 | } 89 | if len(content) > CardLimit { 90 | return fmt.Errorf("# of cards is longer than limit of %d\n", CardLimit) 91 | } 92 | for _, s := range content { 93 | if len(s.Message) > ContentLimit { 94 | return fmt.Errorf("content within a card is longer than character limit of %d\n", ContentLimit) 95 | } 96 | } 97 | 98 | return nil 99 | } 100 | 101 | func LookUp(hash string, password string) (schema.JSONLetter, error) { 102 | return fetch(hash, password) 103 | } 104 | -------------------------------------------------------------------------------- /db/mongo.go: -------------------------------------------------------------------------------- 1 | package db 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "errors" 7 | "fmt" 8 | "log" 9 | "time" 10 | 11 | "github.com/amirgamil/carly/schema" 12 | "github.com/amirgamil/carly/security" 13 | "go.mongodb.org/mongo-driver/bson" 14 | "go.mongodb.org/mongo-driver/mongo" 15 | "go.mongodb.org/mongo-driver/mongo/options" 16 | ) 17 | 18 | var Session *mongo.Client 19 | var letters *mongo.Collection 20 | var ctx context.Context 21 | 22 | func initSession(user string, pass string, ip string) { 23 | // load .env file 24 | //"mongodb://%s:%s@%s:27017" 25 | URIfmt := "mongodb://%s:%s@%s" //"mongodb://127.0.0.1:27017" eventually add user and password 26 | fmt.Println(ip) 27 | mongoURI := fmt.Sprintf(URIfmt, user, pass, ip) 28 | ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) 29 | defer cancel() 30 | Client, err := mongo.Connect(ctx, options.Client().ApplyURI( 31 | mongoURI, 32 | )) 33 | if err != nil { 34 | log.Println("Error starting Mongo session, ", err) 35 | } 36 | 37 | models := []mongo.IndexModel{ 38 | { 39 | Keys: bson.D{{"hash", 1}}, 40 | Options: options.Index().SetUnique(true), 41 | }, 42 | { 43 | Keys: bson.D{{"expiry", 1}}, 44 | Options: options.Index().SetExpireAfterSeconds(0), 45 | }, 46 | } 47 | letters = Client.Database("main").Collection("letters") 48 | fmt.Println("Established connection to %s", letters) 49 | 50 | // Specify the MaxTime option to limit the amount of time the operation can run on the server 51 | opts := options.CreateIndexes().SetMaxTime(2 * time.Second) 52 | _, errIn := letters.Indexes().CreateMany(ctx, models, opts) 53 | if errIn != nil { 54 | log.Println(errIn) 55 | } 56 | 57 | } 58 | 59 | func insert(new schema.Letter) (*mongo.InsertOneResult, error) { 60 | return letters.InsertOne(context.Background(), new) 61 | } 62 | 63 | var UnauthorizedUser = errors.New("password is wrong or not provided") 64 | 65 | //look up hash string in the database 66 | //returns a letter if found, if it's password protected (so will need to handle), and an error 67 | func fetch(hash string, password string) (schema.JSONLetter, error) { 68 | //create BSON object of hash string to look for in the database 69 | lookFor := bson.M{"hash": hash} 70 | 71 | var result schema.Letter 72 | _ = letters.FindOne(ctx, lookFor).Decode(&result) 73 | jsonResult := schema.JSONLetter{ 74 | Hash: result.Hash, 75 | Title: result.Title, 76 | Expiry: result.Expiry.String(), 77 | } 78 | if result.Password != "" { 79 | isValid := security.VerifyPassword(result.Password, password) 80 | if !isValid { 81 | return schema.JSONLetter{}, UnauthorizedUser 82 | } 83 | //get the key from the Salt in order to decrpt the message 84 | key, _, err := security.DeriveKey(password, result.Salt) 85 | if err != nil { 86 | log.Println("Error calculating key to decrypt the message ", err) 87 | } 88 | 89 | //decrypt the message and only return what is necessary (if no password, dont' care about Salt) 90 | decryptedMessage, err := security.Decrypt(string(key), result.Data) 91 | if err != nil { 92 | log.Println("Error decrypting the message, ", err) 93 | } 94 | jsonResult.Data = decryptedMessage 95 | } else { 96 | var actualData []schema.LetterData 97 | errD := json.Unmarshal([]byte(result.Data), &actualData) 98 | if errD != nil { 99 | log.Println("Error converting decrypted data back to readable format ", errD) 100 | } 101 | jsonResult.Data = actualData 102 | } 103 | return jsonResult, nil 104 | } 105 | -------------------------------------------------------------------------------- /frontend/next.config.js: -------------------------------------------------------------------------------- 1 | // next.config.js 2 | const withImages = require('next-images') 3 | module.exports = withImages() -------------------------------------------------------------------------------- /frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "carly", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start" 9 | }, 10 | "dependencies": { 11 | "babel-plugin-styled-components": "^1.8.0", 12 | "next": "10.1.3", 13 | "next-images": "^1.7.0", 14 | "react": "17.0.2", 15 | "react-dom": "17.0.2", 16 | "styled-components": "^5.2.3" 17 | }, 18 | "babel": { 19 | "env": { 20 | "development": { 21 | "presets": [ 22 | "next/babel" 23 | ], 24 | "plugins": [ 25 | [ 26 | "styled-components", 27 | { 28 | "ssr": true, 29 | "displayName": true 30 | } 31 | ] 32 | ] 33 | }, 34 | "production": { 35 | "presets": [ 36 | "next/babel" 37 | ], 38 | "plugins": [ 39 | [ 40 | "styled-components", 41 | { 42 | "ssr": true, 43 | "displayName": false 44 | } 45 | ] 46 | ] 47 | } 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /frontend/public/drive.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amirgamil/carly/ba8dd42490acedc40b2957c65882c5393398f063/frontend/public/drive.gif -------------------------------------------------------------------------------- /frontend/public/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amirgamil/carly/ba8dd42490acedc40b2957c65882c5393398f063/frontend/public/favicon.png -------------------------------------------------------------------------------- /frontend/public/image.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amirgamil/carly/ba8dd42490acedc40b2957c65882c5393398f063/frontend/public/image.gif -------------------------------------------------------------------------------- /frontend/src/components/Card.js: -------------------------------------------------------------------------------- 1 | import styles from '../styles/Card.module.css' 2 | 3 | 4 | const Card = ({data}) => { 5 | return ( 6 |
7 |

{data.person}

8 |
9 |
10 | 11 |
12 |

13 | {data.msg} 14 |

15 |
16 |
17 | ) 18 | } 19 | 20 | export default Card; -------------------------------------------------------------------------------- /frontend/src/components/Content.js: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | import styled from 'styled-components'; 3 | 4 | const Wrapper = styled.div` 5 | display: block; 6 | position: relative; 7 | width: calc(100%); 8 | ` 9 | 10 | export default function Content({placeholderTxt, content, onchange}) { 11 | 12 | const handleChange = (evt) => { 13 | onchange(evt.target.value); 14 | evt.preventDefault(); 15 | } 16 | 17 | return ( 18 |
19 |