From ca95749ec9bb3b5bd57bbda3f0b38b72d04afa39 Mon Sep 17 00:00:00 2001 From: Darius klein Date: Sat, 26 Jul 2025 23:31:00 +0200 Subject: [PATCH] first commit --- .github/workflows/Deploy-docker.yml | 22 ++++ Dockerfile | 22 ++++ common/bolt.go | 159 ++++++++++++++++++++++++++++ common/const.go | 6 ++ common/filter.go | 10 ++ common/hash.go | 24 +++++ common/jwt/create.go | 30 ++++++ common/jwt/verify.go | 33 ++++++ common/scheduler.go | 25 +++++ common/time.go | 7 ++ common/todo.go | 40 +++++++ common/types.go | 32 ++++++ go.mod | 11 ++ handler/errorHandlers.go | 62 +++++++++++ handler/loginHandler.go | 34 ++++++ handler/registerHandler.go | 37 +++++++ handler/storeHandler.go | 32 ++++++ handler/syncHandler.go | 71 +++++++++++++ main.go | 58 ++++++++++ 19 files changed, 715 insertions(+) create mode 100644 .github/workflows/Deploy-docker.yml create mode 100644 Dockerfile create mode 100644 common/bolt.go create mode 100644 common/const.go create mode 100644 common/filter.go create mode 100644 common/hash.go create mode 100644 common/jwt/create.go create mode 100644 common/jwt/verify.go create mode 100644 common/scheduler.go create mode 100644 common/time.go create mode 100644 common/todo.go create mode 100644 common/types.go create mode 100644 go.mod create mode 100644 handler/errorHandlers.go create mode 100644 handler/loginHandler.go create mode 100644 handler/registerHandler.go create mode 100644 handler/storeHandler.go create mode 100644 handler/syncHandler.go create mode 100644 main.go diff --git a/.github/workflows/Deploy-docker.yml b/.github/workflows/Deploy-docker.yml new file mode 100644 index 0000000..17b3431 --- /dev/null +++ b/.github/workflows/Deploy-docker.yml @@ -0,0 +1,22 @@ +name: build and deploy kleinTodo + +on: + push: + branches: [ "master" ] + pull_request: + branches: [ "master" ] + +jobs: + + build: + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v3 + - name: Build the Docker image + run: docker compose build + - name: Docker login + run: docker login gitea.kleinsense.nl -p ${{secrets.docker_password}} -u ${{secrets.docker_username}} + - name: Docker push + run: docker push gitea.kleinsense.nl/dariusklein/kleinTodo:latest diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..6679845 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,22 @@ +# Use an official Golang runtime as a parent image +FROM golang:latest as build + +# Set the working directory to /app +WORKDIR /app +# Copy the current directory contents into the container at /app +COPY go.mod . +COPY go.sum . +# Download and install any required dependencies +RUN go mod download + +COPY . . + +# Build the Go app +RUN go build . + +FROM gcr.io/distroless/base-debian12 + +COPY --from=build /app/kleinTodo . + +# Define the command to run the app when the container starts +CMD ["./kleinTodo"] diff --git a/common/bolt.go b/common/bolt.go new file mode 100644 index 0000000..204099b --- /dev/null +++ b/common/bolt.go @@ -0,0 +1,159 @@ +package common + +import ( + "fmt" + bolt "go.etcd.io/bbolt" + "sync" +) + +type DataStore interface { + SaveValueToBucket(bucket, key, value string) error + CreateBucket(bucket string) error + GetFromBucketByKey(bucket, key string) (string, error) + GetAllBuckets() ([]string, error) + GetAllFromBucket(bucket string) (map[string]string, error) + GetAllKeysFromBucket(bucket string) ([]string, error) + EmptyBucket(bucket string) error + Close() error +} + +type BoltStore struct { + DB *bolt.DB +} + +func NewBoltStore(path string) (*BoltStore, error) { + db, err := bolt.Open(path, 0600, nil) + if err != nil { + return nil, fmt.Errorf("could not open db: %w", err) + } + return &BoltStore{DB: db}, nil +} + +func (s *BoltStore) Close() error { + return s.DB.Close() +} + +var ( + dataStore *BoltStore + once sync.Once + err error +) + +func GetTodoDataStore() (*BoltStore, error) { + once.Do(func() { + // We assign to the outer 'dataStore' and 'err' variables. + dataStore, err = NewBoltStore("todo.db") + }) + if err != nil { + return nil, err + } + + return dataStore, nil +} + +// SaveValueToBucket Save data to bucket +func (s *BoltStore) SaveValueToBucket(bucket, key, value string) error { + return s.DB.Update(func(tx *bolt.Tx) error { + b, err := tx.CreateBucketIfNotExists([]byte(bucket)) + if err != nil { + return err + } + return b.Put([]byte(key), []byte(value)) + }) +} + +// RemoveValueFromBucket remove value +func (s *BoltStore) RemoveValueFromBucket(bucket, key string) error { + return s.DB.Update(func(tx *bolt.Tx) error { + b, err := tx.CreateBucketIfNotExists([]byte(bucket)) + if err != nil { + return err + } + return b.Delete([]byte(key)) + }) +} + +// CreateBucket Create bucket if not exists +func (s *BoltStore) CreateBucket(bucket string) error { + return s.DB.Update(func(tx *bolt.Tx) error { + _, err := tx.CreateBucketIfNotExists([]byte(bucket)) + return err + }) +} + +// GetFromBucketByKey Returns value from bucket by key or empty string +func (s *BoltStore) GetFromBucketByKey(bucket, key string) string { + var value string + s.DB.View(func(tx *bolt.Tx) error { + b := tx.Bucket([]byte(bucket)) + if b == nil { + return nil // Bucket doesn't exist, nothing to do + } + // Get returns nil if the key doesn't exist + valBytes := b.Get([]byte(key)) + if valBytes != nil { + value = string(valBytes) + } + return nil + }) + return value +} + +// GetAllBuckets Returns map of all buckets +func (s *BoltStore) GetAllBuckets() []string { + var a = make([]string, 0) + s.DB.View(func(tx *bolt.Tx) error { + return tx.ForEach(func(name []byte, b *bolt.Bucket) error { + a = append(a, string(name)) + return nil + }) + }) + return a +} + +// GetAllFromBucket Returns map of all from bucket +func (s *BoltStore) GetAllFromBucket(bucket string) map[string]string { + results := make(map[string]string) + s.DB.View(func(tx *bolt.Tx) error { + b := tx.Bucket([]byte(bucket)) + if b == nil { + return nil + } + // Using a cursor is the recommended way to iterate + c := b.Cursor() + for k, v := c.First(); k != nil; k, v = c.Next() { + results[string(k)] = string(v) + } + return nil + }) + return results +} + +// GetAllKeysFromBucket Returns keys from bucket +func (s *BoltStore) GetAllKeysFromBucket(bucket string) []string { + var keys []string + s.DB.View(func(tx *bolt.Tx) error { + b := tx.Bucket([]byte(bucket)) + if b == nil { + return nil + } + return b.ForEach(func(k, v []byte) error { + keys = append(keys, string(k)) + return nil + }) + }) + return keys +} + +// EmptyBucket Returns value from bucket by key or empty string +func (s *BoltStore) EmptyBucket(bucket string) error { + return s.DB.Update(func(tx *bolt.Tx) error { + if err := tx.DeleteBucket([]byte(bucket)); err != nil { + return err + } + if _, err := tx.CreateBucket([]byte(bucket)); err != nil { + return err + } + return nil + }) +} diff --git a/common/const.go b/common/const.go new file mode 100644 index 0000000..7aece64 --- /dev/null +++ b/common/const.go @@ -0,0 +1,6 @@ +package common + +const UserBucket = "users" +const TodoBucket = "todo" + +const AuthHeader = "Authorization" diff --git a/common/filter.go b/common/filter.go new file mode 100644 index 0000000..a48527c --- /dev/null +++ b/common/filter.go @@ -0,0 +1,10 @@ +package common + +func Filter[T any](ss []T, test func(T) bool) (ret []T) { + for _, s := range ss { + if test(s) { + ret = append(ret, s) + } + } + return +} diff --git a/common/hash.go b/common/hash.go new file mode 100644 index 0000000..6728901 --- /dev/null +++ b/common/hash.go @@ -0,0 +1,24 @@ +package common + +import ( + "golang.org/x/crypto/bcrypt" + "log/slog" +) + +func HashPassword(password string) (string, error) { + bytes, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + return string(bytes), err +} + +func (credentials *Credentials) ComparePasswords(password string) bool { + err := bcrypt.CompareHashAndPassword([]byte(password), []byte(credentials.Password)) + if err != nil { + slog.Error(err.Error()) + return false + } + return true +} + +func (credentials *Credentials) HashedPassword() (string, error) { + return HashPassword(credentials.Password) +} diff --git a/common/jwt/create.go b/common/jwt/create.go new file mode 100644 index 0000000..a7b5ac2 --- /dev/null +++ b/common/jwt/create.go @@ -0,0 +1,30 @@ +package jwt + +import ( + "github.com/golang-jwt/jwt/v5" + "os" + "time" +) + +func CreateUserJWT(name string) string { + + //create claims for jwt + claims := jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(time.Now().Add(24 * time.Hour)), + IssuedAt: jwt.NewNumericDate(time.Now()), + NotBefore: jwt.NewNumericDate(time.Now()), + Issuer: "todo.dariusklein.nl", + Subject: name, + } + return SignJWT(claims) +} + +func SignJWT(claims jwt.Claims) string { + //Build jwt with claims + t := jwt.NewWithClaims(jwt.SigningMethodHS512, claims) + //get jwt secret from environment + secret := os.Getenv("JWT_SECRET") + //sign jwt token with secret + token, _ := t.SignedString([]byte(secret)) + return token +} diff --git a/common/jwt/verify.go b/common/jwt/verify.go new file mode 100644 index 0000000..d7eda4d --- /dev/null +++ b/common/jwt/verify.go @@ -0,0 +1,33 @@ +package jwt + +import ( + _ "context" + "gitea.kleinsense.nl/DariusKlein/kleinTodo/common" + "github.com/golang-jwt/jwt/v5" + "net/http" + "os" + "strings" +) + +func GetVerifiedUser(r *http.Request) (string, error) { + verifyJWT, err := VerifyJWT(strings.TrimPrefix(r.Header.Get(common.AuthHeader), "Bearer \t")) + if err != nil { + return "", err + } + return verifyJWT, nil +} + +// VerifyJWT verify JWT token and returns user object +func VerifyJWT(authToken string) (string, error) { + //get jwt secret from environment + secret := os.Getenv("JWT_SECRET") + //parse jwt token + token, err := jwt.ParseWithClaims(authToken, &jwt.RegisteredClaims{}, func(token *jwt.Token) (interface{}, error) { + return []byte(secret), nil + }) + if err != nil { + return "", err + } + subject := token.Claims.(*jwt.RegisteredClaims).Subject + return subject, err +} diff --git a/common/scheduler.go b/common/scheduler.go new file mode 100644 index 0000000..88c3c91 --- /dev/null +++ b/common/scheduler.go @@ -0,0 +1,25 @@ +package common + +import ( + "time" +) + +func Schedule(seconds time.Duration, task func()) (stop func()) { + ticker := time.NewTicker(seconds) + quit := make(chan struct{}) + go func() { + for { + select { + case <-ticker.C: + task() + case <-quit: + ticker.Stop() + return + } + } + }() + + return func() { + close(quit) + } +} diff --git a/common/time.go b/common/time.go new file mode 100644 index 0000000..0e62832 --- /dev/null +++ b/common/time.go @@ -0,0 +1,7 @@ +package common + +import "time" + +func CompareTime(t1, t2 time.Time) time.Duration { + return t2.Sub(t1) +} diff --git a/common/todo.go b/common/todo.go new file mode 100644 index 0000000..b38f8f8 --- /dev/null +++ b/common/todo.go @@ -0,0 +1,40 @@ +package common + +import ( + "encoding/json" + "fmt" +) + +func (todo Todo) Store(store *BoltStore, user string) error { + if todo.Owner != user { + return fmt.Errorf("unauthorized user") + } + todoJson, err := json.Marshal(todo) + if err != nil { + return err + } + return store.SaveValueToBucket(user, todo.Name, string(todoJson)) +} + +func (todoRequest StoreTodoRequest) Store(store *BoltStore, user string) error { + todo := Todo{ + Name: todoRequest.Name, + Description: todoRequest.Description, + Status: todoRequest.Status, + Owner: user, + } + todoJson, err := json.Marshal(todo) + if err != nil { + return err + } + return store.SaveValueToBucket(user, todo.Name, string(todoJson)) +} + +func (todos TodoList) FindByName(name string) (Todo, bool) { + for _, todo := range todos.Todos { + if todo.Name == name { + return todo, true + } + } + return Todo{}, false +} diff --git a/common/types.go b/common/types.go new file mode 100644 index 0000000..c40eb77 --- /dev/null +++ b/common/types.go @@ -0,0 +1,32 @@ +package common + +type Credentials struct { + Username string `json:"username"` + Password string `json:"password"` +} + +type Todo struct { + Name string `json:"name"` + Description string `json:"description"` + Status string `json:"status"` + Owner string `json:"owner"` +} + +type StoreTodoRequest struct { + Name string `json:"name"` + Description string `json:"description"` + Status string `json:"status"` +} + +type TodoList struct { + Todos []Todo `json:"todos"` +} + +type MisMatchingTodo struct { + ServerTodo Todo `json:"server_todo"` + LocalTodo Todo `json:"local_todo"` +} +type SyncResponse struct { + SyncedTodos []Todo `json:"synced_todos"` + MisMatchingTodos []MisMatchingTodo `json:"mismatching_todos"` +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..dc0dcca --- /dev/null +++ b/go.mod @@ -0,0 +1,11 @@ +module gitea.kleinsense.nl/DariusKlein/kleinTodo + +go 1.24.4 + +require ( + github.com/golang-jwt/jwt/v5 v5.2.3 + go.etcd.io/bbolt v1.4.2 + golang.org/x/crypto v0.40.0 +) + +require golang.org/x/sys v0.34.0 // indirect diff --git a/handler/errorHandlers.go b/handler/errorHandlers.go new file mode 100644 index 0000000..46412ea --- /dev/null +++ b/handler/errorHandlers.go @@ -0,0 +1,62 @@ +package handler + +import ( + "log" + "net/http" +) + +func InternalServerErrorHandler(w http.ResponseWriter, err error) { + setError(w, http.StatusInternalServerError, err.Error()) +} + +func NotFoundHandler(w http.ResponseWriter) { + setError(w, http.StatusNotFound, "404 Not Found") +} + +func BadRequestHandler(w http.ResponseWriter) { + setError(w, http.StatusBadRequest, "404 Not Found") +} + +func UnprocessableEntityHandler(w http.ResponseWriter, err error) { + setError(w, http.StatusUnprocessableEntity, err.Error()) +} + +func UnauthorizedHandler(w http.ResponseWriter) { + log.Println("unauthorized") + setError(w, http.StatusUnauthorized, "Unauthorized") +} + +func NotImplementedHandler(w http.ResponseWriter) { + setError(w, http.StatusNotImplemented, "WORK IN PROGRESS") +} + +func setError(w http.ResponseWriter, httpStatus int, errorMessage string) { + w.WriteHeader(httpStatus) + if _, err := w.Write([]byte(errorMessage)); err != nil { + log.Println(err) + } + return +} + +func handleError(w http.ResponseWriter, status int, err error) bool { + if err != nil { + switch status { + case http.StatusInternalServerError: + InternalServerErrorHandler(w, err) + case http.StatusNotFound: + NotFoundHandler(w) + case http.StatusBadRequest: + BadRequestHandler(w) + case http.StatusUnauthorized: + UnauthorizedHandler(w) + case http.StatusNotImplemented: + NotImplementedHandler(w) + case http.StatusUnprocessableEntity: + UnprocessableEntityHandler(w, err) + default: + InternalServerErrorHandler(w, err) + } + return true + } + return false +} diff --git a/handler/loginHandler.go b/handler/loginHandler.go new file mode 100644 index 0000000..5e285a2 --- /dev/null +++ b/handler/loginHandler.go @@ -0,0 +1,34 @@ +package handler + +import ( + "encoding/json" + "errors" + "gitea.kleinsense.nl/DariusKlein/kleinTodo/common" + "gitea.kleinsense.nl/DariusKlein/kleinTodo/common/jwt" + "net/http" +) + +func LoginHandler(w http.ResponseWriter, r *http.Request) { + var user common.Credentials + // Decode input + err := json.NewDecoder(r.Body).Decode(&user) + if handleError(w, http.StatusInternalServerError, err) { + return + } + // Get data store + db, err := common.GetTodoDataStore() + if handleError(w, http.StatusInternalServerError, err) { + return + } + password := db.GetFromBucketByKey(common.UserBucket, user.Username) + if user.ComparePasswords(password) { + w.Header().Set(common.AuthHeader, jwt.CreateUserJWT(user.Username)) + w.WriteHeader(http.StatusOK) + if handleError(w, http.StatusInternalServerError, err) { + return + } + } else { + handleError(w, http.StatusUnauthorized, errors.New("username or password is incorrect")) + return + } +} diff --git a/handler/registerHandler.go b/handler/registerHandler.go new file mode 100644 index 0000000..df16b3e --- /dev/null +++ b/handler/registerHandler.go @@ -0,0 +1,37 @@ +package handler + +import ( + "encoding/json" + "gitea.kleinsense.nl/DariusKlein/kleinTodo/common" + "net/http" +) + +func RegisterHandler(w http.ResponseWriter, r *http.Request) { + var user common.Credentials + // Decode input + err := json.NewDecoder(r.Body).Decode(&user) + if handleError(w, http.StatusInternalServerError, err) { + return + } + // Get data store + db, err := common.GetTodoDataStore() + if handleError(w, http.StatusInternalServerError, err) { + return + } + // Check if user exists + if len(db.GetFromBucketByKey(common.UserBucket, user.Username)) > 0 { + w.WriteHeader(http.StatusUnprocessableEntity) + w.Write([]byte(`{"error":"user already exists"}`)) + return + } + // Hash password + password, err := user.HashedPassword() + if handleError(w, http.StatusBadRequest, err) { + return + } + // Store user + err = db.SaveValueToBucket(common.UserBucket, user.Username, password) + if handleError(w, http.StatusInternalServerError, err) { + return + } +} diff --git a/handler/storeHandler.go b/handler/storeHandler.go new file mode 100644 index 0000000..c2ae0e7 --- /dev/null +++ b/handler/storeHandler.go @@ -0,0 +1,32 @@ +package handler + +import ( + "encoding/json" + "gitea.kleinsense.nl/DariusKlein/kleinTodo/common" + "gitea.kleinsense.nl/DariusKlein/kleinTodo/common/jwt" + "net/http" +) + +func StoreHandler(w http.ResponseWriter, r *http.Request) { + user, err := jwt.GetVerifiedUser(r) + if handleError(w, http.StatusUnauthorized, err) { + return + } + + var todo common.StoreTodoRequest + // Decode input + err = json.NewDecoder(r.Body).Decode(&todo) + if handleError(w, http.StatusBadRequest, err) { + return + } + + store, err := common.GetTodoDataStore() + if handleError(w, http.StatusInternalServerError, err) { + return + } + + err = todo.Store(store, user) + if handleError(w, http.StatusInternalServerError, err) { + return + } +} diff --git a/handler/syncHandler.go b/handler/syncHandler.go new file mode 100644 index 0000000..1777d6f --- /dev/null +++ b/handler/syncHandler.go @@ -0,0 +1,71 @@ +package handler + +import ( + "encoding/json" + "gitea.kleinsense.nl/DariusKlein/kleinTodo/common" + "gitea.kleinsense.nl/DariusKlein/kleinTodo/common/jwt" + "net/http" + "reflect" +) + +func SyncHandler(w http.ResponseWriter, r *http.Request) { + user, err := jwt.GetVerifiedUser(r) + if handleError(w, http.StatusUnauthorized, err) { + return + } + + var todoList common.TodoList + err = json.NewDecoder(r.Body).Decode(&todoList) + if handleError(w, http.StatusBadRequest, err) { + return + } + + store, err := common.GetTodoDataStore() + if handleError(w, http.StatusInternalServerError, err) { + return + } + + storedTodoJsons := store.GetAllFromBucket(user) + + serverTodos := make(map[string]common.Todo) + for key, val := range storedTodoJsons { + var todo common.Todo + if json.Unmarshal([]byte(val), &todo) == nil { + serverTodos[key] = todo + } + } + + var response = common.SyncResponse{ + SyncedTodos: []common.Todo{}, + MisMatchingTodos: []common.MisMatchingTodo{}, + } + + for _, clientTodo := range todoList.Todos { + serverTodo, exists := serverTodos[clientTodo.Name] + + if !exists { + err = clientTodo.Store(store, user) + if handleError(w, http.StatusInternalServerError, err) { + return + } + serverTodos[clientTodo.Name] = clientTodo + } else { + if !reflect.DeepEqual(serverTodo, clientTodo) { + response.MisMatchingTodos = append(response.MisMatchingTodos, common.MisMatchingTodo{ + ServerTodo: serverTodo, + LocalTodo: clientTodo, + }) + } + } + } + + for _, todo := range serverTodos { + response.SyncedTodos = append(response.SyncedTodos, todo) + } + + w.Header().Set("Content-Type", "application/json") + err = json.NewEncoder(w).Encode(response) + if handleError(w, http.StatusInternalServerError, err) { + return + } +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..44a0209 --- /dev/null +++ b/main.go @@ -0,0 +1,58 @@ +package main + +import ( + "fmt" + "gitea.kleinsense.nl/DariusKlein/kleinTodo/common" + "gitea.kleinsense.nl/DariusKlein/kleinTodo/handler" + "log" + "net/http" + "os" + "time" +) + +func main() { + db, err := common.GetTodoDataStore() + if err != nil { + log.Fatal(err) + } + defer db.Close() + + // Create a new ServeMux to route requests. + mux := http.NewServeMux() + + // Register handler for each endpoint. + mux.HandleFunc("POST /register", handler.RegisterHandler) + mux.HandleFunc("POST /login", handler.LoginHandler) + mux.HandleFunc("POST /store", handler.StoreHandler) + mux.HandleFunc("GET /sync", handler.SyncHandler) + + // A simple root handler to confirm the server is running. + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/" { + http.NotFound(w, r) + return + } + fmt.Fprintln(w, "API Server is running. Use the /register, /login, /store, or /sync endpoints.") + }) + + port := os.Getenv("SERVER_PORT") + if port == "" { + port = "8080" + } + + // Configure the server. + server := &http.Server{ + Addr: ":" + port, + Handler: mux, + ReadTimeout: 10 * time.Second, + WriteTimeout: 10 * time.Second, + } + + log.Printf("Server starting on port %s...", port) + + // Start the server. + // log.Fatal will exit the application if the server fails to start. + if err := server.ListenAndServe(); err != nil { + log.Fatal(err) + } +}