first commit

This commit is contained in:
Darius klein 2025-07-26 23:31:00 +02:00
parent 76ae38e837
commit ca95749ec9
19 changed files with 715 additions and 0 deletions

22
.github/workflows/Deploy-docker.yml vendored Normal file
View File

@ -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

22
Dockerfile Normal file
View File

@ -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"]

159
common/bolt.go Normal file
View File

@ -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
})
}

6
common/const.go Normal file
View File

@ -0,0 +1,6 @@
package common
const UserBucket = "users"
const TodoBucket = "todo"
const AuthHeader = "Authorization"

10
common/filter.go Normal file
View File

@ -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
}

24
common/hash.go Normal file
View File

@ -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)
}

30
common/jwt/create.go Normal file
View File

@ -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
}

33
common/jwt/verify.go Normal file
View File

@ -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
}

25
common/scheduler.go Normal file
View File

@ -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)
}
}

7
common/time.go Normal file
View File

@ -0,0 +1,7 @@
package common
import "time"
func CompareTime(t1, t2 time.Time) time.Duration {
return t2.Sub(t1)
}

40
common/todo.go Normal file
View File

@ -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
}

32
common/types.go Normal file
View File

@ -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"`
}

11
go.mod Normal file
View File

@ -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

62
handler/errorHandlers.go Normal file
View File

@ -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
}

34
handler/loginHandler.go Normal file
View File

@ -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
}
}

View File

@ -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
}
}

32
handler/storeHandler.go Normal file
View File

@ -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
}
}

71
handler/syncHandler.go Normal file
View File

@ -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
}
}

58
main.go Normal file
View File

@ -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)
}
}