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/.env.example b/.env.example deleted file mode 100644 index faaaf55..0000000 --- a/.env.example +++ /dev/null @@ -1,6 +0,0 @@ -PLAKKEN_HOST=0.0.0.0 -PLAKKEN_PORT=3000 -PLAKKEN_REDIS_ADDR=localhost:6379 -PLAKKEN_REDIS_USER= -PLAKKEN_REDIS_PASSWORD= -PLAKKEN_REDIS_DB=0 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/.idea/.gitignore b/.idea/.gitignore deleted file mode 100644 index 8bf4d45..0000000 --- a/.idea/.gitignore +++ /dev/null @@ -1,6 +0,0 @@ -# Default ignored files -/shelf/ -/workspace.xml -# Datasource local storage ignored files -/dataSources/ -/dataSources.local.xml diff --git a/.idea/modules.xml b/.idea/modules.xml deleted file mode 100644 index b9c05c5..0000000 --- a/.idea/modules.xml +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/.idea/plakken.iml b/.idea/plakken.iml deleted file mode 100644 index 7f4273e..0000000 --- a/.idea/plakken.iml +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - - - - \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml deleted file mode 100644 index 94a25f7..0000000 --- a/.idea/vcs.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/.woodpecker/build.yaml b/.woodpecker/build.yaml new file mode 100644 index 0000000..88083af --- /dev/null +++ b/.woodpecker/build.yaml @@ -0,0 +1,32 @@ +steps: + - name: publish_image + image: woodpeckerci/plugin-docker-buildx + settings: + repo: git.gnous.eu/${CI_REPO_OWNER}/plakken + dockerfile: docker/Dockerfile + platforms: linux/amd64,linux/arm64/v8,linux/arm + registry: https://git.gnous.eu + tag: ${CI_COMMIT} + username: + from_secret: docker_username + password: + from_secret: docker_password + when: + branch: ${CI_REPO_DEFAULT_BRANCH} + event: push + - name: publish_image_tag + image: woodpeckerci/plugin-docker-buildx + settings: + repo: git.gnous.eu/${CI_REPO_OWNER}/plakken + dockerfile: docker/Dockerfile + platforms: linux/amd64,linux/arm64/v8,linux/arm + registry: https://git.gnous.eu + tags: + - ${CI_COMMIT_TAG##v} # Remove v from tag + - stable + username: + from_secret: docker_username + password: + from_secret: docker_password + when: + event: tag diff --git a/.woodpecker/release.yaml b/.woodpecker/release.yaml new file mode 100644 index 0000000..9c36d4b --- /dev/null +++ b/.woodpecker/release.yaml @@ -0,0 +1,24 @@ +steps: + - name: Build + image: golang:1.22 + commands: + - go mod download + - CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags "-w -s" -o plakken-linux-amd64 # Enable static binary, target Linux, remove debug information and strip binary + - CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags "-w -s" -o plakken-linux-arm64 + - CGO_ENABLED=0 GOOS=linux GOARCH=arm go build -ldflags "-w -s" -o plakken-linux-arm + - CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags "-w -s" -o plakken-windows-amd64.exe + - CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags "-w -s" -o plakken-windows-arm64.exe + - CGO_ENABLED=0 GOOS=linux GOARCH=arm go build -ldflags "-w -s" -o plakken-windows-arm.exe + when: + event: tag + - name: Release + image: woodpeckerci/plugin-gitea-release + settings: + base_url: https://git.gnous.eu + files: + - "plakken*" + api_key: + from_secret: release_token + target: main + when: + event: tag \ No newline at end of file diff --git a/config.go b/config.go deleted file mode 100644 index d9c0ab4..0000000 --- a/config.go +++ /dev/null @@ -1,56 +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") - } - - conf := 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, - } - - return conf -} diff --git a/db.go b/db.go deleted file mode 100644 index 5dcdd03..0000000 --- a/db.go +++ /dev/null @@ -1,52 +0,0 @@ -package main - -import ( - "context" - "github.com/redis/go-redis/v9" - "log" - "time" -) - -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.Do(ctx, key, ttl) - } -} - -func getContent(key string) string { - content := db.HGet(ctx, key, "content").Val() - return content -} - -func deleteContent(key string) { - err := db.Del(ctx, key) - if err != nil { - log.Println(err) - } -} diff --git a/docker/Dockerfile b/docker/Dockerfile new file mode 100644 index 0000000..f50175c --- /dev/null +++ b/docker/Dockerfile @@ -0,0 +1,28 @@ +# Build +FROM golang:1.22 AS build +LABEL authors="gnousEU" + +WORKDIR /build + +COPY go.mod go.sum ./ +RUN go mod download + +COPY main.go ./ +COPY internal/ ./internal +COPY static/ ./static +COPY templates/ ./templates + +RUN CGO_ENABLED=0 GOOS=linux go build -ldflags "-w -s" # Enable static binary, target Linux, remove debug information and strip binary + +# Copy to our image +FROM gcr.io/distroless/static-debian12:nonroot + +WORKDIR /app + +COPY --from=build /build/plakken ./ + +ENV PLAKKEN_LISTEN ":3000" + +EXPOSE 3000/tcp + +ENTRYPOINT ["/app/plakken"] diff --git a/docker/docker-compose.dev.yaml b/docker/docker-compose.dev.yaml new file mode 100644 index 0000000..8ec1e6e --- /dev/null +++ b/docker/docker-compose.dev.yaml @@ -0,0 +1,31 @@ +version: "3" + +networks: + plakken: + external: false + +services: + server: + build: + context: ../ + dockerfile: docker/Dockerfile + restart: always + container_name: plakken + networks: + - plakken + ports: + - "3000:3000" + environment: + - PLAKKEN_REDIS_ADDRESS=redis:6379 + - POSTGRES_PASSWORD=gitea + - PLAKKEN_REDIS_DB=0 + - PLAKKEN_URL_LENGTH=5 + depends_on: + - redis + redis: + image: redis:7-alpine + restart: always + healthcheck: + test: [ "CMD", "redis-cli", "ping" ] + networks: + - plakken diff --git a/docker/docker-compose.yaml b/docker/docker-compose.yaml new file mode 100644 index 0000000..1dc30b0 --- /dev/null +++ b/docker/docker-compose.yaml @@ -0,0 +1,36 @@ +version: "3" + +networks: + plakken: + external: false + +volumes: + redis: + driver: local + +services: + server: + image: git.gnous.eu/gnouseu/plakken:latest + restart: always + container_name: plakken + read_only: true + networks: + - plakken + ports: + - "3000:3000" + environment: + - PLAKKEN_REDIS_ADDRESS=redis:6379 + - POSTGRES_PASSWORD=gitea + - PLAKKEN_REDIS_DB=0 + - PLAKKEN_URL_LENGTH=5 + depends_on: + - redis + redis: + image: redis:7-alpine + restart: always + healthcheck: + test: [ "CMD", "redis-cli", "ping" ] + networks: + - plakken + volumes: + - redis:/data diff --git a/go.mod b/go.mod index 8331b11..8c8a25f 100644 --- a/go.mod +++ b/go.mod @@ -1,13 +1,14 @@ -module plakken +module git.gnous.eu/gnouseu/plakken -go 1.21 +go 1.22 require ( - github.com/joho/godotenv v1.5.1 - github.com/redis/go-redis/v9 v9.2.1 + github.com/redis/go-redis/v9 v9.5.1 + golang.org/x/crypto v0.21.0 ) require ( github.com/cespare/xxhash/v2 v2.2.0 // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect + golang.org/x/sys v0.18.0 // indirect ) diff --git a/go.sum b/go.sum index b1aca73..3f64f52 100644 --- a/go.sum +++ b/go.sum @@ -1,8 +1,14 @@ +github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= +github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= +github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= +github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= -github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= -github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= -github.com/redis/go-redis/v9 v9.2.1 h1:WlYJg71ODF0dVspZZCpYmoF1+U1Jjk9Rwd7pq6QmlCg= -github.com/redis/go-redis/v9 v9.2.1/go.mod h1:hdY0cQFCN4fnSYT6TkisLufl/4W5UIXyv0b/CLO2V2M= +github.com/redis/go-redis/v9 v9.5.1 h1:H1X4D3yHPaYrkL5X06Wh6xNVM/pX0Ft4RV0vMGvLBh8= +github.com/redis/go-redis/v9 v9.5.1/go.mod h1:hdY0cQFCN4fnSYT6TkisLufl/4W5UIXyv0b/CLO2V2M= +golang.org/x/crypto v0.21.0 h1:X31++rzVUdKhX5sWmSOFZxx8UW/ldWx55cbf08iNAMA= +golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs= +golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4= +golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..373f03e --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,52 @@ +package config + +import ( + "log" + "os" + "strconv" +) + +// 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 { + 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..ae9e4d5 --- /dev/null +++ b/internal/constant/constants.go @@ -0,0 +1,14 @@ +package constant + +import "time" + +const ( + HTTPTimeout = 3 * time.Second + ExpirationCurlCreate = 604800 * time.Second // Second in one week + TokenLength = 32 + ArgonSaltSize = 16 + ArgonMemory = 64 * 1024 + ArgonThreads = 4 + ArgonKeyLength = 32 + ArgonIterations = 2 +) diff --git a/internal/database/db.go b/internal/database/db.go new file mode 100644 index 0000000..8c5ec68 --- /dev/null +++ b/internal/database/db.go @@ -0,0 +1,81 @@ +package database + +import ( + "context" + "log" + "time" + + "git.gnous.eu/gnouseu/plakken/internal/secret" + "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 +} + +// Ping test connection to Redis database +func Ping(db *redis.Client) error { + status := db.Ping(ctx) + if status.String() != "ping: PONG" { + return &pingError{} + } + return nil +} + +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, token string) (bool, error) { + storedHash := config.DB.HGet(ctx, url, "secret").Val() + + result, err := secret.VerifyPassword(token, storedHash) + if err != nil { + return false, err + } + + return result, nil +} diff --git a/internal/database/error.go b/internal/database/error.go new file mode 100644 index 0000000..de0951e --- /dev/null +++ b/internal/database/error.go @@ -0,0 +1,7 @@ +package database + +type pingError struct{} + +func (m *pingError) Error() string { + return "Connection to redis not work" +} diff --git a/internal/httpServer/server.go b/internal/httpServer/server.go new file mode 100644 index 0000000..f17ea63 --- /dev/null +++ b/internal/httpServer/server.go @@ -0,0 +1,67 @@ +package httpServer + +import ( + "embed" + "log" + "net/http" + + "git.gnous.eu/gnouseu/plakken/internal/constant" + "git.gnous.eu/gnouseu/plakken/internal/web/plak" + "github.com/redis/go-redis/v9" +) + +type ServerConfig struct { + HTTPServer *http.Server + UrlLength uint8 + DB *redis.Client + Static embed.FS + Templates embed.FS +} + +func (config ServerConfig) home(w http.ResponseWriter, _ *http.Request) { + index, err := config.Static.ReadFile("static/index.html") + if err != nil { + log.Println(err) + } + _, err = w.Write(index) + if err != nil { + log.Println(err) + } +} + +// Configure HTTP router +func (config ServerConfig) router() { + WebConfig := plak.WebConfig{ + DB: config.DB, + UrlLength: config.UrlLength, + Templates: config.Templates, + } + staticFiles := http.FS(config.Static) + + http.HandleFunc("GET /{$}", config.home) + http.Handle("GET /static/{file}", http.FileServer(staticFiles)) + http.HandleFunc("GET /{key}/{settings...}", WebConfig.View) + http.HandleFunc("POST /{$}", WebConfig.CurlCreate) + http.HandleFunc("POST /create/{$}", WebConfig.PostCreate) + http.HandleFunc("DELETE /{key}", WebConfig.DeleteRequest) +} + +// 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) + + config.router() + + log.Fatal(config.HTTPServer.ListenAndServe()) +} diff --git a/internal/secret/crypto.go b/internal/secret/crypto.go new file mode 100644 index 0000000..957c260 --- /dev/null +++ b/internal/secret/crypto.go @@ -0,0 +1,147 @@ +// Package secret implement all crypto utils like password hashing and secret generation +package secret + +import ( + "bytes" + "crypto/rand" + "encoding/base64" + "fmt" + "strconv" + "strings" + + "git.gnous.eu/gnouseu/plakken/internal/constant" + "golang.org/x/crypto/argon2" +) + +type argon2idHash struct { + salt []byte + hash []byte +} + +// Argon2id config +type config struct { + saltLength uint8 + memory uint32 + threads uint8 + keyLength uint32 + iterations uint32 +} + +// generateSecret for password hashing or token generation +func generateSecret(length uint8) ([]byte, error) { + secret := make([]byte, length) + + _, err := rand.Read(secret) + if err != nil { + return nil, err + } + + return secret, err +} + +// GenerateToken generate hexadecimal token +func GenerateToken() (string, error) { + secret, err := generateSecret(constant.TokenLength) + if err != nil { + return "", err + } + + token := fmt.Sprintf("%x", secret) + + return token, nil +} + +// generateArgon2ID Generate an argon2id hash from source string and specified salt +func (config config) generateArgon2ID(source string, salt []byte) []byte { + hash := argon2.IDKey([]byte(source), salt, config.iterations, config.memory, config.threads, config.keyLength) + + return hash +} + +// Password hash a source string with argon2id, return properly encoded hash +func Password(password string) (string, error) { + config := config{ + saltLength: constant.ArgonSaltSize, + memory: constant.ArgonMemory, + threads: constant.ArgonThreads, + keyLength: constant.ArgonKeyLength, + iterations: constant.ArgonIterations, + } + + salt, err := generateSecret(config.saltLength) + if err != nil { + return "", err + } + + hash := config.generateArgon2ID(password, salt) + + base64Hash := base64.RawStdEncoding.EncodeToString(hash) + base64Salt := base64.RawStdEncoding.EncodeToString(salt) + + formatted := fmt.Sprintf("$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s", argon2.Version, config.memory, config.iterations, config.threads, base64Salt, base64Hash) + + return formatted, nil +} + +// VerifyPassword check is source password and stored password is similar, take password and a properly encoded hash. +func VerifyPassword(password string, hash string) (bool, error) { + argon2Hash, config, err := parseHash(hash) + if err != nil { + return false, err + } + + result := config.generateArgon2ID(password, argon2Hash.salt) + + return bytes.Equal(result, argon2Hash.hash), nil +} + +// parseHash parse existing encoded argon2id string +func parseHash(source string) (argon2idHash, config, error) { + separateItem := strings.Split(source, "$") + if len(separateItem) != 6 { + return argon2idHash{}, config{}, &parseError{message: "Hash format is not valid"} + } + + if separateItem[1] != "argon2id" { + return argon2idHash{}, config{}, &parseError{message: "Algorithm is not valid"} + } + + separateParam := strings.Split(separateItem[3], ",") + if len(separateParam) != 3 { + return argon2idHash{}, config{}, &parseError{message: "Hash config is not valid"} + } + + salt, err := base64.RawStdEncoding.Strict().DecodeString(separateItem[4]) + if err != nil { + return argon2idHash{}, config{}, err + } + + var hash []byte + hash, err = base64.RawStdEncoding.Strict().DecodeString(separateItem[5]) + if err != nil { + return argon2idHash{}, config{}, err + } + + saltLength := uint8(len(salt)) + keyLength := uint32(len(hash)) + + var memory int + memory, err = strconv.Atoi(strings.Replace(separateParam[0], "m=", "", -1)) + if err != nil { + return argon2idHash{}, config{}, err + } + + var iterations int + iterations, err = strconv.Atoi(strings.Replace(separateParam[1], "t=", "", -1)) + if err != nil { + return argon2idHash{}, config{}, err + } + + var threads int + threads, err = strconv.Atoi(strings.Replace(separateParam[2], "p=", "", -1)) + if err != nil { + return argon2idHash{}, config{}, err + } + + return argon2idHash{salt: salt, hash: hash}, config{saltLength: saltLength, memory: uint32(memory), threads: uint8(threads), iterations: uint32(iterations), keyLength: keyLength}, nil +} diff --git a/internal/secret/crypto_test.go b/internal/secret/crypto_test.go new file mode 100644 index 0000000..f983646 --- /dev/null +++ b/internal/secret/crypto_test.go @@ -0,0 +1,81 @@ +package secret + +import ( + "errors" + "fmt" + "regexp" + "testing" + + "git.gnous.eu/gnouseu/plakken/internal/constant" + "golang.org/x/crypto/argon2" +) + +func TestPasswordFormat(t *testing.T) { + regex := fmt.Sprintf("\\$argon2id\\$v=%d\\$m=%d,t=%d,p=%d\\$[A-Za-z0-9+/]*\\$[A-Za-z0-9+/]*$", argon2.Version, constant.ArgonMemory, constant.ArgonIterations, constant.ArgonThreads) + + got, err := Password("Password!") + if err != nil { + t.Fatal(err) + } + + result, _ := regexp.MatchString(regex, got) + if !result { + t.Fatal("Error in Password, format is not valid "+": ", got) + } +} + +func TestVerifyPassword(t *testing.T) { + result, err := VerifyPassword("Password!", "$argon2id$v=19$m=65536,t=2,p=4$A+t5YGpyy1BHCbvk/LP1xQ$eNuUj6B2ZqXlGi6KEqep39a7N4nysUIojuPXye+Ypp0") + if err != nil { + t.Fatal(err) + } + + if !result { + t.Fatal("Error in VerifyPassword, got:", result, "want: ", true) + } +} + +func TestVerifyPasswordInvalid(t *testing.T) { + result, err := VerifyPassword("notsamepassword", "$argon2id$v=19$m=65536,t=2,p=4$A+t5YGpyy1BHCbvk/LP1xQ$eNuUj6B2ZqXlGi6KEqep39a7N4nysUIojuPXye+Ypp0") + if err != nil { + t.Fatal(err) + } + + if result { + t.Fatal("Error in VerifyPassword, got:", result, "want: ", false) + } +} + +func TestParseHash(t *testing.T) { + _, config, err := parseHash("$argon2id$v=19$m=65536,t=2,p=4$A+t5YGpyy1BHCbvk/LP1xQ$eNuUj6B2ZqXlGi6KEqep39a7N4nysUIojuPXye+Ypp0") + if err != nil { + t.Fatal(err) + } + if config.saltLength != constant.ArgonSaltSize { + t.Fatal("Error in VerifyPassword: config.saltLength are not correct, go: ", config.saltLength, "want: ", constant.ArgonSaltSize) + } + + if config.keyLength != constant.ArgonKeyLength { + t.Fatal("Error in VerifyPassword: config.keyLength are not correct, go: ", config.saltLength, "want: ", constant.ArgonKeyLength) + } + + if config.threads != constant.ArgonThreads { + t.Fatal("Error in VerifyPassword: config.threads are not correct, go: ", config.threads, "want: ", constant.ArgonThreads) + } + + if config.memory != constant.ArgonMemory { + t.Fatal("Error in VerifyPassword: config.memory are not correct, go: ", config.memory, "want: ", constant.ArgonMemory) + } + + if config.iterations != constant.ArgonIterations { + t.Fatal("Error in VerifyPassword: config.iterations are not correct, go: ", config.iterations, "want: ", constant.ArgonIterations) + } +} + +func TestParseBadHashAlgo(t *testing.T) { + _, _, err := parseHash("$notvalid$v=19$m=65536,t=2,p=4$A+t5YGpyy1BHCbvk/LP1xQ$eNuUj6B2ZqXlGi6KEqep39a7N4nysUIojuPXye+Ypp0") + want := &parseError{message: "Algorithm is not valid"} + if !errors.As(err, &want) { + t.Fatal("Error in parseHash, want :", want, "got: ", err) + } +} diff --git a/internal/secret/error.go b/internal/secret/error.go new file mode 100644 index 0000000..2b82c3a --- /dev/null +++ b/internal/secret/error.go @@ -0,0 +1,9 @@ +package secret + +type parseError struct { + message string +} + +func (m *parseError) Error() string { + return "parseHash: " + m.message +} diff --git a/internal/utils/error.go b/internal/utils/error.go new file mode 100644 index 0000000..87acf33 --- /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/internal/utils/utils.go b/internal/utils/utils.go new file mode 100644 index 0000000..506f07f --- /dev/null +++ b/internal/utils/utils.go @@ -0,0 +1,99 @@ +package utils + +import ( + "log" + mathrand "math/rand/v2" + "regexp" + "strconv" + "strings" +) + +// GenerateUrl generate random string for plak url +func GenerateUrl(length uint8) string { + listChars := []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789") + b := make([]rune, length) + for i := range b { + b[i] = listChars[mathrand.IntN(len(listChars))] + } + + return string(b) +} + +// checkCharRedundant verify is a character is redundant in a string +func checkCharRedundant(source string, char string) bool { // Verify if a char is redundant + return strings.Count(source, char) > 1 +} + +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 0, &parseIntBeforeSeparatorError{message: *source + ": cannot parse value as int"} + } + if value < 0 { // Only positive value is correct + 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 +} + +// 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 + 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 0, &ParseExpirationError{message: "Invalid syntax"} + } + tempOutput, err = parseIntBeforeSeparator(&source, "h") + expiration += tempOutput * 3600 + if err != nil { + log.Println(err) + return 0, &ParseExpirationError{message: "Invalid syntax"} + } + tempOutput, err = parseIntBeforeSeparator(&source, "m") + expiration += tempOutput * 60 + if err != nil { + log.Println(err) + return 0, &ParseExpirationError{message: "Invalid syntax"} + } + tempOutput, err = parseIntBeforeSeparator(&source, "s") + expiration += tempOutput * 1 + if err != nil { + log.Println(err) + return 0, &ParseExpirationError{message: "Invalid syntax"} + } + + return expiration, nil +} + +// ValidKey Verify if a key is valid (only letter, number, - and _) +func ValidKey(key string) bool { + result, err := regexp.MatchString("^[a-zA-Z0-9_.-]*$", key) + if err != nil { + return false + } + log.Println(key, result) + return result +} diff --git a/internal/utils/utils_test.go b/internal/utils/utils_test.go new file mode 100644 index 0000000..f5ae6fe --- /dev/null +++ b/internal/utils/utils_test.go @@ -0,0 +1,105 @@ +package utils + +import ( + "errors" + "testing" +) + +func TestCheckCharNotRedundantTrue(t *testing.T) { // Test checkCharRedundant with redundant char + want := true + got := checkCharRedundant("2d1h3md4h7s", "h") + if got != want { + t.Fatal("Error in parseExpirationFull, want : ", want, "got : ", got) + } +} + +func TestCheckCharNotRedundantFalse(t *testing.T) { // Test checkCharRedundant with not redundant char + want := false + got := checkCharRedundant("2d1h3m47s", "h") + if got != want { + t.Fatal("Error in parseExpirationFull, want : ", want, "got : ", got) + } +} + +func TestParseExpirationFull(t *testing.T) { // test parseExpirationFull with all valid separator + result, _ := ParseExpiration("2d1h3m47s") + correctValue := 176627 + if result != correctValue { + t.Fatal("Error in parseExpirationFull, want : ", correctValue, "got : ", result) + } +} + +func TestParseExpirationMissing(t *testing.T) { // test parseExpirationFull with all valid separator + result, _ := ParseExpiration("1h47s") + correctValue := 3647 + if result != correctValue { + t.Fatal("Error in ParseExpirationFull, want : ", correctValue, "got : ", result) + } +} + +func TestParseExpirationWithCaps(t *testing.T) { // test parseExpirationFull with all valid separator + result, _ := ParseExpiration("2D1h3M47s") + correctValue := 176627 + if result != correctValue { + t.Fatal("Error in parseExpirationFull, want : ", correctValue, "got : ", result) + } +} + +func TestParseExpirationNull(t *testing.T) { // test ParseExpirationFull with all valid separator + result, _ := ParseExpiration("0") + correctValue := 0 + if result != correctValue { + t.Fatal("Error in ParseExpirationFull, want: ", correctValue, "got: ", result) + } +} + +func TestParseExpirationNegative(t *testing.T) { // test ParseExpirationFull with all valid separator + _, got := ParseExpiration("-42h1m4s") + want := &ParseExpirationError{} + if !errors.As(got, &want) { + t.Fatal("Error in ParseExpirationFull, want : ", want, "got : ", got) + } +} + +func TestParseExpirationInvalid(t *testing.T) { // test ParseExpirationFull with all valid separator + _, got := ParseExpiration("8h42h1m1d4s") + want := &ParseExpirationError{} + if !errors.As(got, &want) { + t.Fatal("Error in ParseExpirationFull, want : ", want, "got : ", got) + } + +} + +func TestParseExpirationInvalidRedundant(t *testing.T) { // test ParseExpirationFull with all valid separator + _, got := ParseExpiration("8h42h1m1h4s") + want := &ParseExpirationError{} + if !errors.As(got, &want) { + t.Fatal("Error in ParseExpirationFull, want : ", want, "got : ", got) + } +} + +func TestParseExpirationInvalidTooHigh(t *testing.T) { // test ParseExpirationFull with all valid separator + _, got := ParseExpiration("2d1h3m130s") + want := &ParseExpirationError{} + if !errors.As(got, &want) { + t.Fatal("Error in ParseExpirationFull, want : ", want, "got : ", got) + } +} + +func TestValidKey(t *testing.T) { // test ValidKey with a valid key + got := ValidKey("ab_a-C42") + want := true + + if got != want { + t.Fatal("Error in ValidKey, want : ", want, "got : ", got) + } +} + +func TestInValidKey(t *testing.T) { // test ValidKey with invalid key + got := ValidKey("ab_?a-C42") + want := false + + if got != want { + t.Fatal("Error in ValidKey, want : ", want, "got : ", got) + } +} diff --git a/internal/web/plak/error.go b/internal/web/plak/error.go new file mode 100644 index 0000000..46c1fbf --- /dev/null +++ b/internal/web/plak/error.go @@ -0,0 +1,18 @@ +package plak + +type deletePlakError struct { + name string + err error +} + +func (m *deletePlakError) Error() string { + return "Cannot delete: " + m.name + " : " + m.err.Error() +} + +type createError struct { + message string +} + +func (m *createError) Error() string { + return "create: cannot create plak: " + m.message +} diff --git a/internal/web/plak/plak.go b/internal/web/plak/plak.go new file mode 100644 index 0000000..f65f0f5 --- /dev/null +++ b/internal/web/plak/plak.go @@ -0,0 +1,251 @@ +package plak + +import ( + "context" + "embed" + "io" + "log" + "net/http" + "time" + + "git.gnous.eu/gnouseu/plakken/internal/constant" + "git.gnous.eu/gnouseu/plakken/internal/database" + "git.gnous.eu/gnouseu/plakken/internal/secret" + "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 + Templates embed.FS +} + +// plak "Object" for plak +type plak struct { + Key string + Content string + Expiration time.Duration + DB *redis.Client +} + +// create a plak +func (plak plak) create() (string, error) { + dbConf := database.DBConfig{ + DB: plak.DB, + } + + token, err := secret.GenerateToken() + if err != nil { + return "", err + } + + if dbConf.UrlExist(plak.Key) { + return "", &createError{message: "key already exist"} + } + + var hashedSecret string + hashedSecret, err = secret.Password(token) + if err != nil { + return "", err + } + + dbConf.InsertPaste(plak.Key, plak.Content, hashedSecret, plak.Expiration) + + return token, nil +} + +// PostCreate manage POST request for create plak +func (config WebConfig) PostCreate(w http.ResponseWriter, r *http.Request) { + content := r.FormValue("content") + if content == "" { + w.WriteHeader(http.StatusBadRequest) + return + } + + filename := r.FormValue("filename") + var key string + if len(filename) == 0 { + key = utils.GenerateUrl(config.UrlLength) + } else { + if !utils.ValidKey(filename) { + w.WriteHeader(http.StatusBadRequest) + return + } + key = filename + } + + plak := plak{ + Key: key, + Content: content, + DB: config.DB, + } + + rawExpiration := r.FormValue("exp") + expiration, err := utils.ParseExpiration(rawExpiration) + if err != nil { + log.Println(err) + w.WriteHeader(http.StatusInternalServerError) + return + } + + if expiration == 0 { + plak.Expiration = -1 + } else { + plak.Expiration = time.Duration(expiration * int(time.Second)) + } + + _, err = plak.create() + if err != nil { + log.Println(err) + w.WriteHeader(http.StatusInternalServerError) + return + } + + http.Redirect(w, r, "/"+key, http.StatusSeeOther) +} + +// CurlCreate PostCreate plak with minimum param, ideal for curl. Force 7 day expiration +func (config WebConfig) CurlCreate(w http.ResponseWriter, r *http.Request) { + if r.ContentLength == 0 { + w.WriteHeader(http.StatusBadRequest) + return + } + + content, _ := io.ReadAll(r.Body) + err := r.Body.Close() + if err != nil { + log.Println(err) + } + + key := utils.GenerateUrl(config.UrlLength) + + plak := plak{ + Key: key, + Content: string(content), + Expiration: constant.ExpirationCurlCreate, + DB: config.DB, + } + + var token string + token, err = plak.create() + if err != nil { + w.WriteHeader(http.StatusBadRequest) + return + } + + var baseURL string + if r.TLS == nil { + baseURL = "http://" + r.Host + "/" + key + } else { + baseURL = "https://" + r.Host + "/" + key + } + + message := baseURL + "\n" + "Delete with : 'curl -X DELETE " + baseURL + "?secret\\=" + token + "'" + "\n" + + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + _, err = io.WriteString(w, message) + if err != nil { + log.Println(err) + } +} + +// View for plak +func (config WebConfig) View(w http.ResponseWriter, r *http.Request) { + dbConf := database.DBConfig{ + DB: config.DB, + } + var currentPlak plak + key := r.PathValue("key") + + if dbConf.UrlExist(key) { + currentPlak = plak{ + Key: key, + DB: config.DB, + } + currentPlak = currentPlak.getContent() + if r.PathValue("settings") == "raw" { + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + _, err := io.WriteString(w, currentPlak.Content) + if err != nil { + log.Println(err) + } + } else { + t, err := template.ParseFS(config.Templates, "templates/paste.html") + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + log.Println(err) + return + } + err = t.Execute(w, currentPlak) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + log.Println(err) + return + } + } + } else { + w.WriteHeader(http.StatusNotFound) + } +} + +// DeleteRequest manage plak deletion endpoint +func (config WebConfig) DeleteRequest(w http.ResponseWriter, r *http.Request) { + dbConf := database.DBConfig{ + DB: config.DB, + } + key := r.PathValue("key") + var valid bool + + if dbConf.UrlExist(key) { + var err error + token := r.URL.Query().Get("secret") + + valid, err = dbConf.VerifySecret(key, token) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + log.Println(err) + return + } + + if valid { + plak := plak{ + Key: key, + DB: config.DB, + } + err := plak.delete() + if err != nil { + log.Println(err) + } + + w.WriteHeader(http.StatusNoContent) + return + } else { + w.WriteHeader(http.StatusForbidden) + return + } + } + + w.WriteHeader(http.StatusNotFound) +} + +// delete DeleteRequest plak from database +func (plak plak) delete() 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/main.go b/main.go index aa7c81c..8af2291 100644 --- a/main.go +++ b/main.go @@ -1,103 +1,37 @@ package main import ( - "fmt" - "github.com/redis/go-redis/v9" - "html/template" - "io" + "embed" "log" - "net/http" - "strings" + + "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", "") - switch r.Method { - case "GET": - if path == "/" { - http.ServeFile(w, r, "./static/index.html") - - } else if strings.HasPrefix(path, "/static/") { - fs := http.FileServer(http.Dir("./static")) - http.Handle("/static/", http.StripPrefix("/static/", fs)) - } else { - if urlExist(clearPath) { - if strings.HasSuffix(path, "/raw") { - pasteContent := getContent(clearPath) - w.Header().Set("Content-Type", "text/plain") - _, 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") - insertPaste(url, content, secret, -1) - http.Redirect(w, r, url, http.StatusSeeOther) - } else { - w.WriteHeader(http.StatusMethodNotAllowed) - } - case "DELETE": - if strings.HasPrefix(path, "/delete") { - urlItem := strings.Split(path, "/") - if urlExist("/" + urlItem[2]) { - secret := r.URL.Query().Get("secret") - if verifySecret("/"+urlItem[2], secret) { - deleteContent("/" + urlItem[2]) - w.WriteHeader(http.StatusNoContent) - } else { - w.WriteHeader(http.StatusForbidden) - } - } else { - w.WriteHeader(http.StatusNotFound) - } - } else { - w.WriteHeader(http.StatusMethodNotAllowed) - } - } -} +var ( + //go:embed templates + templates embed.FS + //go:embed static + static embed.FS +) func main() { - db = connectDB() - currentConfig = getConfig() - listen := currentConfig.host + ":" + currentConfig.port - http.HandleFunc("/", handleRequest) - - if currentConfig.host == "" { - fmt.Println("Listening on port " + listen) - } else { - fmt.Println("Listening on " + listen) - } - - err := http.ListenAndServe(listen, nil) + initConfig := config.GetConfig() + dbConfig := database.InitDB(initConfig.RedisAddress, initConfig.RedisUser, initConfig.RedisPassword, initConfig.RedisDB) + db := database.ConnectDB(dbConfig) + err := database.Ping(db) if err != nil { log.Fatal(err) } + + serverConfig := httpServer.ServerConfig{ + HTTPServer: httpServer.Config(initConfig.ListenAddress), + UrlLength: initConfig.UrlLength, + DB: db, + Static: static, + Templates: templates, + } + + serverConfig.Server() } diff --git a/static/app.js b/static/app.js index e69de29..bef2c56 100644 --- a/static/app.js +++ b/static/app.js @@ -0,0 +1,31 @@ +const codeEditor = document.getElementById('content'); +const lineCounter = document.getElementById('lines'); + +let lineCountCache = 0; + +// Update line counter +function updateLineCounter() { + const lineCount = codeEditor.value.split('\n').length; + + if (lineCountCache !== lineCount) { + const outarr = Array.from({length: lineCount}, (_, index) => index + 1); + lineCounter.value = outarr.join('\n'); + } + + lineCountCache = lineCount; +} + +codeEditor.addEventListener('input', updateLineCounter); + +codeEditor.addEventListener('keydown', (e) => { + if (e.key === 'Tab') { + e.preventDefault(); + + const {value, selectionStart, selectionEnd} = codeEditor; + codeEditor.value = `${value.slice(0, selectionStart)}\t${value.slice(selectionEnd)}`; + codeEditor.setSelectionRange(selectionStart + 1, selectionStart + 1); + updateLineCounter(); + } +}); + +updateLineCounter(); diff --git a/static/index.html b/static/index.html index 10504d4..8459130 100644 --- a/static/index.html +++ b/static/index.html @@ -7,52 +7,61 @@ name="description"> - New plak • Plakken + New plak • Plakken -
- - - + +
+ + +
+
+ + + +
\ No newline at end of file diff --git a/static/style.css b/static/style.css index d605146..cf339b3 100644 --- a/static/style.css +++ b/static/style.css @@ -1,8 +1,9 @@ :root { --accent: #be0560; - --background: #141414; + --background: #121212; --border: #333; - --text: #e2e2e2; + --text: #e6e6e6; + --placeholder: #666; } body { @@ -12,45 +13,20 @@ body { margin: 0; } -button, textarea { - background-color: inherit; - border: none; -} - -textarea { - color: var(--text); - font: 14px/1.6 "JetBrains Mono", monospace; - height: calc(100vh - 3rem); - outline: none; - padding: 1rem; - resize: none; - width: calc(100vw - 2rem); -} - -pre { - margin: 15px; -} - -nav { - align-items: end; - bottom: 1rem; +form { display: flex; flex-flow: row wrap; - position: absolute; - right: 1rem; } -ul { - display: flex; - flex-flow: row wrap; - gap: 2.6rem; - list-style: none; - margin: 0; - padding: 0 1.9rem; -} - -label { - display: block; +#lines { + color: var(--placeholder); + font: 400 14px/1.6 "JetBrains Mono", monospace; + height: calc(100vh - 29px); + overflow-y: hidden; + padding: 8px 0; + text-align: center; + user-select: none; + width: 26px; } input, select { @@ -58,9 +34,9 @@ input, select { border: 2px solid var(--border); border-radius: 2px; color: var(--text); - font-size: 15px; + font-size: 13px; outline: none; - padding: 5px 6px; + padding: 6px 8px; transition: border .15s ease; } @@ -68,14 +44,44 @@ input:hover, select:hover { border-color: var(--text); } +button, textarea { + background-color: inherit; + border: none; + outline: none; + resize: none; +} + +#content { + color: var(--text); + font: 400 14px/1.6 "JetBrains Mono", monospace; + height: calc(100vh - 29px); + padding: 8px; + width: calc(100vw - 45px); +} + +nav { + top: 1rem; + display: flex; + flex-flow: row wrap; + position: absolute; + right: 12px; +} + +ul { + display: flex; + flex-flow: row wrap; + gap: 36px; + list-style: none; + margin: 0; + padding: 0 1.9rem; +} + svg { - fill: none; - height: 26px; - stroke: var(--text); - stroke-width: 2; - stroke-linecap: round; + cursor: pointer; + height: 24px; + fill: var(--text); transition: .15s ease; - width: 26px; + width: 24px; } svg:hover { @@ -86,10 +92,6 @@ input:focus-visible, select:focus-visible { border: 2px solid var(--text); } -button:focus-visible{ - outline: none; -} - ::selection { background-color: var(--accent); color: #fff; diff --git a/utils.go b/utils.go deleted file mode 100644 index 36305da..0000000 --- a/utils.go +++ /dev/null @@ -1,40 +0,0 @@ -package main - -import ( - "crypto/rand" - "encoding/hex" - "log" - mathrand "math/rand" -) - -func generateUrl() string { - listChars := []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789") - b := make([]rune, currentConfig.urlLength) - for i := range b { - b[i] = listChars[mathrand.Intn(len(listChars))] - } - - return string(b) -} - -func generateSecret() string { - key := make([]byte, 32) - _, err := rand.Read(key) - if err != nil { - log.Printf("Failed to generate secret") - } - - return hex.EncodeToString(key) -} - -func urlExist(url string) bool { - exist := db.Exists(ctx, url).Val() - return exist == 1 -} - -func verifySecret(url string, secret string) bool { - if secret == db.HGet(ctx, url, "secret").Val() { - return true - } - return false -}