diff --git a/.env b/.env index 0e99fe4..a7c0978 100644 --- a/.env +++ b/.env @@ -1,7 +1,6 @@ -PLAKKEN_INTERFACE=0.0.0.0 -PLAKKEN_PORT=3000 -PLAKKEN_REDIS_ADDR=localhost:6379 +PLAKKEN_LISTEN=:3000 +PLAKKEN_REDIS_ADDRESS=localhost:6379 PLAKKEN_REDIS_USER= PLAKKEN_REDIS_PASSWORD= PLAKKEN_REDIS_DB=0 -PLAKKEN_REDIS_URL_LEN=5 +PLAKKEN_URL_LENGTH=5 diff --git a/.gitignore b/.gitignore index cb9b7ef..087b7b6 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,7 @@ +# IDE specific +.idea +.vscode + ### Go ### *.exe *.exe~ diff --git a/config.go b/config.go deleted file mode 100644 index 8630a3a..0000000 --- a/config.go +++ /dev/null @@ -1,54 +0,0 @@ -package main - -import ( - "github.com/joho/godotenv" - "log" - "os" - "strconv" -) - -type Config struct { - host string - port string - redisAddr string - redisUser string - redisPassword string - redisDB int - urlLength int -} - -func GetConfig() Config { - err := godotenv.Load() - if err != nil { - log.Fatalf("Error loading .env file: %v", err) - } - - port := os.Getenv("PLAKKEN_PORT") - redisAddr := os.Getenv("PLAKKEN_REDIS_ADDR") - db := os.Getenv("PLAKKEN_REDIS_DB") - uLen := os.Getenv("PLAKKEN_REDIS_URL_LEN") - - if port == "" || redisAddr == "" { - log.Fatal("Missing or invalid PLAKKEN_PORT or PLAKKEN_REDIS_ADDR") - } - - redisDB, err := strconv.Atoi(db) - if err != nil { - log.Fatal("Invalid PLAKKEN_REDIS_DB") - } - - urlLen, err := strconv.Atoi(uLen) - if err != nil { - log.Fatal("Invalid PLAKKEN_REDIS_URL_LEN") - } - - return Config{ - host: os.Getenv("PLAKKEN_INTERFACE"), - port: port, - redisAddr: redisAddr, - redisUser: os.Getenv("PLAKKEN_REDIS_USER"), - redisPassword: os.Getenv("PLAKKEN_REDIS_PASSWORD"), - redisDB: redisDB, - urlLength: urlLen, - } -} diff --git a/db.go b/db.go deleted file mode 100644 index aaaa0ad..0000000 --- a/db.go +++ /dev/null @@ -1,49 +0,0 @@ -package main - -import ( - "context" - "log" - "time" - - "github.com/redis/go-redis/v9" -) - -var ctx = context.Background() - -func ConnectDB() *redis.Client { - localDb := redis.NewClient(&redis.Options{ - Addr: currentConfig.redisAddr, - Username: currentConfig.redisUser, - Password: currentConfig.redisPassword, - DB: currentConfig.redisDB, - }) - return localDb -} - -func insertPaste(key string, content string, secret string, ttl time.Duration) { - type dbSchema struct { - content string - secret string - } - - hash := dbSchema{ - content: content, - secret: secret, - } - err := db.HSet(ctx, key, "content", hash.content) - if err != nil { - log.Println(err) - } - err = db.HSet(ctx, key, "secret", hash.secret) - if ttl > -1 { - db.Expire(ctx, key, ttl) - } -} - -func getContent(key string) string { - return db.HGet(ctx, key, "content").Val() -} - -func DeleteContent(key string) { - db.Del(ctx, key) -} diff --git a/go.mod b/go.mod index 5e80fb0..042b6ec 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ -module plakken +module git.gnous.eu/gnouseu/plakken -go 1.21 +go 1.22 require ( github.com/joho/godotenv v1.5.1 diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..2576b54 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,59 @@ +package config + +import ( + "log" + "os" + "strconv" + + "github.com/joho/godotenv" +) + +// InitConfig Structure for program initialisation +type InitConfig struct { + ListenAddress string + RedisAddress string + RedisUser string + RedisPassword string + RedisDB int + UrlLength uint8 +} + +// GetConfig Initialise configuration form .env +func GetConfig() InitConfig { + err := godotenv.Load() + if err != nil { + log.Fatalf("Error loading .env file: %v", err) + } + + listenAddress := os.Getenv("PLAKKEN_LISTEN") + redisAddress := os.Getenv("PLAKKEN_REDIS_ADDRESS") + db := os.Getenv("PLAKKEN_REDIS_DB") + uLen := os.Getenv("PLAKKEN_URL_LENGTH") + + if listenAddress == "" || redisAddress == "" { + log.Fatal("Missing or invalid listenAddress or PLAKKEN_REDIS_ADDRESS") + } + + redisDB, err := strconv.Atoi(db) + if err != nil { + log.Fatal("Invalid PLAKKEN_REDIS_DB") + } + + urlLength, err := strconv.Atoi(uLen) + if err != nil { + log.Fatal("Invalid PLAKKEN_URL_LENGTH") + } + + if urlLength > 255 { + log.Fatal("PLAKKEN_URL_LENGTH cannot be greater than 255") + } + + return InitConfig{ + ListenAddress: listenAddress, + RedisAddress: redisAddress, + RedisUser: os.Getenv("PLAKKEN_REDIS_USER"), + RedisPassword: os.Getenv("PLAKKEN_REDIS_PASSWORD"), + RedisDB: redisDB, + UrlLength: uint8(urlLength), + } +} diff --git a/internal/constant/constants.go b/internal/constant/constants.go new file mode 100644 index 0000000..0522e19 --- /dev/null +++ b/internal/constant/constants.go @@ -0,0 +1,7 @@ +package constant + +import "time" + +const ( + HTTPTimeout = 3 * time.Second +) diff --git a/internal/database/db.go b/internal/database/db.go new file mode 100644 index 0000000..1769dc0 --- /dev/null +++ b/internal/database/db.go @@ -0,0 +1,64 @@ +package database + +import ( + "context" + "log" + "time" + + "github.com/redis/go-redis/v9" +) + +type DBConfig struct { + DB *redis.Client +} + +var ctx = context.Background() + +// InitDB initialise redis connection settings +func InitDB(addr string, user string, password string, db int) *redis.Options { + DBConfig := &redis.Options{ + Addr: addr, + Username: user, + Password: password, + DB: db, + } + + return DBConfig +} + +// ConnectDB make new database connection +func ConnectDB(dbConfig *redis.Options) *redis.Client { + localDb := redis.NewClient(dbConfig) + return localDb +} + +func (config DBConfig) InsertPaste(key string, content string, secret string, ttl time.Duration) { + type dbSchema struct { + content string + secret string + } + + hash := dbSchema{ + content: content, + secret: secret, + } + err := config.DB.HSet(ctx, key, "content", hash.content).Err() + if err != nil { + log.Println(err) + } + err = config.DB.HSet(ctx, key, "secret", hash.secret).Err() + if err != nil { + log.Println(err) + } + if ttl > -1 { + config.DB.Expire(ctx, key, ttl) + } +} + +func (config DBConfig) UrlExist(url string) bool { + return config.DB.Exists(ctx, url).Val() == 1 +} + +func (config DBConfig) VerifySecret(url string, secret string) bool { + return secret == config.DB.HGet(ctx, url, "secret").Val() +} diff --git a/internal/httpServer/server.go b/internal/httpServer/server.go new file mode 100644 index 0000000..3c4fa52 --- /dev/null +++ b/internal/httpServer/server.go @@ -0,0 +1,51 @@ +package httpServer + +import ( + "log" + "net/http" + + "git.gnous.eu/gnouseu/plakken/internal/constant" + "git.gnous.eu/gnouseu/plakken/internal/web/plak" + "git.gnous.eu/gnouseu/plakken/internal/web/static" + "github.com/redis/go-redis/v9" +) + +type ServerConfig struct { + HTTPServer *http.Server + UrlLength uint8 + DB *redis.Client +} + +// Configure HTTP router +func (config ServerConfig) router(_ http.ResponseWriter, _ *http.Request) { + WebConfig := plak.WebConfig{ + DB: config.DB, + UrlLength: config.UrlLength, + } + + http.HandleFunc("GET /{$}", static.Home) + http.HandleFunc("GET /{key}/{settings...}", WebConfig.View) + http.HandleFunc("GET /static/{file}", static.ServeStatic) + http.HandleFunc("POST /{$}", WebConfig.Create) + http.HandleFunc("DELETE /{key}", WebConfig.Delete) +} + +// Config Configure HTTP server +func Config(listenAddress string) *http.Server { + server := &http.Server{ + Addr: listenAddress, + ReadTimeout: constant.HTTPTimeout, + WriteTimeout: constant.HTTPTimeout, + } + + return server +} + +// Server Start HTTP server +func (config ServerConfig) Server() { + log.Println("Listening on " + config.HTTPServer.Addr) + + http.HandleFunc("/", config.router) + + log.Fatal(config.HTTPServer.ListenAndServe()) +} diff --git a/internal/utils/error.go b/internal/utils/error.go new file mode 100644 index 0000000..d946d64 --- /dev/null +++ b/internal/utils/error.go @@ -0,0 +1,17 @@ +package utils + +type ParseIntBeforeSeparatorError struct { + Message string +} + +func (m *ParseIntBeforeSeparatorError) Error() string { + return "parseIntBeforeSeparator: " + m.Message +} + +type ParseExpirationError struct { + Message string +} + +func (m *ParseExpirationError) Error() string { + return "parseIntBeforeSeparator: " + m.Message +} diff --git a/utils.go b/internal/utils/utils.go similarity index 52% rename from utils.go rename to internal/utils/utils.go index 5d23dce..77d41fd 100644 --- a/utils.go +++ b/internal/utils/utils.go @@ -1,18 +1,18 @@ -package main +package utils import ( "crypto/rand" "encoding/hex" - "fmt" "log" mathrand "math/rand" "strconv" "strings" ) -func GenerateUrl() string { +// GenerateUrl generate random string for plak url +func GenerateUrl(length uint8) string { listChars := []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789") - b := make([]rune, currentConfig.urlLength) + b := make([]rune, length) for i := range b { b[i] = listChars[mathrand.Intn(len(listChars))] } @@ -20,6 +20,7 @@ func GenerateUrl() string { return string(b) } +// GenerateSecret generate random secret (32 bytes hexadecimal) func GenerateSecret() string { key := make([]byte, 32) _, err := rand.Read(key) @@ -30,62 +31,73 @@ func GenerateSecret() string { return hex.EncodeToString(key) } -func UrlExist(url string) bool { - return db.Exists(ctx, url).Val() == 1 +// CheckCharRedundant verify is a character is redundant in a string +func CheckCharRedundant(source string, char string) bool { // Verify if a char is redundant + if strings.Count(source, char) > 1 { + return true + } + return false } -func VerifySecret(url string, secret string) bool { - return secret == db.HGet(ctx, url, "secret").Val() -} - -func parseIntBeforeSeparator(source *string, sep string) (int, error) { // return -1 if error, only accept positive number +func parseIntBeforeSeparator(source *string, sep string) (int, error) { // return 0 & error if error, only accept positive number + if CheckCharRedundant(*source, sep) { + return 0, &ParseIntBeforeSeparatorError{Message: *source + ": cannot parse value as int"} + } var value int var err error if strings.Contains(*source, sep) { value, err = strconv.Atoi(strings.Split(*source, sep)[0]) if err != nil { log.Println(err) - return -1, fmt.Errorf("parseIntBeforeSeparator : \"%s\" : cannot parse value as int", *source) + return 0, &ParseIntBeforeSeparatorError{Message: *source + ": cannot parse value as int"} } if value < 0 { // Only positive value is correct - return -1, fmt.Errorf("parseIntBeforeSeparator : \"%s\" : format only take positive value", *source) + return 0, &ParseIntBeforeSeparatorError{Message: *source + ": format only take positive value"} } + + if value > 99 { + return 0, &ParseIntBeforeSeparatorError{Message: *source + ": Format only take two number"} + } + *source = strings.Join(strings.Split(*source, sep)[1:], "") } return value, nil } -func ParseExpiration(source string) (int, error) { // return -1 if error +// ParseExpiration Parse "1d1h1m1s" duration format. Return 0 & error if error +func ParseExpiration(source string) (int, error) { var expiration int var tempOutput int var err error - errMessage := "ParseExpiration : \"%s\" : invalid syntax" if source == "0" { return 0, nil } + + source = strings.ToLower(source) + tempOutput, err = parseIntBeforeSeparator(&source, "d") expiration = tempOutput * 86400 if err != nil { log.Println(err) - return -1, fmt.Errorf(errMessage, source) + return 0, &ParseExpirationError{Message: "Invalid syntax"} } tempOutput, err = parseIntBeforeSeparator(&source, "h") expiration += tempOutput * 3600 if err != nil { log.Println(err) - return -1, fmt.Errorf(errMessage, source) + return 0, &ParseExpirationError{Message: "Invalid syntax"} } tempOutput, err = parseIntBeforeSeparator(&source, "m") expiration += tempOutput * 60 if err != nil { log.Println(err) - return -1, fmt.Errorf(errMessage, source) + return 0, &ParseExpirationError{Message: "Invalid syntax"} } tempOutput, err = parseIntBeforeSeparator(&source, "s") expiration += tempOutput * 1 if err != nil { log.Println(err) - return -1, fmt.Errorf(errMessage, source) + return 0, &ParseExpirationError{Message: "Invalid syntax"} } return expiration, nil diff --git a/internal/web/plak/error.go b/internal/web/plak/error.go new file mode 100644 index 0000000..e2241f0 --- /dev/null +++ b/internal/web/plak/error.go @@ -0,0 +1,10 @@ +package plak + +type DeletePlakError struct { + Name string + Err error +} + +func (m *DeletePlakError) Error() string { + return "Cannot delete: " + m.Name + " : " + m.Err.Error() +} diff --git a/internal/web/plak/plak.go b/internal/web/plak/plak.go new file mode 100644 index 0000000..d854a75 --- /dev/null +++ b/internal/web/plak/plak.go @@ -0,0 +1,134 @@ +package plak + +import ( + "context" + "io" + "log" + "net/http" + "time" + + "git.gnous.eu/gnouseu/plakken/internal/database" + "git.gnous.eu/gnouseu/plakken/internal/utils" + "github.com/redis/go-redis/v9" + + "html/template" +) + +var ctx = context.Background() + +type WebConfig struct { + DB *redis.Client + UrlLength uint8 +} + +// Plak "Object" for plak +type Plak struct { + Key string + Content string + Expiration time.Duration + DB *redis.Client +} + +// Create manage POST request for create Plak +func (config WebConfig) Create(w http.ResponseWriter, r *http.Request) { + content := r.FormValue("content") + if content == "" { + w.WriteHeader(http.StatusBadRequest) + } + + dbConf := database.DBConfig{ + DB: config.DB, + } + + secret := utils.GenerateSecret() + key := utils.GenerateUrl(config.UrlLength) + rawExpiration := r.FormValue("exp") + expiration, err := utils.ParseExpiration(rawExpiration) + if err != nil { + w.WriteHeader(http.StatusBadRequest) + } else if expiration == 0 { + dbConf.InsertPaste(key, content, secret, -1) + } else { + dbConf.InsertPaste(key, content, secret, time.Duration(expiration*int(time.Second))) + } + + http.Redirect(w, r, key, http.StatusSeeOther) +} + +// View for plak +func (config WebConfig) View(w http.ResponseWriter, r *http.Request) { + dbConf := database.DBConfig{ + DB: config.DB, + } + var plak Plak + key := r.PathValue("key") + + if dbConf.UrlExist(key) { + plak = Plak{ + Key: key, + DB: config.DB, + } + plak = plak.GetContent() + if r.PathValue("settings") == "raw" { + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + _, err := io.WriteString(w, plak.Content) + if err != nil { + log.Println(err) + } + } else { + t, err := template.ParseFiles("templates/paste.html") + if err != nil { + log.Println(err) + } + err = t.Execute(w, plak) + if err != nil { + log.Println(err) + } + } + } else { + w.WriteHeader(http.StatusNotFound) + } +} + +// Delete manage plak deletion endpoint +func (config WebConfig) Delete(w http.ResponseWriter, r *http.Request) { + dbConf := database.DBConfig{ + DB: config.DB, + } + key := r.PathValue("key") + + if dbConf.UrlExist(key) { + secret := r.URL.Query().Get("secret") + if dbConf.VerifySecret(key, secret) { + plak := Plak{ + Key: key, + DB: config.DB, + } + err := plak.deletePlak() + if err != nil { + log.Println(err) + } + w.WriteHeader(http.StatusNoContent) + } else { + w.WriteHeader(http.StatusForbidden) + } + } + w.WriteHeader(http.StatusNotFound) +} + +// deletePlak Delete plak from database +func (plak Plak) deletePlak() error { + err := plak.DB.Del(ctx, plak.Key).Err() + if err != nil { + log.Println(err) + return &DeletePlakError{Name: plak.Key, Err: err} + } + + return nil +} + +// GetContent get plak content +func (plak Plak) GetContent() Plak { + plak.Content = plak.DB.HGet(ctx, plak.Key, "content").Val() + return plak +} diff --git a/internal/web/static/static.go b/internal/web/static/static.go new file mode 100644 index 0000000..4c4a059 --- /dev/null +++ b/internal/web/static/static.go @@ -0,0 +1,15 @@ +package static + +import ( + "net/http" +) + +// ServeStatic Serve static file from static +func ServeStatic(w http.ResponseWriter, r *http.Request) { + http.ServeFile(w, r, "./static/"+r.PathValue("file")) // TODO: vérifier si c'est safe +} + +// Home Serve index.html +func Home(w http.ResponseWriter, r *http.Request) { + http.ServeFile(w, r, "./static/index.html") +} diff --git a/main.go b/main.go index d75aa4b..6ced2da 100644 --- a/main.go +++ b/main.go @@ -1,111 +1,21 @@ package main import ( - "fmt" - "github.com/redis/go-redis/v9" - "html/template" - "io" - "log" - "net/http" - "strings" - "time" + "git.gnous.eu/gnouseu/plakken/internal/config" + "git.gnous.eu/gnouseu/plakken/internal/database" + "git.gnous.eu/gnouseu/plakken/internal/httpServer" ) -var currentConfig Config -var db *redis.Client - -type pasteView struct { - Content string - Key string -} - -func handleRequest(w http.ResponseWriter, r *http.Request) { - path := r.URL.Path - clearPath := strings.ReplaceAll(r.URL.Path, "/raw", "") - staticResource := "/static/" - switch r.Method { - case "GET": - if path == "/" { - http.ServeFile(w, r, "./static/index.html") - - } else if strings.HasPrefix(path, staticResource) { - fs := http.FileServer(http.Dir("./static")) - http.Handle(staticResource, http.StripPrefix(staticResource, fs)) - } else { - if UrlExist(clearPath) { - if strings.HasSuffix(path, "/raw") { - pasteContent := getContent(clearPath) - w.Header().Set("Content-Type", "text/plain; charset=utf-8") - _, err := io.WriteString(w, pasteContent) - if err != nil { - log.Println(err) - } - } else { - pasteContent := getContent(path) - s := pasteView{Content: pasteContent, Key: strings.TrimPrefix(path, "/")} - t, err := template.ParseFiles("templates/paste.html") - if err != nil { - log.Println(err) - } - err = t.Execute(w, s) - if err != nil { - log.Println(err) - } - } - } else { - w.WriteHeader(http.StatusNotFound) - } - } - case "POST": - if path == "/" { - secret := GenerateSecret() - url := "/" + GenerateUrl() - content := r.FormValue("content") - rawExpiration := r.FormValue("exp") - expiration, err := ParseExpiration(rawExpiration) - if err != nil { - log.Println(err) - } else if expiration == 0 { - insertPaste(url, content, secret, -1) - } else if expiration == -1 { - w.WriteHeader(http.StatusBadRequest) - } else { - insertPaste(url, content, secret, time.Duration(expiration*int(time.Second))) - } - - http.Redirect(w, r, url, http.StatusSeeOther) - } else { - w.WriteHeader(http.StatusMethodNotAllowed) - } - case "DELETE": - if UrlExist(path) { - secret := r.URL.Query().Get("secret") - if VerifySecret(path, secret) { - DeleteContent(path) - w.WriteHeader(http.StatusNoContent) - } else { - w.WriteHeader(http.StatusForbidden) - } - } else { - w.WriteHeader(http.StatusNotFound) - } - } -} - func main() { - db = ConnectDB() - currentConfig = GetConfig() - listen := currentConfig.host + ":" + currentConfig.port - http.HandleFunc("/", handleRequest) + initConfig := config.GetConfig() + dbConfig := database.InitDB(initConfig.RedisAddress, initConfig.RedisUser, initConfig.RedisPassword, initConfig.RedisDB) + db := database.ConnectDB(dbConfig) - if currentConfig.host == "" { - fmt.Println("Listening on port " + listen) - } else { - fmt.Println("Listening on " + listen) + serverConfig := httpServer.ServerConfig{ + HTTPServer: httpServer.Config(initConfig.ListenAddress), + UrlLength: initConfig.UrlLength, + DB: db, } - err := http.ListenAndServe(listen, nil) - if err != nil { - log.Fatal(err) - } + serverConfig.Server() } diff --git a/static/index.html b/static/index.html index dfaa289..5061a27 100644 --- a/static/index.html +++ b/static/index.html @@ -7,9 +7,8 @@ name="description"> -