commit 2e2fbdf80078015e7ab8558ba187e399c235da54 Author: Bagas Aulia Rezki Date: Thu Apr 25 22:51:37 2024 +0700 Add password validation diff --git a/.air.toml b/.air.toml new file mode 100644 index 0000000..3651e65 --- /dev/null +++ b/.air.toml @@ -0,0 +1,46 @@ +root = "." +testdata_dir = "testdata" +tmp_dir = "tmp" + +[build] + args_bin = [] + bin = "tmp\\main.exe" + cmd = "go build -o ./tmp/main.exe ." + delay = 1000 + exclude_dir = ["assets", "tmp", "vendor", "testdata", "uploads"] + exclude_file = [] + exclude_regex = ["_test.go"] + exclude_unchanged = false + follow_symlink = false + full_bin = "" + include_dir = [] + include_ext = ["go", "tpl", "tmpl", "html"] + include_file = [] + kill_delay = "0s" + log = "build-errors.log" + poll = false + poll_interval = 0 + post_cmd = [] + pre_cmd = [] + rerun = false + rerun_delay = 500 + send_interrupt = false + stop_on_error = false + +[color] + app = "" + build = "yellow" + main = "magenta" + runner = "green" + watcher = "cyan" + +[log] + main_only = false + time = false + +[misc] + clean_on_exit = false + +[screen] + clear_on_rebuild = false + keep_scroll = true diff --git a/.env b/.env new file mode 100644 index 0000000..4441a74 --- /dev/null +++ b/.env @@ -0,0 +1,17 @@ +SERVER_HOST=localhost +SERVER_PORT=8000 + +DOMAIN=filekeeper.fossy.my.id + +CORS_PROTO=https +CORS_LIST=filekeeper.fossy.my.id:443,fossy.my.id:443 +CORS_METHODS=POST,GET + +DB_HOST=127.0.0.1 +DB_PORT=3306 +DB_USERNAME=root +DB_PASSWORD=test123 +DB_NAME=filekeeper + +SESSION_NAME=Session +SESSION_MAX_AGE=604800 \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c613e59 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +/tmp +/uploads +/public/output.css +/log + +*_templ.txt +*_templ.go \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..0dcdb68 --- /dev/null +++ b/Makefile @@ -0,0 +1,9 @@ +run: generate tailwindcss + @go build -o ./tmp/main.exe + @./tmp/main.exe + +generate: + @templ generate + +tailwindcss: + @npx tailwindcss -i ./public/input.css -o ./public/output.css \ No newline at end of file diff --git a/db/database.go b/db/database.go new file mode 100644 index 0000000..8123f2e --- /dev/null +++ b/db/database.go @@ -0,0 +1,39 @@ +package db + +import ( + "fmt" + "github.com/fossyy/filekeeper/logger" + "github.com/fossyy/filekeeper/utils" + "gorm.io/driver/mysql" + _ "gorm.io/driver/mysql" + "gorm.io/gorm" + gormLogger "gorm.io/gorm/logger" + "os" + "strings" +) + +var DB *gorm.DB + +var log *logger.AggregatedLogger + +func init() { + var err error + connection := fmt.Sprintf("%s:%s@tcp(%s:%s)/%s?charset=utf8mb4&parseTime=True&loc=Local", utils.Getenv("DB_USERNAME"), utils.Getenv("DB_PASSWORD"), utils.Getenv("DB_HOST"), utils.Getenv("DB_PORT"), utils.Getenv("DB_NAME")) + DB, err = gorm.Open(mysql.Open(connection), &gorm.Config{}, &gorm.Config{ + Logger: gormLogger.Default.LogMode(gormLogger.Silent), + }) + if err != nil { + panic("failed to connect database") + } + file, err := os.ReadFile("schema.sql") + if err != nil { + log.Error("Error opening file: %v", err) + } + querys := strings.Split(string(file), "\n") + for _, query := range querys { + err := DB.Exec(query).Error + if err != nil { + panic(err.Error()) + } + } +} diff --git a/db/model/user/user.go b/db/model/user/user.go new file mode 100644 index 0000000..81917d3 --- /dev/null +++ b/db/model/user/user.go @@ -0,0 +1,77 @@ +package user + +import ( + "fmt" + "github.com/fossyy/filekeeper/db" + "github.com/fossyy/filekeeper/logger" + "github.com/fossyy/filekeeper/types" + "sync" + "time" +) + +type Cache struct { + users map[string]*types.UserWithExpired + mu sync.Mutex +} + +var log *logger.AggregatedLogger +var UserCache *Cache + +func init() { + log = logger.Logger() + + UserCache = &Cache{users: make(map[string]*types.UserWithExpired)} + ticker := time.NewTicker(time.Hour * 8) + + go func() { + for { + <-ticker.C + currentTime := time.Now() + cacheClean := 0 + log.Info(fmt.Sprintf("Cache cleanup initiated at %02d:%02d:%02d", currentTime.Hour(), currentTime.Minute(), currentTime.Second())) + + UserCache.mu.Lock() + for _, user := range UserCache.users { + if currentTime.Sub(user.AccessAt) > time.Hour*8 { + delete(UserCache.users, user.Email) + cacheClean++ + } + } + UserCache.mu.Unlock() + + log.Info(fmt.Sprintf("Cache cleanup completed: %d entries removed. Finished at %s", cacheClean, time.Since(currentTime))) + } + }() +} + +func Get(email string) (*types.UserWithExpired, error) { + UserCache.mu.Lock() + defer UserCache.mu.Unlock() + + if user, ok := UserCache.users[email]; ok { + return user, nil + } + + var userData types.UserWithExpired + err := db.DB.Table("users").Where("email = ?", email).First(&userData).Error + if err != nil { + return nil, err + } + + UserCache.users[email] = &types.UserWithExpired{ + UserID: userData.UserID, + Username: userData.Username, + Email: userData.Email, + Password: userData.Password, + AccessAt: time.Now(), + } + + return &userData, nil +} + +func DeleteCache(email string) { + UserCache.mu.Lock() + defer UserCache.mu.Unlock() + + delete(UserCache.users, email) +} diff --git a/email/email.go b/email/email.go new file mode 100644 index 0000000..c02a73c --- /dev/null +++ b/email/email.go @@ -0,0 +1,39 @@ +package email + +import ( + "gopkg.in/gomail.v2" +) + +type SmtpServer struct { + Host string + Port int + User string + Password string +} + +type Email interface { + Send() +} + +func NewSmtpServer(Host string, Port int, User string, Password string) *SmtpServer { + return &SmtpServer{ + Host: Host, + Port: Port, + User: User, + Password: Password, + } +} + +func (mail *SmtpServer) Send(dst string, subject string, body string) error { + m := gomail.NewMessage() + m.SetHeader("From", mail.User) + m.SetHeader("To", dst) + m.SetHeader("Subject", subject) + m.SetBody("text/html", body) + d := gomail.NewDialer(mail.Host, mail.Port, mail.User, mail.Password) + + if err := d.DialAndSend(m); err != nil { + return err + } + return nil +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..9564b2c --- /dev/null +++ b/go.mod @@ -0,0 +1,20 @@ +module github.com/fossyy/filekeeper + +go 1.22.2 + +require ( + github.com/a-h/templ v0.2.648 + github.com/google/uuid v1.6.0 + github.com/joho/godotenv v1.5.1 + golang.org/x/crypto v0.21.0 + gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df + gorm.io/driver/mysql v1.5.6 + gorm.io/gorm v1.25.8 +) + +require ( + github.com/go-sql-driver/mysql v1.7.0 // indirect + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/jinzhu/now v1.1.5 // indirect + gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..be21266 --- /dev/null +++ b/go.sum @@ -0,0 +1,25 @@ +github.com/a-h/templ v0.2.648 h1:A1ggHGIE7AONOHrFaDTM8SrqgqHL6fWgWCijQ21Zy9I= +github.com/a-h/templ v0.2.648/go.mod h1:SA7mtYwVEajbIXFRh3vKdYm/4FYyLQAtPH1+KxzGPA8= +github.com/go-sql-driver/mysql v1.7.0 h1:ueSltNNllEqE3qcWBTD0iQd3IpL/6U+mJxLkazJ7YPc= +github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= +github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= +github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= +github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +golang.org/x/crypto v0.21.0 h1:X31++rzVUdKhX5sWmSOFZxx8UW/ldWx55cbf08iNAMA= +golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs= +gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc h1:2gGKlE2+asNV9m7xrywl36YYNnBG5ZQ0r/BOOxqPpmk= +gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc/go.mod h1:m7x9LTH6d71AHyAX77c9yqWCCa3UKHcVEj9y7hAtKDk= +gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df h1:n7WqCuqOuCbNr617RXOY0AWRXxgwEyPp2z+p0+hgMuE= +gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df/go.mod h1:LRQQ+SO6ZHR7tOkpBDuZnXENFzX8qRjMDMyPD6BRkCw= +gorm.io/driver/mysql v1.5.6 h1:Ld4mkIickM+EliaQZQx3uOJDJHtrd70MxAUqWqlx3Y8= +gorm.io/driver/mysql v1.5.6/go.mod h1:sEtPWMiqiN1N1cMXoXmBbd8C6/l+TESwriotuRRpkDM= +gorm.io/gorm v1.25.7/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8= +gorm.io/gorm v1.25.8 h1:WAGEZ/aEcznN4D03laj8DKnehe1e9gYQAjW8xyPRdeo= +gorm.io/gorm v1.25.8/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8= diff --git a/handler/download/download.go b/handler/download/download.go new file mode 100644 index 0000000..c8da242 --- /dev/null +++ b/handler/download/download.go @@ -0,0 +1,62 @@ +package downloadHandler + +import ( + "errors" + "github.com/fossyy/filekeeper/db" + "github.com/fossyy/filekeeper/logger" + "github.com/fossyy/filekeeper/middleware" + "github.com/fossyy/filekeeper/session" + "github.com/fossyy/filekeeper/types" + "github.com/fossyy/filekeeper/types/models" + "github.com/fossyy/filekeeper/utils" + downloadView "github.com/fossyy/filekeeper/view/download" + "net/http" +) + +var log *logger.AggregatedLogger + +func init() { + log = logger.Logger() +} + +func GET(w http.ResponseWriter, r *http.Request) { + cookie, err := r.Cookie("Session") + if err != nil { + if errors.Is(err, http.ErrNoCookie) { + http.Redirect(w, r, "/signin", http.StatusSeeOther) + return + } + log.Error(err.Error()) + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + storeSession, err := session.Store.Get(cookie.Value) + if err != nil { + if errors.Is(err, &session.SessionNotFound{}) { + storeSession.Destroy(w) + } + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + userSession := middleware.GetUser(storeSession) + + var files []models.File + db.DB.Table("files").Where("owner_id = ?", userSession.UserID).Find(&files) + var filesData []types.FileData + for i := 0; i < len(files); i++ { + filesData = append(filesData, types.FileData{ + ID: files[i].ID.String(), + Name: files[i].Name, + Size: utils.ConvertFileSize(files[i].Size), + Downloaded: files[i].Downloaded, + }) + } + + component := downloadView.Main("Download Page", filesData) + err = component.Render(r.Context(), w) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + log.Error(err.Error()) + return + } +} diff --git a/handler/download/file/file.go b/handler/download/file/file.go new file mode 100644 index 0000000..29c4464 --- /dev/null +++ b/handler/download/file/file.go @@ -0,0 +1,56 @@ +package downloadFileHandler + +import ( + "github.com/fossyy/filekeeper/db" + "github.com/fossyy/filekeeper/logger" + "github.com/fossyy/filekeeper/types/models" + "net/http" + "os" + "path/filepath" +) + +var log *logger.AggregatedLogger + +func init() { + log = logger.Logger() +} + +func GET(w http.ResponseWriter, r *http.Request) { + fileID := r.PathValue("id") + + var file models.File + err := db.DB.Table("files").Where("id = ?", fileID).First(&file).Error + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + log.Error(err.Error()) + } + + uploadDir := "uploads" + + currentDir, _ := os.Getwd() + basePath := filepath.Join(currentDir, uploadDir) + saveFolder := filepath.Join(basePath, file.OwnerID.String(), file.ID.String()) + + if filepath.Dir(saveFolder) != filepath.Join(basePath, file.OwnerID.String()) { + log.Error("invalid path") + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + openFile, err := os.OpenFile(filepath.Join(saveFolder, file.Name), os.O_RDONLY, 0) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + log.Error(err.Error()) + } + defer openFile.Close() + + stat, err := openFile.Stat() + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + log.Error(err.Error()) + } + + w.Header().Set("Content-Disposition", "attachment; filename="+stat.Name()) + http.ServeContent(w, r, stat.Name(), stat.ModTime(), openFile) + return +} diff --git a/handler/error/error.go b/handler/error/error.go new file mode 100644 index 0000000..447a384 --- /dev/null +++ b/handler/error/error.go @@ -0,0 +1,23 @@ +package errorHandler + +import ( + "github.com/fossyy/filekeeper/logger" + errorView "github.com/fossyy/filekeeper/view/error" + "net/http" +) + +var log *logger.AggregatedLogger + +func init() { + log = logger.Logger() +} + +func ALL(w http.ResponseWriter, r *http.Request) { + component := errorView.Main("Not Found") + err := component.Render(r.Context(), w) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + log.Error(err.Error()) + return + } +} diff --git a/handler/forgotPassword/forgotPassword.go b/handler/forgotPassword/forgotPassword.go new file mode 100644 index 0000000..f1e42a0 --- /dev/null +++ b/handler/forgotPassword/forgotPassword.go @@ -0,0 +1,149 @@ +package forgotPasswordHandler + +import ( + "bytes" + "context" + "errors" + "fmt" + "github.com/fossyy/filekeeper/db" + "github.com/fossyy/filekeeper/email" + "github.com/fossyy/filekeeper/logger" + "github.com/fossyy/filekeeper/types" + "github.com/fossyy/filekeeper/types/models" + "github.com/fossyy/filekeeper/utils" + emailView "github.com/fossyy/filekeeper/view/email" + forgotPasswordView "github.com/fossyy/filekeeper/view/forgotPassword" + "gorm.io/gorm" + "net/http" + "sync" + "time" +) + +type ForgotPassword struct { + User *models.User + Code string + mu sync.Mutex + CreateTime time.Time +} + +var log *logger.AggregatedLogger +var mailServer *email.SmtpServer +var ListForgotPassword map[string]*ForgotPassword +var UserForgotPassword = make(map[string]string) + +func init() { + log = logger.Logger() + ListForgotPassword = make(map[string]*ForgotPassword) + mailServer = email.NewSmtpServer("mail.fossy.my.id", 25, "test@fossy.my.id", "Test123456") + ticker := time.NewTicker(time.Minute) + go func() { + for { + <-ticker.C + currentTime := time.Now() + cacheClean := 0 + log.Info(fmt.Sprintf("Cache cleanup initiated at %02d:%02d:%02d", currentTime.Hour(), currentTime.Minute(), currentTime.Second())) + + for _, data := range ListForgotPassword { + data.mu.Lock() + if currentTime.Sub(data.CreateTime) > time.Minute*1 { + delete(ListForgotPassword, data.User.Email) + delete(UserForgotPassword, data.Code) + cacheClean++ + } + data.mu.Unlock() + } + + log.Info(fmt.Sprintf("Cache cleanup completed: %d entries removed. Finished at %s", cacheClean, time.Since(currentTime))) + } + }() +} + +func GET(w http.ResponseWriter, r *http.Request) { + component := forgotPasswordView.Main("Forgot Password Page", types.Message{ + Code: 3, + Message: "", + }) + err := component.Render(r.Context(), w) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + log.Error(err.Error()) + return + } +} + +func POST(w http.ResponseWriter, r *http.Request) { + err := r.ParseForm() + if err != nil { + http.Error(w, "Error parsing form", http.StatusBadRequest) + log.Error(err.Error()) + return + } + + emailForm := r.Form.Get("email") + + var user models.User + err = db.DB.Table("users").Where("email = ?", emailForm).First(&user).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + component := forgotPasswordView.Main(fmt.Sprintf("Account with this email address %s is not found", emailForm), types.Message{ + Code: 0, + Message: "", + }) + err := component.Render(r.Context(), w) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + log.Error(err.Error()) + return + } + return + } + + err = verifyForgot(&user) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + log.Error(err.Error()) + return + } + + component := forgotPasswordView.EmailSend("Forgot Password Page") + err = component.Render(r.Context(), w) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + log.Error(err.Error()) + return + } + return +} + +func verifyForgot(user *models.User) error { + var code string + + var buffer bytes.Buffer + data, ok := ListForgotPassword[user.Email] + + if !ok { + code = utils.GenerateRandomString(64) + } else { + code = data.Code + } + + err := emailView.ForgotPassword(user.Username, fmt.Sprintf("https://%s/forgot-password/verify/%s", utils.Getenv("DOMAIN"), code)).Render(context.Background(), &buffer) + if err != nil { + return err + } + + userData := &ForgotPassword{ + User: user, + Code: code, + CreateTime: time.Now(), + } + + UserForgotPassword[code] = user.Email + ListForgotPassword[user.Email] = userData + + err = mailServer.Send(user.Email, "Password Change Request", buffer.String()) + if err != nil { + return err + } + + return nil +} diff --git a/handler/forgotPassword/verify/verify.go b/handler/forgotPassword/verify/verify.go new file mode 100644 index 0000000..e577ba5 --- /dev/null +++ b/handler/forgotPassword/verify/verify.go @@ -0,0 +1,109 @@ +package forgotPasswordVerifyHandler + +import ( + "github.com/fossyy/filekeeper/db" + "github.com/fossyy/filekeeper/db/model/user" + forgotPasswordHandler "github.com/fossyy/filekeeper/handler/forgotPassword" + "github.com/fossyy/filekeeper/logger" + "github.com/fossyy/filekeeper/session" + "github.com/fossyy/filekeeper/types" + "github.com/fossyy/filekeeper/utils" + forgotPasswordView "github.com/fossyy/filekeeper/view/forgotPassword" + signupView "github.com/fossyy/filekeeper/view/signup" + + "net/http" +) + +var log *logger.AggregatedLogger + +func init() { + log = logger.Logger() +} + +func GET(w http.ResponseWriter, r *http.Request) { + code := r.PathValue("code") + + email := forgotPasswordHandler.UserForgotPassword[code] + _, ok := forgotPasswordHandler.ListForgotPassword[email] + + if !ok { + w.WriteHeader(http.StatusNotFound) + return + } + + component := forgotPasswordView.NewPasswordForm("Forgot Password Page", types.Message{ + Code: 3, + Message: "", + }) + err := component.Render(r.Context(), w) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + log.Error(err.Error()) + return + } +} + +func POST(w http.ResponseWriter, r *http.Request) { + code := r.PathValue("code") + + email := forgotPasswordHandler.UserForgotPassword[code] + data, ok := forgotPasswordHandler.ListForgotPassword[email] + + if !ok { + w.WriteHeader(http.StatusNotFound) + return + } + + err := r.ParseForm() + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + log.Error(err.Error()) + return + } + + password := r.Form.Get("password") + isValid := utils.ValidatePassword(password) + if !isValid { + component := signupView.Main("Sign up Page", types.Message{ + Code: 0, + Message: "Password is invalid", + }) + err := component.Render(r.Context(), w) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + log.Error(err.Error()) + return + } + return + } + hashedPassword, err := utils.HashPassword(password) + + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + log.Error(err.Error()) + return + } + + err = db.DB.Table("users").Where("email = ?", data.User.Email).Update("password", hashedPassword).Error + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + log.Error(err.Error()) + return + } + + delete(forgotPasswordHandler.ListForgotPassword, data.User.Email) + delete(forgotPasswordHandler.UserForgotPassword, data.Code) + + session.RemoveAllSession(data.User.Email) + + user.DeleteCache(data.User.Email) + + component := forgotPasswordView.ChangeSuccess("Forgot Password Page") + err = component.Render(r.Context(), w) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + log.Error(err.Error()) + return + } + return +} diff --git a/handler/index/index.go b/handler/index/index.go new file mode 100644 index 0000000..bc17bb8 --- /dev/null +++ b/handler/index/index.go @@ -0,0 +1,23 @@ +package indexHandler + +import ( + "github.com/fossyy/filekeeper/logger" + "github.com/fossyy/filekeeper/view/index" + "net/http" +) + +var log *logger.AggregatedLogger + +func init() { + log = logger.Logger() +} + +func GET(w http.ResponseWriter, r *http.Request) { + component := indexView.Main("main page") + err := component.Render(r.Context(), w) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + log.Error(err.Error()) + return + } +} diff --git a/handler/logout/logout.go b/handler/logout/logout.go new file mode 100644 index 0000000..6c6fd2f --- /dev/null +++ b/handler/logout/logout.go @@ -0,0 +1,44 @@ +package logoutHandler + +import ( + "errors" + "github.com/fossyy/filekeeper/logger" + "github.com/fossyy/filekeeper/session" + "github.com/fossyy/filekeeper/types" + "github.com/fossyy/filekeeper/utils" + "net/http" +) + +var log *logger.AggregatedLogger + +func init() { + log = logger.Logger() +} + +func GET(w http.ResponseWriter, r *http.Request) { + cookie, err := r.Cookie("Session") + if err != nil { + return + } + + storeSession, err := session.Store.Get(cookie.Value) + if err != nil { + if errors.Is(err, &session.SessionNotFound{}) { + storeSession.Destroy(w) + } + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + session.Store.Delete(cookie.Value) + session.RemoveSession(storeSession.Values["user"].(types.User).Email, cookie.Value) + + http.SetCookie(w, &http.Cookie{ + Name: utils.Getenv("SESSION_NAME"), + Value: "", + MaxAge: -1, + }) + + http.Redirect(w, r, "/signin", http.StatusSeeOther) + return +} diff --git a/handler/misc/misc.go b/handler/misc/misc.go new file mode 100644 index 0000000..5992897 --- /dev/null +++ b/handler/misc/misc.go @@ -0,0 +1,25 @@ +package miscHandler + +import ( + "net/http" +) + +func Robot(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, "/public/robots.txt", http.StatusSeeOther) +} + +func Favicon(w http.ResponseWriter, r *http.Request) { + //currentDir, _ := os.Getwd() + //fmt.Println(currentDir) + //logo := "../../../favicon.ico" + //basePath := filepath.Join(currentDir, "public") + //logoPath := filepath.Join(basePath, logo) + //fmt.Println(filepath.Dir(logoPath)) + //if filepath.Dir(logoPath) != basePath { + // log.Print("invalid logo path", logoPath) + // w.WriteHeader(500) + // return + //} + //http.ServeContent() + http.Redirect(w, r, "/public/favicon.ico", http.StatusSeeOther) +} diff --git a/handler/signin/signin.go b/handler/signin/signin.go new file mode 100644 index 0000000..13c8bd0 --- /dev/null +++ b/handler/signin/signin.go @@ -0,0 +1,92 @@ +package signinHandler + +import ( + "errors" + "github.com/fossyy/filekeeper/db/model/user" + "github.com/fossyy/filekeeper/logger" + "github.com/fossyy/filekeeper/session" + "github.com/fossyy/filekeeper/types" + "github.com/fossyy/filekeeper/utils" + signinView "github.com/fossyy/filekeeper/view/signin" + "net/http" +) + +var log *logger.AggregatedLogger + +func init() { + log = logger.Logger() +} + +func GET(w http.ResponseWriter, r *http.Request) { + component := signinView.Main("Sign in Page", types.Message{ + Code: 3, + Message: "", + }) + err := component.Render(r.Context(), w) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + log.Error(err.Error()) + return + } +} + +func POST(w http.ResponseWriter, r *http.Request) { + err := r.ParseForm() + if err != nil { + http.Error(w, "Error parsing form", http.StatusBadRequest) + log.Error(err.Error()) + return + } + email := r.Form.Get("email") + password := r.Form.Get("password") + userData, err := user.Get(email) + if err != nil { + component := signinView.Main("Sign in Page", types.Message{ + Code: 0, + Message: "Incorrect Username or Password", + }) + log.Error(err.Error()) + err = component.Render(r.Context(), w) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + log.Error(err.Error()) + return + } + return + } + + if email == userData.Email && utils.CheckPasswordHash(password, userData.Password) { + storeSession := session.Store.Create() + storeSession.Values["user"] = types.User{ + UserID: userData.UserID, + Email: email, + Username: userData.Username, + Authenticated: true, + } + + storeSession.Save(w) + session.AppendSession(email, storeSession) + + cookie, err := r.Cookie("redirect") + if errors.Is(err, http.ErrNoCookie) { + http.Redirect(w, r, "/", http.StatusSeeOther) + return + } + http.SetCookie(w, &http.Cookie{ + Name: "redirect", + MaxAge: -1, + }) + http.Redirect(w, r, cookie.Value, http.StatusSeeOther) + return + } + component := signinView.Main("Sign in Page", types.Message{ + Code: 0, + Message: "Incorrect Username or Password", + }) + err = component.Render(r.Context(), w) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + log.Error(err.Error()) + return + } +} diff --git a/handler/signup/signup.go b/handler/signup/signup.go new file mode 100644 index 0000000..92cebb6 --- /dev/null +++ b/handler/signup/signup.go @@ -0,0 +1,179 @@ +package signupHandler + +import ( + "bytes" + "context" + "errors" + "fmt" + "github.com/fossyy/filekeeper/db" + "github.com/fossyy/filekeeper/email" + "github.com/fossyy/filekeeper/logger" + "github.com/fossyy/filekeeper/types" + "github.com/fossyy/filekeeper/types/models" + "github.com/fossyy/filekeeper/utils" + emailView "github.com/fossyy/filekeeper/view/email" + signupView "github.com/fossyy/filekeeper/view/signup" + "github.com/google/uuid" + "gorm.io/gorm" + "net/http" + "sync" + "time" +) + +type UnverifiedUser struct { + User *models.User + Code string + mu sync.Mutex + CreateTime time.Time +} + +var log *logger.AggregatedLogger +var mailServer *email.SmtpServer +var VerifyUser map[string]*UnverifiedUser +var VerifyEmail map[string]string + +func init() { + log = logger.Logger() + mailServer = email.NewSmtpServer("mail.fossy.my.id", 25, "test@fossy.my.id", "Test123456") + VerifyUser = make(map[string]*UnverifiedUser) + VerifyEmail = make(map[string]string) + + ticker := time.NewTicker(time.Minute) + go func() { + for { + <-ticker.C + currentTime := time.Now() + cacheClean := 0 + log.Info(fmt.Sprintf("Cache cleanup initiated at %02d:%02d:%02d", currentTime.Hour(), currentTime.Minute(), currentTime.Second())) + + for _, data := range VerifyUser { + data.mu.Lock() + if currentTime.Sub(data.CreateTime) > time.Minute*1 { + delete(VerifyUser, data.Code) + delete(VerifyEmail, data.User.Email) + cacheClean++ + } + data.mu.Unlock() + } + + log.Info(fmt.Sprintf("Cache cleanup completed: %d entries removed. Finished at %s", cacheClean, time.Since(currentTime))) + } + }() +} + +func GET(w http.ResponseWriter, r *http.Request) { + component := signupView.Main("Sign up Page", types.Message{ + Code: 3, + Message: "", + }) + err := component.Render(r.Context(), w) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + log.Error(err.Error()) + return + } +} + +func POST(w http.ResponseWriter, r *http.Request) { + err := r.ParseForm() + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + log.Error(err.Error()) + return + } + userEmail := r.Form.Get("email") + username := r.Form.Get("username") + password := r.Form.Get("password") + isValid := utils.ValidatePassword(password) + if !isValid { + component := signupView.Main("Sign up Page", types.Message{ + Code: 0, + Message: "Password is invalid", + }) + err := component.Render(r.Context(), w) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + log.Error(err.Error()) + return + } + return + } + hashedPassword, err := utils.HashPassword(password) + + newUser := models.User{ + UserID: uuid.New(), + Username: username, + Email: userEmail, + Password: hashedPassword, + } + + var data models.User + err = db.DB.Table("users").Where("email = ? OR username = ?", userEmail, username).First(&data).Error + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + err = verifyEmail(&newUser) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + log.Error(err.Error()) + return + } + + component := signupView.EmailSend("Sign up Page") + err = component.Render(r.Context(), w) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + log.Error(err.Error()) + return + } + return + } + http.Error(w, err.Error(), http.StatusInternalServerError) + log.Error(err.Error()) + return + } + component := signupView.Main("Sign up Page", types.Message{ + Code: 0, + Message: "Email or Username has been registered", + }) + err = component.Render(r.Context(), w) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + log.Error(err.Error()) + return + } + return +} + +func verifyEmail(user *models.User) error { + var buffer bytes.Buffer + var code string + + code = VerifyEmail[user.Email] + userData, ok := VerifyUser[code] + + if !ok { + code = utils.GenerateRandomString(64) + } else { + code = userData.Code + } + + err := emailView.RegistrationEmail(user.Username, fmt.Sprintf("https://%s/signup/verify/%s", utils.Getenv("DOMAIN"), code)).Render(context.Background(), &buffer) + if err != nil { + return err + } + + unverifiedUser := UnverifiedUser{ + User: user, + Code: code, + CreateTime: time.Now(), + } + + VerifyUser[code] = &unverifiedUser + VerifyEmail[user.Email] = code + + err = mailServer.Send(user.Email, "Account Registration Verification", buffer.String()) + if err != nil { + return err + } + return nil +} diff --git a/handler/signup/verify/verify.go b/handler/signup/verify/verify.go new file mode 100644 index 0000000..b5f3d30 --- /dev/null +++ b/handler/signup/verify/verify.go @@ -0,0 +1,54 @@ +package signupVerifyHandler + +import ( + "github.com/fossyy/filekeeper/db" + signupHandler "github.com/fossyy/filekeeper/handler/signup" + "github.com/fossyy/filekeeper/logger" + "github.com/fossyy/filekeeper/types" + signupView "github.com/fossyy/filekeeper/view/signup" + "net/http" +) + +var log *logger.AggregatedLogger + +func init() { + log = logger.Logger() +} + +func GET(w http.ResponseWriter, r *http.Request) { + code := r.PathValue("code") + data, ok := signupHandler.VerifyUser[code] + + if !ok { + w.WriteHeader(http.StatusNotFound) + return + } + + err := db.DB.Create(&data.User).Error + + if err != nil { + component := signupView.Main("Sign up Page", types.Message{ + Code: 0, + Message: "Email or Username has been registered", + }) + err := component.Render(r.Context(), w) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + log.Error(err.Error()) + return + } + return + } + + delete(signupHandler.VerifyUser, code) + delete(signupHandler.VerifyEmail, data.User.Email) + + component := signupView.VerifySuccess("Verify page") + + err = component.Render(r.Context(), w) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + log.Error(err.Error()) + return + } +} diff --git a/handler/upload/initialisation/initialisation.go b/handler/upload/initialisation/initialisation.go new file mode 100644 index 0000000..630f5cd --- /dev/null +++ b/handler/upload/initialisation/initialisation.go @@ -0,0 +1,174 @@ +package initialisation + +import ( + "encoding/json" + "errors" + "github.com/fossyy/filekeeper/db" + "github.com/fossyy/filekeeper/logger" + "github.com/fossyy/filekeeper/middleware" + "github.com/fossyy/filekeeper/session" + "github.com/fossyy/filekeeper/types" + "github.com/fossyy/filekeeper/types/models" + "github.com/google/uuid" + "gorm.io/gorm" + "io" + "net/http" + "os" + "path/filepath" +) + +var log *logger.AggregatedLogger + +func init() { + log = logger.Logger() +} + +func POST(w http.ResponseWriter, r *http.Request) { + cookie, err := r.Cookie("Session") + if err != nil { + handleError(w, err, http.StatusInternalServerError) + return + } + + storeSession, err := session.Store.Get(cookie.Value) + if err != nil { + if errors.Is(err, &session.SessionNotFound{}) { + storeSession.Destroy(w) + } + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + userSession := middleware.GetUser(storeSession) + + body, err := io.ReadAll(r.Body) + if err != nil { + handleError(w, err, http.StatusInternalServerError) + return + } + + var fileInfo types.FileInfo + if err := json.Unmarshal(body, &fileInfo); err != nil { + handleError(w, err, http.StatusInternalServerError) + return + } + + fileData, err := getFile(fileInfo.Name, userSession.UserID) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + upload, err := handleNewUpload(userSession, fileInfo) + if err != nil { + handleError(w, err, http.StatusInternalServerError) + return + } + respondJSON(w, upload) + return + } + respondErrorJSON(w, err, http.StatusInternalServerError) + return + } + + info, err := GetUploadInfo(fileData.ID.String()) + if err != nil { + log.Error(err.Error()) + return + } + + if info.Done { + respondJSON(w, map[string]bool{"Done": true}) + return + } + respondJSON(w, info) +} + +func getFile(name string, ownerID uuid.UUID) (models.File, error) { + var data models.File + err := db.DB.Table("files").Where("name = ? AND owner_id = ?", name, ownerID).First(&data).Error + if err != nil { + return data, err + } + return data, nil +} + +func handleNewUpload(user types.User, file types.FileInfo) (models.FilesUploaded, error) { + uploadDir := "uploads" + if _, err := os.Stat(uploadDir); os.IsNotExist(err) { + log.Error(err.Error()) + err := os.Mkdir(uploadDir, os.ModePerm) + if err != nil { + log.Error(err.Error()) + return models.FilesUploaded{}, err + } + } + + fileID := uuid.New() + ownerID := user.UserID + + currentDir, _ := os.Getwd() + basePath := filepath.Join(currentDir, uploadDir) + saveFolder := filepath.Join(basePath, ownerID.String(), fileID.String()) + if filepath.Dir(saveFolder) != filepath.Join(basePath, ownerID.String()) { + return models.FilesUploaded{}, errors.New("invalid path") + } + + err := os.MkdirAll(saveFolder, os.ModePerm) + if err != nil { + log.Error(err.Error()) + return models.FilesUploaded{}, err + } + + newFile := models.File{ + ID: fileID, + OwnerID: ownerID, + Name: file.Name, + Size: file.Size, + Downloaded: 0, + } + err = db.DB.Create(&newFile).Error + if err != nil { + log.Error(err.Error()) + return models.FilesUploaded{}, err + } + + filesUploaded := models.FilesUploaded{ + UploadID: uuid.New(), + FileID: fileID, + OwnerID: ownerID, + Name: file.Name, + Size: file.Size, + Uploaded: -1, + Done: false, + } + + err = db.DB.Create(&filesUploaded).Error + if err != nil { + log.Error(err.Error()) + return models.FilesUploaded{}, err + } + return filesUploaded, nil +} + +func GetUploadInfo(fileID string) (*models.FilesUploaded, error) { + var data *models.FilesUploaded + err := db.DB.Table("files_uploadeds").Where("file_id = ?", fileID).First(&data).Error + if err != nil { + return data, err + } + return data, nil +} + +func respondJSON(w http.ResponseWriter, data interface{}) { + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(data); err != nil { + handleError(w, err, http.StatusInternalServerError) + } +} + +func respondErrorJSON(w http.ResponseWriter, err error, statusCode int) { + w.WriteHeader(statusCode) + respondJSON(w, map[string]string{"error": err.Error()}) +} + +func handleError(w http.ResponseWriter, err error, statusCode int) { + http.Error(w, err.Error(), statusCode) + log.Error(err.Error()) +} diff --git a/handler/upload/upload.go b/handler/upload/upload.go new file mode 100644 index 0000000..c3fa1b7 --- /dev/null +++ b/handler/upload/upload.go @@ -0,0 +1,142 @@ +package uploadHandler + +import ( + "errors" + "github.com/fossyy/filekeeper/db" + "github.com/fossyy/filekeeper/handler/upload/initialisation" + "github.com/fossyy/filekeeper/logger" + "github.com/fossyy/filekeeper/middleware" + "github.com/fossyy/filekeeper/session" + filesView "github.com/fossyy/filekeeper/view/upload" + "io" + "net/http" + "os" + "path/filepath" + "strconv" + "sync" +) + +var log *logger.AggregatedLogger +var mu sync.Mutex + +func init() { + log = logger.Logger() +} + +func GET(w http.ResponseWriter, r *http.Request) { + component := filesView.Main("upload page") + if err := component.Render(r.Context(), w); err != nil { + handleError(w, err, http.StatusInternalServerError) + return + } +} + +func POST(w http.ResponseWriter, r *http.Request) { + fileID := r.PathValue("id") + if err := r.ParseMultipartForm(32 << 20); err != nil { + handleError(w, err, http.StatusInternalServerError) + return + } + + cookie, err := r.Cookie("Session") + if err != nil { + handleCookieError(w, r, err) + return + } + + storeSession, err := session.Store.Get(cookie.Value) + if err != nil { + if errors.Is(err, &session.SessionNotFound{}) { + storeSession.Destroy(w) + } + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + userSession := middleware.GetUser(storeSession) + + if r.FormValue("done") == "true" { + finalizeFileUpload(fileID) + return + } + + uploadDir := "uploads" + if err := createUploadDirectory(uploadDir); err != nil { + handleError(w, err, http.StatusInternalServerError) + return + } + + file, err := initialisation.GetUploadInfo(fileID) + if err != nil { + log.Error("error getting upload info: " + err.Error()) + return + } + + currentDir, _ := os.Getwd() + basePath := filepath.Join(currentDir, uploadDir) + saveFolder := filepath.Join(basePath, userSession.UserID.String(), file.FileID.String()) + + if filepath.Dir(saveFolder) != filepath.Join(basePath, userSession.UserID.String()) { + log.Error("invalid path") + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + fileByte, _, err := r.FormFile("chunk") + if err != nil { + handleError(w, err, http.StatusInternalServerError) + return + } + defer fileByte.Close() + + dst, err := os.OpenFile(filepath.Join(saveFolder, file.Name), os.O_WRONLY|os.O_APPEND|os.O_CREATE, 0666) + if err != nil { + handleError(w, err, http.StatusInternalServerError) + return + } + defer dst.Close() + if _, err := io.Copy(dst, fileByte); err != nil { + handleError(w, err, http.StatusInternalServerError) + return + } + rawIndex := r.FormValue("index") + index, err := strconv.Atoi(rawIndex) + if err != nil { + return + } + updateIndex(index, fileID) +} + +func finalizeFileUpload(fileID string) { + db.DB.Table("files_uploadeds").Where("file_id = ?", fileID).Updates(map[string]interface{}{ + "Done": true, + }) +} + +func createUploadDirectory(uploadDir string) error { + if _, err := os.Stat(uploadDir); os.IsNotExist(err) { + if err := os.Mkdir(uploadDir, os.ModePerm); err != nil { + return err + } + } + return nil +} + +func updateIndex(index int, fileID string) { + db.DB.Table("files_uploadeds").Where("file_id = ?", fileID).Updates(map[string]interface{}{ + "Uploaded": index, + }) +} + +func handleCookieError(w http.ResponseWriter, r *http.Request, err error) { + if errors.Is(err, http.ErrNoCookie) { + http.Redirect(w, r, "/signin", http.StatusSeeOther) + return + } + handleError(w, err, http.StatusInternalServerError) +} + +func handleError(w http.ResponseWriter, err error, status int) { + http.Error(w, err.Error(), status) + log.Error(err.Error()) +} diff --git a/handler/user/user.go b/handler/user/user.go new file mode 100644 index 0000000..8b4cc59 --- /dev/null +++ b/handler/user/user.go @@ -0,0 +1,44 @@ +package userHandler + +import ( + "errors" + "github.com/fossyy/filekeeper/logger" + "github.com/fossyy/filekeeper/middleware" + "github.com/fossyy/filekeeper/session" + userView "github.com/fossyy/filekeeper/view/user" + "net/http" +) + +var log *logger.AggregatedLogger + +func init() { + log = logger.Logger() +} + +func GET(w http.ResponseWriter, r *http.Request) { + cookie, err := r.Cookie("Session") + if err != nil { + return + } + storeSession, err := session.Store.Get(cookie.Value) + if err != nil { + if errors.Is(err, &session.SessionNotFound{}) { + storeSession.Destroy(w) + } + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + userSession := middleware.GetUser(storeSession) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + log.Error(err.Error()) + return + } + component := userView.Main("User Page", userSession.Email, userSession.Username) + err = component.Render(r.Context(), w) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + log.Error(err.Error()) + return + } +} diff --git a/logger/logger.go b/logger/logger.go new file mode 100644 index 0000000..10541df --- /dev/null +++ b/logger/logger.go @@ -0,0 +1,60 @@ +package logger + +import ( + "fmt" + "io" + "log" + "os" + "time" +) + +type AggregatedLogger struct { + infoLogger *log.Logger + warnLogger *log.Logger + errorLogger *log.Logger + panicLogger *log.Logger +} + +func init() { + if _, err := os.Stat("log"); os.IsNotExist(err) { + os.Mkdir("log", os.ModePerm) + } +} + +func Logger() *AggregatedLogger { + currentTime := time.Now() + formattedTime := currentTime.Format("2006-01-02-15-04") + file, err := os.OpenFile(fmt.Sprintf("log/log-%s.log", formattedTime), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) + if err != nil { + return &AggregatedLogger{} + } + flag := log.Ldate | log.Ltime + writer := io.MultiWriter(os.Stdout, file) + infoLogger := log.New(writer, "INFO: ", flag) + warnLogger := log.New(writer, "WARN: ", flag) + errorLogger := log.New(writer, "ERROR: ", flag) + panicLogger := log.New(writer, "PANIC: ", flag) + + return &AggregatedLogger{ + infoLogger: infoLogger, + warnLogger: warnLogger, + errorLogger: errorLogger, + panicLogger: panicLogger, + } +} + +func (l *AggregatedLogger) Info(v ...interface{}) { + l.infoLogger.Println(v...) +} + +func (l *AggregatedLogger) Warn(v ...interface{}) { + l.warnLogger.Println(v...) +} + +func (l *AggregatedLogger) Error(v ...interface{}) { + l.errorLogger.Println(v...) +} + +func (l *AggregatedLogger) Panic(v ...interface{}) { + l.panicLogger.Panic(v...) +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..f674f0c --- /dev/null +++ b/main.go @@ -0,0 +1,23 @@ +package main + +import ( + "fmt" + "github.com/fossyy/filekeeper/middleware" + "github.com/fossyy/filekeeper/routes" + "github.com/fossyy/filekeeper/utils" + "net/http" +) + +func main() { + serverAddr := fmt.Sprintf("%s:%s", utils.Getenv("SERVER_HOST"), utils.Getenv("SERVER_PORT")) + server := http.Server{ + Addr: serverAddr, + Handler: middleware.Handler(routes.SetupRoutes()), + } + + fmt.Printf("Listening on http://%s\n", serverAddr) + err := server.ListenAndServe() + if err != nil { + return + } +} diff --git a/middleware/middleware.go b/middleware/middleware.go new file mode 100644 index 0000000..55324a2 --- /dev/null +++ b/middleware/middleware.go @@ -0,0 +1,146 @@ +package middleware + +import ( + "errors" + "fmt" + errorHandler "github.com/fossyy/filekeeper/handler/error" + "github.com/fossyy/filekeeper/logger" + "github.com/fossyy/filekeeper/session" + "github.com/fossyy/filekeeper/types" + "github.com/fossyy/filekeeper/utils" + "net/http" + "strings" +) + +var log *logger.AggregatedLogger + +func init() { + log = logger.Logger() +} + +type wrapper struct { + http.ResponseWriter + request *http.Request + statusCode int +} + +func (w *wrapper) WriteHeader(code int) { + if code == http.StatusNotFound { + w.Header().Set("Content-Type", "text/html") + errorHandler.ALL(w.ResponseWriter, w.request) + return + } + w.ResponseWriter.WriteHeader(code) + w.statusCode = code + return +} + +func Handler(next http.Handler) http.Handler { + return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + address := strings.Split(utils.Getenv("CORS_LIST"), ",") + + for _, addr := range address { + if request.Host == addr { + writer.Header().Set("Access-Control-Allow-Origin", fmt.Sprintf("%s://%s", utils.Getenv("CORS_PROTO"), addr)) + } + } + + wrappedWriter := &wrapper{ + ResponseWriter: writer, + request: request, + statusCode: http.StatusOK, + } + + writer.Header().Set("Access-Control-Allow-Methods", fmt.Sprintf("%s, OPTIONS", utils.Getenv("CORS_METHODS"))) + writer.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization") + next.ServeHTTP(wrappedWriter, request) + log.Info(fmt.Sprintf("%s %s %s %v \n", utils.ClientIP(request), request.Method, request.RequestURI, wrappedWriter.statusCode)) + }) +} + +func Auth(next http.HandlerFunc, w http.ResponseWriter, r *http.Request) { + cookie, err := r.Cookie("Session") + if err != nil { + if errors.Is(err, http.ErrNoCookie) { + http.SetCookie(w, &http.Cookie{ + Name: "redirect", + Value: r.RequestURI, + Path: "/", + }) + http.Redirect(w, r, "/signin", http.StatusSeeOther) + return + } + log.Error(err.Error()) + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + storeSession, err := session.Store.Get(cookie.Value) + if err != nil { + if errors.Is(err, &session.SessionNotFound{}) { + storeSession.Destroy(w) + http.Redirect(w, r, "/signin", http.StatusSeeOther) + return + } + log.Error(err.Error()) + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + userSession := GetUser(storeSession) + if userSession.Authenticated { + next.ServeHTTP(w, r) + return + } + http.SetCookie(w, &http.Cookie{ + Name: "redirect", + Value: r.RequestURI, + Path: "/", + }) + http.Redirect(w, r, "/signin", http.StatusSeeOther) + return +} + +func Guest(next http.HandlerFunc, w http.ResponseWriter, r *http.Request) { + cookie, err := r.Cookie("Session") + if err != nil { + if errors.Is(err, http.ErrNoCookie) { + next.ServeHTTP(w, r) + return + } + log.Error(err.Error()) + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + storeSession, err := session.Store.Get(cookie.Value) + if err != nil { + if errors.Is(err, &session.SessionNotFound{}) { + http.SetCookie(w, &http.Cookie{ + Name: "Session", + Value: "", + MaxAge: -1, + }) + next.ServeHTTP(w, r) + return + } else { + log.Error(err.Error()) + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + } + userSession := GetUser(storeSession) + if !userSession.Authenticated { + next.ServeHTTP(w, r) + return + } + http.Redirect(w, r, "/", http.StatusSeeOther) + return +} + +func GetUser(s *session.Session) types.User { + val := s.Values["user"] + var userSession = types.User{} + userSession, ok := val.(types.User) + if !ok { + return types.User{Authenticated: false} + } + return userSession +} diff --git a/public/favicon.ico b/public/favicon.ico new file mode 100644 index 0000000..9407a32 Binary files /dev/null and b/public/favicon.ico differ diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..06b673b --- /dev/null +++ b/public/index.html @@ -0,0 +1,222 @@ + + + + + + + Document + + + + + + + + + + + + + Document + + + + + + +
+
+
+
+

Forgot password

+

Enter your email below to reset your password

+
+
+
+
+ + +
+
+ + +
+
+
    +
  • +
    + + + + +
    + Passwords do not match +
  • +
  • +
    + + + + +
    + Password length must be at least 8 characters +
  • +
  • +
    + + + + +
    + The password must contain at least one symbol (!@#$%^&*), one uppercase letter, and three numbers +
  • +
+
+ +
+
+
+ + + + + + + + + + + \ No newline at end of file diff --git a/public/input.css b/public/input.css new file mode 100644 index 0000000..bd6213e --- /dev/null +++ b/public/input.css @@ -0,0 +1,3 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; \ No newline at end of file diff --git a/public/robots.txt b/public/robots.txt new file mode 100644 index 0000000..4f9540b --- /dev/null +++ b/public/robots.txt @@ -0,0 +1 @@ +User-agent: * \ No newline at end of file diff --git a/public/upload.js b/public/upload.js new file mode 100644 index 0000000..cdb831c --- /dev/null +++ b/public/upload.js @@ -0,0 +1,173 @@ +document.addEventListener("dragover", function (event) { + event.preventDefault(); +}); + +document.addEventListener("drop", async function (event) { + event.preventDefault(); + const file = event.dataTransfer.files[0] + await handleFile(file) +}); + +document.getElementById('dropzone-file').addEventListener('change', async function(event) { + event.preventDefault(); + const file = event.target.files[0] + await handleFile(file) +}); + +async function handleFile(file){ + const chunkSize = 2 * 1024 * 1024; + const chunks = Math.ceil(file.size / chunkSize); + const data = JSON.stringify({ + "name": file.name, + "size": file.size, + "chunk": chunks, + }); + + fetch('/upload/init', { + method: 'POST', + body: data, + }).then(async response => { + const responseData = await response.json() + console.log(responseData) + if (responseData.Done === false) { + addNewUploadElement(file) + const fileChunks = await splitFile(file, chunkSize); + await uploadChunks(file.name,file.size, fileChunks, responseData.Uploaded, responseData.FileID); + } else { + alert("file already uploaded") + } + + }).catch(error => { + console.error('Error uploading file:', error); + }); +} + +function addNewUploadElement(file){ + const newDiv = document.createElement('div'); + newDiv.innerHTML = ` +
+
+
+ + + + +
+

${ file.name }

+

${ convertFileSize(file.size) }

+
+
+ +
+
+
+ +
+
+ +
+ Starting... +
+
Uploading 0%
+
+ `; + document.getElementById('container').appendChild(newDiv); +} + +function convertFileSize(sizeInBytes) { + if (sizeInBytes < 1024) { + return sizeInBytes + ' B'; + } else if (sizeInBytes < 1024 * 1024) { + return (sizeInBytes / 1024).toFixed(2) + ' KB'; + } else if (sizeInBytes < 1024 * 1024 * 1024) { + return (sizeInBytes / (1024 * 1024)).toFixed(2) + ' MB'; + } else { + return (sizeInBytes / (1024 * 1024 * 1024)).toFixed(2) + ' GB'; + } +} + +async function splitFile(file, chunkSize) { + const fileSize = file.size; + const chunks = Math.ceil(fileSize / chunkSize); + const fileChunks = []; + + for (let i = 0; i < chunks; i++) { + const start = i * chunkSize; + const end = Math.min(fileSize, start + chunkSize); + const chunk = file.slice(start, end); + fileChunks.push(chunk); + } + + return fileChunks; +} + +async function uploadChunks(name, size, chunks, uploadedChunk= -1, FileID) { + let byteUploaded = 0 + var progress1 = document.getElementById(`progress-${name}-1`); + var progress2 = document.getElementById(`progress-${name}-2`); + var progress3 = document.getElementById(`progress-${name}-3`); + var progress4 = document.getElementById(`progress-${name}-4`); + for (let index = 0; index < chunks.length; index++) { + const percentComplete = Math.round((index + 1) / chunks.length * 100); + const chunk = chunks[index]; + if (!(index <= uploadedChunk)) { + const formData = new FormData(); + formData.append('name', name); + formData.append('chunk', chunk); + formData.append('index', index); + formData.append('done', false); + + progress1.setAttribute("aria-valuenow", percentComplete); + progress2.style.width = `${percentComplete}%`; + + const startTime = performance.now(); + await fetch(`/upload/${FileID}`, { + method: 'POST', + body: formData + }); + + const endTime = performance.now(); + const totalTime = (endTime - startTime) / 1000; + const uploadSpeed = chunk.size / totalTime / 1024 / 1024; + byteUploaded += chunk.size + progress3.innerText = `${uploadSpeed.toFixed(2)} MB/s`; + progress4.innerText = `Uploading ${percentComplete}% - ${convertFileSize(byteUploaded)} of ${ convertFileSize(size)}`; + } else { + progress1.setAttribute("aria-valuenow", percentComplete); + progress2.style.width = `${percentComplete}%`; + byteUploaded += chunk.size + } + } + + const formData = new FormData(); + formData.append('name', name); + formData.append('done', true); + return fetch(`/upload/${FileID}`, { + method: 'POST', + body: formData + }); +} + + diff --git a/public/validatePassword.js b/public/validatePassword.js new file mode 100644 index 0000000..4402f57 --- /dev/null +++ b/public/validatePassword.js @@ -0,0 +1,97 @@ +var isMatch = false +var isValid = false +var isSecure = false +function validatePasswords() { + var password = document.getElementById('password').value; + var confirmPassword = document.getElementById('confirmPassword').value; + var matchGoodPath = document.getElementById('matchGoodPath'); + var matchBadPath = document.getElementById('matchBadPath'); + var lengthGoodPath = document.getElementById('lengthGoodPath'); + var lengthBadPath = document.getElementById('lengthBadPath'); + var requirementsGoodPath = document.getElementById('requirementsGoodPath'); + var requirementsBadPath = document.getElementById('requirementsBadPath'); + var matchSvgContainer = document.getElementById('matchSvgContainer'); + var lengthSvgContainer = document.getElementById('lengthSvgContainer'); + var requirementsSvgContainer = document.getElementById('requirementsSvgContainer'); + var matchStatusText = document.getElementById('matchStatusText'); + var lengthStatusText = document.getElementById('lengthStatusText'); + var requirementsStatusText = document.getElementById('requirementsStatusText'); + var symbolRegex = /[!@#$%^&*]/; + var uppercaseRegex = /[A-Z]/; + var numberRegex = /\d/g; + + + if (password === confirmPassword && password.length > 0 && confirmPassword.length > 0 && password.length === confirmPassword.length) { + matchSvgContainer.classList.remove('bg-red-200'); + matchSvgContainer.classList.add('bg-green-200'); + matchStatusText.classList.remove('text-red-700'); + matchStatusText.classList.add('text-green-700'); + matchGoodPath.style.display = 'inline'; + matchBadPath.style.display = 'none'; + matchStatusText.textContent = "Passwords match"; + console.log("anjay") + isMatch = true + } else { + matchSvgContainer.classList.remove('bg-green-200'); + matchSvgContainer.classList.add('bg-red-200'); + matchStatusText.classList.remove('text-green-700'); + matchStatusText.classList.add('text-red-700'); + matchGoodPath.style.display = 'none'; + matchBadPath.style.display = 'inline'; + matchStatusText.textContent = "Passwords do not match"; + isMatch = false + } + + if (password.length >= 8) { + lengthSvgContainer.classList.remove('bg-red-200'); + lengthSvgContainer.classList.add('bg-green-200'); + lengthStatusText.classList.remove('text-red-700'); + lengthStatusText.classList.add('text-green-700'); + lengthGoodPath.style.display = 'inline'; + lengthBadPath.style.display = 'none'; + lengthStatusText.textContent = "Password length meets requirement"; + isValid = true + } else { + lengthSvgContainer.classList.remove('bg-green-200'); + lengthSvgContainer.classList.add('bg-red-200'); + lengthStatusText.classList.remove('text-green-700'); + lengthStatusText.classList.add('text-red-700'); + lengthGoodPath.style.display = 'none'; + lengthBadPath.style.display = 'inline'; + lengthStatusText.textContent = "Password length must be at least 8 characters"; + isValid = false + } + + var symbolCheck = symbolRegex.test(password); + var uppercaseCheck = uppercaseRegex.test(password); + var numberCount = (password.match(numberRegex) || []).length; + + if (symbolCheck && uppercaseCheck && numberCount >= 3) { + requirementsSvgContainer.classList.remove('bg-red-200'); + requirementsSvgContainer.classList.add('bg-green-200'); + requirementsStatusText.classList.remove('text-red-700'); + requirementsStatusText.classList.add('text-green-700'); + requirementsGoodPath.style.display = 'inline'; + requirementsBadPath.style.display = 'none'; + requirementsStatusText.textContent = "Password meets additional requirements"; + isSecure = true + } else { + requirementsSvgContainer.classList.remove('bg-green-200'); + requirementsSvgContainer.classList.add('bg-red-200'); + requirementsStatusText.classList.remove('text-green-700'); + requirementsStatusText.classList.add('text-red-700'); + requirementsGoodPath.style.display = 'none'; + requirementsBadPath.style.display = 'inline'; + requirementsStatusText.textContent = "The password must contain at least one symbol (!@#$%^&*), one uppercase letter, and three numbers"; + isSecure = false + } + + if (isSecure && isValid && isSecure && password === confirmPassword) { + document.getElementById("submit").disabled = false; + } else { + document.getElementById("submit").disabled = true; + } +} + +document.getElementById('password').addEventListener('input', validatePasswords); +document.getElementById('confirmPassword').addEventListener('input', validatePasswords); \ No newline at end of file diff --git a/routes/routes.go b/routes/routes.go new file mode 100644 index 0000000..4c5646c --- /dev/null +++ b/routes/routes.go @@ -0,0 +1,166 @@ +package routes + +import ( + downloadHandler "github.com/fossyy/filekeeper/handler/download" + downloadFileHandler "github.com/fossyy/filekeeper/handler/download/file" + forgotPasswordHandler "github.com/fossyy/filekeeper/handler/forgotPassword" + forgotPasswordVerifyHandler "github.com/fossyy/filekeeper/handler/forgotPassword/verify" + indexHandler "github.com/fossyy/filekeeper/handler/index" + logoutHandler "github.com/fossyy/filekeeper/handler/logout" + miscHandler "github.com/fossyy/filekeeper/handler/misc" + signinHandler "github.com/fossyy/filekeeper/handler/signin" + signupHandler "github.com/fossyy/filekeeper/handler/signup" + signupVerifyHandler "github.com/fossyy/filekeeper/handler/signup/verify" + uploadHandler "github.com/fossyy/filekeeper/handler/upload" + "github.com/fossyy/filekeeper/handler/upload/initialisation" + userHandler "github.com/fossyy/filekeeper/handler/user" + "github.com/fossyy/filekeeper/middleware" + "net/http" +) + +func SetupRoutes() *http.ServeMux { + handler := http.NewServeMux() + + handler.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + switch r.RequestURI { + case "/": + switch r.Method { + case http.MethodGet: + indexHandler.GET(w, r) + default: + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + } + default: + w.WriteHeader(http.StatusNotFound) + } + }) + + handler.HandleFunc("/signin", func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + middleware.Guest(signinHandler.GET, w, r) + case http.MethodPost: + middleware.Guest(signinHandler.POST, w, r) + } + }) + + signupRouter := http.NewServeMux() + handler.Handle("/signup/", http.StripPrefix("/signup", signupRouter)) + + signupRouter.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + middleware.Guest(signupHandler.GET, w, r) + case http.MethodPost: + middleware.Guest(signupHandler.POST, w, r) + default: + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + } + }) + + signupRouter.HandleFunc("/verify/{code}", func(w http.ResponseWriter, r *http.Request) { + middleware.Guest(signupVerifyHandler.GET, w, r) + }) + + forgotPasswordRouter := http.NewServeMux() + handler.Handle("/forgot-password/", http.StripPrefix("/forgot-password", forgotPasswordRouter)) + forgotPasswordRouter.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + middleware.Guest(forgotPasswordHandler.GET, w, r) + case http.MethodPost: + middleware.Guest(forgotPasswordHandler.POST, w, r) + default: + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + } + }) + + forgotPasswordRouter.HandleFunc("/verify/{code}", func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + middleware.Guest(forgotPasswordVerifyHandler.GET, w, r) + case http.MethodPost: + middleware.Guest(forgotPasswordVerifyHandler.POST, w, r) + default: + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + } + }) + + handler.HandleFunc("/user", func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + middleware.Auth(userHandler.GET, w, r) + default: + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + } + }) + + // Upload router + uploadRouter := http.NewServeMux() + handler.Handle("/upload/", http.StripPrefix("/upload", uploadRouter)) + + uploadRouter.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + middleware.Auth(uploadHandler.GET, w, r) + default: + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + } + }) + + uploadRouter.HandleFunc("/{id}", func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodPost: + middleware.Auth(uploadHandler.POST, w, r) + default: + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + } + }) + + uploadRouter.HandleFunc("/init", func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodPost: + middleware.Auth(initialisation.POST, w, r) + default: + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + } + }) + + // Download router + downloadRouter := http.NewServeMux() + handler.Handle("/download/", http.StripPrefix("/download", downloadRouter)) + downloadRouter.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + middleware.Auth(downloadHandler.GET, w, r) + default: + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + } + }) + + downloadRouter.HandleFunc("/{id}", func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + downloadFileHandler.GET(w, r) + default: + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + } + }) + + handler.HandleFunc("/logout", func(w http.ResponseWriter, r *http.Request) { + middleware.Auth(logoutHandler.GET, w, r) + }) + + handler.HandleFunc("/robots.txt", func(w http.ResponseWriter, r *http.Request) { + miscHandler.Robot(w, r) + }) + + handler.HandleFunc("/favicon.ico", func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, "/public/favicon.ico", http.StatusSeeOther) + }) + + fileServer := http.FileServer(http.Dir("./public")) + handler.Handle("/public/", http.StripPrefix("/public", fileServer)) + + return handler +} diff --git a/schema.sql b/schema.sql new file mode 100644 index 0000000..189cd15 --- /dev/null +++ b/schema.sql @@ -0,0 +1,3 @@ +CREATE TABLE IF NOT EXISTS users (user_id VARCHAR(255) PRIMARY KEY NOT NULL,username VARCHAR(255) UNIQUE NOT NULL,email VARCHAR(255) UNIQUE NOT NULL,password TEXT NOT NULL); +CREATE TABLE IF NOT EXISTS files (id VARCHAR(255) PRIMARY KEY NOT NULL,owner_id VARCHAR(255) NOT NULL,name TEXT NOT NULL,size BIGINT NOT NULL,downloaded BIGINT NOT NULL,FOREIGN KEY (owner_id) REFERENCES users(user_id)); +CREATE TABLE IF NOT EXISTS files_uploadeds (upload_id VARCHAR(255) PRIMARY KEY NOT NULL,file_id VARCHAR(255) NOT NULL,owner_id VARCHAR(255) NOT NULL,name TEXT NOT NULL,size INT NOT NULL,uploaded INT NOT NULL DEFAULT 0,done BOOLEAN NOT NULL DEFAULT FALSE,FOREIGN KEY (file_id) REFERENCES files(id),FOREIGN KEY (owner_id) REFERENCES users(user_id)); \ No newline at end of file diff --git a/session/session.go b/session/session.go new file mode 100644 index 0000000..67f1fac --- /dev/null +++ b/session/session.go @@ -0,0 +1,100 @@ +package session + +import ( + "github.com/fossyy/filekeeper/utils" + "net/http" + "strconv" + "sync" +) + +type Session struct { + ID string + Values map[string]interface{} +} + +type StoreSession struct { + Sessions map[string]*Session + mu sync.Mutex +} + +var Store = StoreSession{Sessions: make(map[string]*Session)} +var userSessions = make(map[string][]string) + +type SessionNotFound struct{} + +func (e *SessionNotFound) Error() string { + return "session not found" +} + +func (s *StoreSession) Get(id string) (*Session, error) { + s.mu.Lock() + defer s.mu.Unlock() + if session, ok := s.Sessions[id]; ok { + return session, nil + } + return nil, &SessionNotFound{} +} + +func (s *StoreSession) Create() *Session { + id := utils.GenerateRandomString(128) + session := &Session{ + ID: id, + Values: make(map[string]interface{}), + } + s.Sessions[id] = session + return session +} + +func (s *StoreSession) Delete(id string) { + s.mu.Lock() + defer s.mu.Unlock() + delete(s.Sessions, id) +} + +func (s *Session) Save(w http.ResponseWriter) { + maxAge, _ := strconv.Atoi(utils.Getenv("SESSION_MAX_AGE")) + http.SetCookie(w, &http.Cookie{ + Name: utils.Getenv("SESSION_NAME"), + Value: s.ID, + MaxAge: maxAge, + }) +} + +func (s *Session) Destroy(w http.ResponseWriter) { + http.SetCookie(w, &http.Cookie{ + Name: utils.Getenv("SESSION_NAME"), + Value: "", + MaxAge: -1, + }) +} + +func AppendSession(email string, session *Session) { + userSessions[email] = append(userSessions[email], session.ID) +} + +func RemoveSession(email string, id string) { + sessions := userSessions[email] + var updatedSessions []string + for _, userSession := range sessions { + if userSession != id { + updatedSessions = append(updatedSessions, userSession) + } + } + if len(updatedSessions) > 0 { + userSessions[email] = updatedSessions + return + } + delete(userSessions, email) +} + +func RemoveAllSession(email string) { + sessions := userSessions[email] + for _, session := range sessions { + delete(Store.Sessions, session) + } + delete(userSessions, email) +} + +func Getses() map[string][]string { + return userSessions +} diff --git a/staging.bat b/staging.bat new file mode 100644 index 0000000..cdbcc43 --- /dev/null +++ b/staging.bat @@ -0,0 +1,10 @@ +@echo off + +REM Start the Go server using Air +start "" air + +REM Watch for changes in Tailwind CSS +start "" npx tailwindcss -i ./public/input.css -o ./public/output.css --watch + +REM Watch for changes in templates and proxy to Go server +start "" cmd /k "templ generate -watch -proxy=http://localhost:8000" \ No newline at end of file diff --git a/staging.sh b/staging.sh new file mode 100644 index 0000000..327f097 --- /dev/null +++ b/staging.sh @@ -0,0 +1,5 @@ +#!/bin/bash + +air & +npx tailwindcss -i ./public/input.css -o ./public/output.css --watch & +templ generate -watch -proxy=http://localhost:8000 diff --git a/tailwind.config.js b/tailwind.config.js new file mode 100644 index 0000000..9a46c36 --- /dev/null +++ b/tailwind.config.js @@ -0,0 +1,9 @@ +/** @type {import('tailwindcss').Config} */ +module.exports = { + content: ["./view/**/*.{html,js,templ}"], + theme: { + extend: {}, + }, + plugins: [], +} + diff --git a/types/models/models.go b/types/models/models.go new file mode 100644 index 0000000..2ac157c --- /dev/null +++ b/types/models/models.go @@ -0,0 +1,28 @@ +package models + +import "github.com/google/uuid" + +type User struct { + UserID uuid.UUID `gorm:"primaryKey;not null;unique"` + Username string `gorm:"unique;not null"` + Email string `gorm:"unique;not null"` + Password string `gorm:"not null"` +} + +type File struct { + ID uuid.UUID `gorm:"primaryKey;not null;unique"` + OwnerID uuid.UUID `gorm:"not null"` + Name string `gorm:"not null"` + Size int `gorm:"not null"` + Downloaded int `gorm:"not null;default=0"` +} + +type FilesUploaded struct { + UploadID uuid.UUID `gorm:"primaryKey;not null;unique"` + FileID uuid.UUID `gorm:"not null"` + OwnerID uuid.UUID `gorm:"not null"` + Name string `gorm:"not null"` + Size int `gorm:"not null"` + Uploaded int `gorm:"not null;default=0"` + Done bool `gorm:"not null;default=false"` +} diff --git a/types/types.go b/types/types.go new file mode 100644 index 0000000..e7a2683 --- /dev/null +++ b/types/types.go @@ -0,0 +1,46 @@ +package types + +import ( + "github.com/google/uuid" + "time" +) + +type Message struct { + Code int + Message string +} + +type User struct { + UserID uuid.UUID + Email string + Username string + Authenticated bool +} + +type UserWithExpired struct { + UserID uuid.UUID + Username string + Email string + Password string + AccessAt time.Time +} + +type FileInfo struct { + Name string `json:"name"` + Size int `json:"size"` + Chunk int `json:"chunk"` +} + +type FileInfoUploaded struct { + Name string `json:"name"` + Size int `json:"size"` + Chunk int `json:"chunk"` + UploadedChunk int `json:"uploaded_chunk"` +} + +type FileData struct { + ID string + Name string + Size string + Downloaded int +} diff --git a/utils/utils.go b/utils/utils.go new file mode 100644 index 0000000..dd2ad9d --- /dev/null +++ b/utils/utils.go @@ -0,0 +1,142 @@ +package utils + +import ( + "fmt" + "github.com/fossyy/filekeeper/logger" + "github.com/joho/godotenv" + "golang.org/x/crypto/bcrypt" + "math/rand" + "net/http" + "os" + "strings" + "sync" + "time" + "unicode" +) + +type Env struct { + value map[string]string + mu sync.Mutex +} + +var env *Env +var log *logger.AggregatedLogger + +func init() { + env = &Env{value: map[string]string{}} +} + +func ClientIP(request *http.Request) string { + ip := request.Header.Get("X-Real-IP") + if ip == "" { + ip = request.Header.Get("X-Forwarded-For") + if ip == "" { + ip = request.RemoteAddr + } + } + + if strings.Contains(ip, ",") { + ips := strings.Split(ip, ",") + ip = strings.TrimSpace(ips[0]) + } + + if strings.Contains(ip, ":") { + ips := strings.Split(ip, ":") + ip = ips[0] + } + + return ip +} + +func HashPassword(password string) (string, error) { + bytes, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + return string(bytes), err +} + +func CheckPasswordHash(password, hash string) bool { + err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) + return err == nil +} + +func ValidatePassword(password string) bool { + if len(password) < 6 { + return false + } + + var ( + hasSymbol bool + hasNumber int + hasUppercase bool + ) + + symbols := []string{"!", "@", "#", "$", "%", "^", "&", "*"} + + for _, symbol := range symbols { + if strings.Contains(password, symbol) { + hasSymbol = true + } + } + + for _, char := range password { + switch { + case unicode.IsNumber(char): + hasNumber++ + case unicode.IsUpper(char): + hasUppercase = true + } + } + + return hasSymbol && hasNumber >= 3 && hasUppercase +} + +func ConvertFileSize(byte int) string { + if byte < 1024 { + return fmt.Sprintf("%d B", byte) + } else if byte < 1024*1024 { + return fmt.Sprintf("%d KB", byte/1024) + } else if byte < 1024*1024*1024 { + return fmt.Sprintf("%d MB", byte/(1024*1024)) + } else { + return fmt.Sprintf("%d GB", byte/(1024*1024*1024)) + } +} + +func Getenv(key string) string { + env.mu.Lock() + defer env.mu.Unlock() + + if val, ok := env.value[key]; ok { + return val + } + + err := godotenv.Load(".env") + if err != nil { + log.Error("Error loading .env file: %s", err) + } + + val := os.Getenv(key) + env.value[key] = val + + return val +} + +func GenerateRandomString(length int) string { + const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" + seededRand := rand.New(rand.NewSource(time.Now().UnixNano())) + var result strings.Builder + for i := 0; i < length; i++ { + randomIndex := seededRand.Intn(len(charset)) + result.WriteString(string(charset[randomIndex])) + } + return result.String() +} + +func SanitizeFilename(filename string) string { + invalidChars := []string{"\\", "/", ":", "*", "?", "\"", "<", ">", "|"} + + for _, char := range invalidChars { + filename = strings.ReplaceAll(filename, char, "_") + } + + return filename +} diff --git a/view/download/download.templ b/view/download/download.templ new file mode 100644 index 0000000..6d74992 --- /dev/null +++ b/view/download/download.templ @@ -0,0 +1,76 @@ +package downloadView + +import ( + "github.com/fossyy/filekeeper/view/layout" + "github.com/fossyy/filekeeper/types" +) + +templ component(title string, files []types.FileData){ + @layout.Base(title){ +
+
+
+
+

Download Files

+
+
+
+ for _, file := range files { +
+
+
+

{ file.Name }

+

{ file.Size }

+
+
+ + + + + + + + +
+
+
+ } + +
+
+
+ } +} + +templ Main(title string, files []types.FileData){ + @component(title, files) +} \ No newline at end of file diff --git a/view/email/email.templ b/view/email/email.templ new file mode 100644 index 0000000..6ebd43d --- /dev/null +++ b/view/email/email.templ @@ -0,0 +1,111 @@ +package emailView + +templ RegistrationEmail(name string, link string) { + + + + + + + Email Verification + + + +
+

Email Verification

+

Dear {name},

+

Please verify your email address by clicking the button below:

+ Verify Email +

Or copy and paste this URL into a new tab of your browser:
{link}

+

If you did not request this verification, please disregard this email.

+

Thank you,
The Filekeeper Team

+
+ + +} + +templ ForgotPassword(name string, link string) { + + + + + + + Email Verification + + + +
+

Password Change Request

+

Dear {name},

+

Please verify your password change request by clicking the button below:

+ Verify Password Change +

Or copy and paste this URL into a new tab of your browser:
{link}

+

If you did not request this password change, please disregard this email.

+

Thank you,
The Filekeeper Team

+
+ + +} \ No newline at end of file diff --git a/view/error/error.templ b/view/error/error.templ new file mode 100644 index 0000000..e2b39d8 --- /dev/null +++ b/view/error/error.templ @@ -0,0 +1,39 @@ +package errorView + +import "github.com/fossyy/filekeeper/view/layout" + +templ content(title string){ + @layout.Base(title){ +
+
+

404 Not Found

+

+ The page you are looking for does not exist. It might have been moved or deleted. +

+
+ + + + + +
+ } +} + +templ Main(title string) { + @content(title) +} \ No newline at end of file diff --git a/view/forgotPassword/forgotPassword.templ b/view/forgotPassword/forgotPassword.templ new file mode 100644 index 0000000..af3bfcf --- /dev/null +++ b/view/forgotPassword/forgotPassword.templ @@ -0,0 +1,169 @@ +package forgotPasswordView + +import ( + "github.com/fossyy/filekeeper/types" + "github.com/fossyy/filekeeper/view/layout" +) + +templ content(title string, err types.Message) { + @layout.Base(title){ +
+
+
+
+

Forgot password

+

Enter your email below to reset your password

+ switch err.Code { + case 0: + + } +
+
+
+
+ + +
+ +
+
+
+ } +} + +templ Main(title string, err types.Message) { + @content(title, err) +} + +templ NewPasswordForm(title string, err types.Message) { + @layout.Base(title){ +
+
+
+
+

Forgot password

+

Enter your email below to reset your password

+ switch err.Code { + case 0: + + } +
+
+
+
+ + +
+
+ + +
+
+
    +
  • +
    + + + + +
    + Passwords do not match +
  • +
  • +
    + + + + +
    + Password length must be at least 8 characters +
  • +
  • +
    + + + + +
    + The password must contain at least one symbol (!@#$%^&*), one uppercase letter, and three numbers +
  • +
+
+ +
+
+
+