diff --git a/.env.example b/.env.example index 76041aef..732da9d9 100644 --- a/.env.example +++ b/.env.example @@ -13,7 +13,6 @@ ENV_HTTP_HOST=localhost ENV_HTTP_PORT=8080 # --- App super admin credentials -ENV_APP_TOKEN_USERNAME="" ENV_APP_TOKEN_PUBLIC="" ENV_APP_TOKEN_PRIVATE="" diff --git a/boost/app.go b/boost/app.go index 168e1036..257bec6e 100644 --- a/boost/app.go +++ b/boost/app.go @@ -3,43 +3,53 @@ package boost import ( "github.com/oullin/database" "github.com/oullin/env" - "github.com/oullin/handler/user" "github.com/oullin/pkg" + "github.com/oullin/pkg/http/middleware" "github.com/oullin/pkg/llogs" - "github.com/oullin/pkg/middleware" - "net/http" + baseHttp "net/http" ) type App struct { - Validator *pkg.Validator `validate:"required"` - Logs *llogs.Driver `validate:"required"` - DbConnection *database.Connection `validate:"required"` - AdminUser *user.AdminUser `validate:"required"` - Env *env.Environment `validate:"required"` - Mux *http.ServeMux `validate:"required"` - Sentry *pkg.Sentry `validate:"required"` + router *Router + sentry *pkg.Sentry + logs *llogs.Driver + validator *pkg.Validator + env *env.Environment + db *database.Connection } -func MakeApp(mux *http.ServeMux, app *App) *App { - app.Mux = mux +func MakeApp(env *env.Environment, validator *pkg.Validator) *App { + app := App{ + env: env, + validator: validator, + logs: MakeLogs(env), + sentry: MakeSentry(env), + db: MakeDbConnection(env), + } - return app -} + router := Router{ + Env: env, + Mux: baseHttp.NewServeMux(), + Pipeline: middleware.Pipeline{ + Env: env, + }, + } -func (app App) RegisterUsers() { - stack := middleware.MakeMiddlewareStack(app.Env, func(seed string) bool { - return app.AdminUser.IsAllowed(seed) - }) + app.SetRouter(router) - handler := user.RequestHandler{ - Repository: user.MakeRepository(app.DbConnection, app.AdminUser), - Validator: app.Validator, + return &app +} + +func (a *App) Boot() { + if a.router == nil { + panic("Router is not set") } - app.Mux.HandleFunc("POST /users", pkg.CreateHandle( - stack.Push( - handler.Create, - stack.AdminUser, - ), - )) + router := *a.router + + router.Profile() + router.Experience() + router.Projects() + router.Social() + router.Talks() } diff --git a/boost/boost.go b/boost/factory.go similarity index 91% rename from boost/boost.go rename to boost/factory.go index 7a57a29a..ea7f2f5e 100644 --- a/boost/boost.go +++ b/boost/factory.go @@ -50,7 +50,7 @@ func MakeLogs(env *env.Environment) *llogs.Driver { lDriver, err := llogs.MakeFilesLogs(env) if err != nil { - panic("Logs: error opening logs file: " + err.Error()) + panic("logs: error opening logs file: " + err.Error()) } return &lDriver @@ -62,9 +62,8 @@ func MakeEnv(values map[string]string, validate *pkg.Validator) *env.Environment port, _ := strconv.Atoi(values["ENV_DB_PORT"]) token := auth.Token{ - Username: strings.TrimSpace(values["ENV_APP_TOKEN_USERNAME"]), - Public: strings.TrimSpace(values["ENV_APP_TOKEN_PUBLIC"]), - Private: strings.TrimSpace(values["ENV_APP_TOKEN_PRIVATE"]), + Public: strings.TrimSpace(values["ENV_APP_TOKEN_PUBLIC"]), + Private: strings.TrimSpace(values["ENV_APP_TOKEN_PRIVATE"]), } app := env.AppEnvironment{ @@ -115,7 +114,7 @@ func MakeEnv(values map[string]string, validate *pkg.Validator) *env.Environment } if _, err := validate.Rejects(logsCreds); err != nil { - panic(errorSufix + "invalid [Logs Creds] model: " + validate.GetErrorsAsJason()) + panic(errorSufix + "invalid [logs Creds] model: " + validate.GetErrorsAsJason()) } if _, err := validate.Rejects(net); err != nil { diff --git a/boost/helpers.go b/boost/helpers.go new file mode 100644 index 00000000..1c56e36d --- /dev/null +++ b/boost/helpers.go @@ -0,0 +1,45 @@ +package boost + +import ( + "github.com/oullin/database" + "github.com/oullin/env" + baseHttp "net/http" +) + +func (a *App) SetRouter(router Router) { + a.router = &router +} + +func (a *App) CloseLogs() { + if a.logs == nil { + return + } + + driver := *a.logs + driver.Close() +} + +func (a *App) CloseDB() { + if a.db == nil { + return + } + + driver := *a.db + driver.Close() +} + +func (a *App) GetEnv() *env.Environment { + return a.env +} + +func (a *App) GetDB() *database.Connection { + return a.db +} + +func (a *App) GetMux() *baseHttp.ServeMux { + if a.router == nil { + return nil + } + + return a.router.Mux +} diff --git a/boost/spark.go b/boost/ignite.go similarity index 82% rename from boost/spark.go rename to boost/ignite.go index 4f87dce7..03d73e0e 100644 --- a/boost/spark.go +++ b/boost/ignite.go @@ -6,7 +6,7 @@ import ( "github.com/oullin/pkg" ) -func Spark(envPath string) (*env.Environment, *pkg.Validator) { +func Ignite(envPath string) (*env.Environment, *pkg.Validator) { validate := pkg.GetDefaultValidator() envMap, err := godotenv.Read(envPath) diff --git a/boost/router.go b/boost/router.go new file mode 100644 index 00000000..b280b9d4 --- /dev/null +++ b/boost/router.go @@ -0,0 +1,79 @@ +package boost + +import ( + "github.com/oullin/env" + "github.com/oullin/handler" + "github.com/oullin/pkg/http" + "github.com/oullin/pkg/http/middleware" + baseHttp "net/http" +) + +type Router struct { + Env *env.Environment + Mux *baseHttp.ServeMux + Pipeline middleware.Pipeline +} + +func (r *Router) PipelineFor(apiHandler http.ApiHandler) baseHttp.HandlerFunc { + tokenMiddleware := middleware.MakeTokenMiddleware( + r.Env.App.Credentials, + ) + + return http.MakeApiHandler( + r.Pipeline.Chain( + apiHandler, + middleware.UsernameCheck, + tokenMiddleware.Handle, + ), + ) +} + +func (r *Router) Profile() { + abstract := handler.MakeProfileHandler() + + resolver := r.PipelineFor( + abstract.Handle, + ) + + r.Mux.HandleFunc("GET /profile", resolver) +} + +func (r *Router) Experience() { + abstract := handler.MakeExperienceHandler() + + resolver := r.PipelineFor( + abstract.Handle, + ) + + r.Mux.HandleFunc("GET /experience", resolver) +} + +func (r *Router) Projects() { + abstract := handler.MakeProjectsHandler() + + resolver := r.PipelineFor( + abstract.Handle, + ) + + r.Mux.HandleFunc("GET /projects", resolver) +} + +func (r *Router) Social() { + abstract := handler.MakeSocialHandler() + + resolver := r.PipelineFor( + abstract.Handle, + ) + + r.Mux.HandleFunc("GET /social", resolver) +} + +func (r *Router) Talks() { + abstract := handler.MakeTalks() + + resolver := r.PipelineFor( + abstract.Handle, + ) + + r.Mux.HandleFunc("GET /talks", resolver) +} diff --git a/cli/main.go b/cli/main.go index 34a5de51..79996bee 100644 --- a/cli/main.go +++ b/cli/main.go @@ -16,7 +16,7 @@ var guard gate.Guard var environment *env.Environment func init() { - secrets, _ := boost.Spark("./../.env") + secrets, _ := boost.Ignite("./../.env") environment = secrets guard = gate.MakeGuard(environment.App.Credentials) diff --git a/database/seeder/main.go b/database/seeder/main.go index 40a5df84..a9669531 100644 --- a/database/seeder/main.go +++ b/database/seeder/main.go @@ -13,7 +13,7 @@ import ( var environment *env.Environment func init() { - secrets, _ := boost.Spark("./.env") + secrets, _ := boost.Ignite("./.env") environment = secrets } @@ -30,11 +30,11 @@ func main() { // [1] --- Create the Seeder Runner. seeder := seeds.MakeSeeder(dbConnection, environment) - // [2] --- Truncate the DB. + // [2] --- Truncate the db. if err := seeder.TruncateDB(); err != nil { panic(err) } else { - cli.Successln("DB Truncated successfully ...") + cli.Successln("db Truncated successfully ...") time.Sleep(2 * time.Second) } @@ -114,5 +114,5 @@ func main() { wg.Wait() - cli.Magentaln("DB seeded as expected ....") + cli.Magentaln("db seeded as expected ....") } diff --git a/database/seeder/seeds/factory.go b/database/seeder/seeds/factory.go index c1ebcee4..97acb9cd 100644 --- a/database/seeder/seeds/factory.go +++ b/database/seeder/seeds/factory.go @@ -23,7 +23,7 @@ func MakeSeeder(dbConnection *database.Connection, environment *env.Environment) func (s *Seeder) TruncateDB() error { if s.environment.App.IsProduction() { - return fmt.Errorf("cannot truncate DB at the seeder level") + return fmt.Errorf("cannot truncate db at the seeder level") } truncate := database.MakeTruncate(s.dbConn, s.environment) diff --git a/env/app.go b/env/app.go index b3d874b2..d296a84a 100644 --- a/env/app.go +++ b/env/app.go @@ -5,7 +5,6 @@ import "github.com/oullin/pkg/auth" const local = "local" const staging = "staging" const production = "production" -const ApiKeyHeader = "X-API-Key" type AppEnvironment struct { Name string `validate:"required,min=4"` diff --git a/handler/experience.go b/handler/experience.go new file mode 100644 index 00000000..028f893d --- /dev/null +++ b/handler/experience.go @@ -0,0 +1,34 @@ +package handler + +import ( + "github.com/oullin/pkg/http" + "log/slog" + baseHttp "net/http" + "os" +) + +type ExperienceHandler struct { + content string +} + +func MakeExperienceHandler() ExperienceHandler { + return ExperienceHandler{ + content: "./storage/fixture/experience.json", + } +} + +func (h ExperienceHandler) Handle(w baseHttp.ResponseWriter, r *baseHttp.Request) *http.ApiError { + fixture, err := os.ReadFile(h.content) + + if err != nil { + slog.Error("Error reading projects file", "error", err) + + return http.InternalError("could not read experience data") + } + + if err := writeJSON(fixture, w); err != nil { + return http.InternalError(err.Error()) + } + + return nil // A nil return indicates success. +} diff --git a/handler/profile.go b/handler/profile.go new file mode 100644 index 00000000..c7613c39 --- /dev/null +++ b/handler/profile.go @@ -0,0 +1,34 @@ +package handler + +import ( + "github.com/oullin/pkg/http" + "log/slog" + baseHttp "net/http" + "os" +) + +type ProfileHandler struct { + content string +} + +func MakeProfileHandler() ProfileHandler { + return ProfileHandler{ + content: "./storage/fixture/profile.json", + } +} + +func (h ProfileHandler) Handle(w baseHttp.ResponseWriter, r *baseHttp.Request) *http.ApiError { + fixture, err := os.ReadFile(h.content) + + if err != nil { + slog.Error("Error reading projects file", "error", err) + + return http.InternalError("could not read profile data") + } + + if err := writeJSON(fixture, w); err != nil { + return http.InternalError(err.Error()) + } + + return nil // A nil return indicates success. +} diff --git a/handler/projects.go b/handler/projects.go new file mode 100644 index 00000000..2268d86c --- /dev/null +++ b/handler/projects.go @@ -0,0 +1,34 @@ +package handler + +import ( + "github.com/oullin/pkg/http" + "log/slog" + baseHttp "net/http" + "os" +) + +type ProjectsHandler struct { + content string +} + +func MakeProjectsHandler() ProjectsHandler { + return ProjectsHandler{ + content: "./storage/fixture/projects.json", + } +} + +func (h ProjectsHandler) Handle(w baseHttp.ResponseWriter, r *baseHttp.Request) *http.ApiError { + fixture, err := os.ReadFile(h.content) + + if err != nil { + slog.Error("Error reading projects file", "error", err) + + return http.InternalError("could not read projects data") + } + + if err := writeJSON(fixture, w); err != nil { + return http.InternalError(err.Error()) + } + + return nil // A nil return indicates success. +} diff --git a/handler/response.go b/handler/response.go new file mode 100644 index 00000000..55f7860e --- /dev/null +++ b/handler/response.go @@ -0,0 +1,17 @@ +package handler + +import ( + "fmt" + baseHttp "net/http" +) + +func writeJSON(content []byte, w baseHttp.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(baseHttp.StatusOK) + + if _, err := w.Write(content); err != nil { + return fmt.Errorf("error writing response: %v", err) + } + + return nil +} diff --git a/handler/social.go b/handler/social.go new file mode 100644 index 00000000..89131176 --- /dev/null +++ b/handler/social.go @@ -0,0 +1,34 @@ +package handler + +import ( + "github.com/oullin/pkg/http" + "log/slog" + baseHttp "net/http" + "os" +) + +type SocialHandler struct { + content string +} + +func MakeSocialHandler() SocialHandler { + return SocialHandler{ + content: "./storage/fixture/social.json", + } +} + +func (h SocialHandler) Handle(w baseHttp.ResponseWriter, r *baseHttp.Request) *http.ApiError { + fixture, err := os.ReadFile(h.content) + + if err != nil { + slog.Error("Error reading projects file", "error", err) + + return http.InternalError("could not read social data") + } + + if err := writeJSON(fixture, w); err != nil { + return http.InternalError(err.Error()) + } + + return nil // A nil return indicates success. +} diff --git a/handler/talks.go b/handler/talks.go new file mode 100644 index 00000000..b71dd0b6 --- /dev/null +++ b/handler/talks.go @@ -0,0 +1,34 @@ +package handler + +import ( + "github.com/oullin/pkg/http" + "log/slog" + baseHttp "net/http" + "os" +) + +type Talks struct { + content string +} + +func MakeTalks() Talks { + return Talks{ + content: "./storage/fixture/talks.json", + } +} + +func (h Talks) Handle(w baseHttp.ResponseWriter, r *baseHttp.Request) *http.ApiError { + fixture, err := os.ReadFile(h.content) + + if err != nil { + slog.Error("Error reading projects file", "error", err) + + return http.InternalError("could not read talks data") + } + + if err := writeJSON(fixture, w); err != nil { + return http.InternalError(err.Error()) + } + + return nil // A nil return indicates success. +} diff --git a/handler/user/admin.go b/handler/user/admin.go deleted file mode 100644 index 3e76f8b1..00000000 --- a/handler/user/admin.go +++ /dev/null @@ -1,39 +0,0 @@ -package user - -import ( - "crypto/sha256" - "encoding/hex" - "strings" -) - -const adminUserName = "gocanto" - -type AdminUser struct { - PublicToken string `validate:"required,min=10"` - PrivateToken string `validate:"required,min=10"` -} - -func (ga AdminUser) IsAllowed(seed string) bool { - token := strings.Trim(ga.PublicToken, " ") - salt := strings.Trim(ga.PrivateToken, " ") - externalSalt := strings.Trim(seed, " ") - - if salt != externalSalt { - return false - } - - hash := sha256.New() - hash.Write([]byte(externalSalt)) - bytes := hash.Sum(hash.Sum(nil)) - - encodeToString := strings.Trim( - hex.EncodeToString(bytes), - " ", - ) - - return token == encodeToString -} - -func (ga AdminUser) IsNotAllowed(seed string) bool { - return !ga.IsAllowed(seed) -} diff --git a/handler/user/create.go b/handler/user/create.go deleted file mode 100644 index 3dd06d15..00000000 --- a/handler/user/create.go +++ /dev/null @@ -1,123 +0,0 @@ -package user - -import ( - "encoding/json" - "errors" - "fmt" - "github.com/oullin/env" - "github.com/oullin/pkg" - "github.com/oullin/pkg/media" - "github.com/oullin/pkg/request" - "github.com/oullin/pkg/response" - "io" - "mime/multipart" - "net/http" -) - -func (handler RequestHandler) Create(w http.ResponseWriter, r *http.Request) *response.Response { - var rawRequest RawCreateRequestBag - - multipartRequest, err := request.MakeMultipartRequest(r, &rawRequest) - defer multipartRequest.Close(nil) - - if err != nil { - return response.BadRequest("issues creating the request", err) - } - - err = multipartRequest.ParseRawData(extractData) - if err != nil { - return response.BadRequest("NEW: Error getting multipart reader", err) - } - - var requestBag CreateRequestBag - if err = json.Unmarshal(rawRequest.payload, &requestBag); err != nil { - return response.BadRequest("Invalid request payload: malformed JSON", err) - } - - validate := handler.Validator - if rejects, err := validate.Rejects(requestBag); rejects { - return response.Forbidden("Validation failed", validate.GetErrors(), err) - } - - if result := handler.Repository.FindByUserName(requestBag.Username); result != nil { - return response.Unprocessable(fmt.Sprintf("user '%s' already exists", requestBag.Username), nil) - } - - profilePic, err := media.MakeMedia( - requestBag.Username, - multipartRequest.GetFile(), - multipartRequest.GetHeaderName(), - ) - - if err != nil { - return response.BadRequest("Error handling the given file", err) - } - - if err := profilePic.Upload(media.GetUsersImagesDir()); err != nil { - return response.BadRequest("Error saving the given file", err) - } - - requestBag.PublicToken = r.Header.Get(env.ApiKeyHeader) - requestBag.PictureFileName = profilePic.GetFileName() - requestBag.ProfilePictureURL = profilePic.GetFilePath(requestBag.Username) - - created, err := handler.Repository.Create(requestBag) - - if err != nil { - return response.InternalServerError(err.Error(), err) - } - - payload := map[string]any{ - "message": "User created successfully!", - "user": map[string]string{ - "uuid": created.UUID, - "picture_file_name": requestBag.PictureFileName, - "profile_picture_url": requestBag.ProfilePictureURL, - }, - } - - return pkg.SendJSON(w, http.StatusCreated, payload) -} - -func extractData[T media.MultipartFormInterface](reader *multipart.Reader, data T) error { - for { - part, err := reader.NextPart() - - if err == io.EOF { - break - } - - if err != nil { - return err - } - - switch part.FormName() { - - case "data": - if part.FileName() != "" { - return errors.New("expected 'data' to be a JSON text field") - } - - if dataBytes, err := io.ReadAll(part); err != nil { - return errors.New("Error reading data field" + err.Error()) - } else { - data.SetPayload(dataBytes) - } - - case "profile_picture_url": - - if fileBytes, err := io.ReadAll(part); err != nil { - return errors.New("Error reading file" + err.Error()) - } else { - data.SetFile(fileBytes) - data.SetHeaderName(part.FileName()) - } - } - - if err = part.Close(); err != nil { - return errors.New("Issue closing the multi-part reader" + err.Error()) - } - } - - return nil -} diff --git a/handler/user/repository.go b/handler/user/repository.go deleted file mode 100644 index 27f3ff15..00000000 --- a/handler/user/repository.go +++ /dev/null @@ -1,98 +0,0 @@ -package user - -import ( - "fmt" - "github.com/google/uuid" - "github.com/oullin/database" - "github.com/oullin/pkg" - "github.com/oullin/pkg/gorm" - "strings" - "time" -) - -type Repository struct { - Connection *database.Connection - Admin *AdminUser -} - -func MakeRepository(model *database.Connection, admin *AdminUser) *Repository { - return &Repository{ - Connection: model, - Admin: admin, - } -} - -func (r Repository) Create(attr CreateRequestBag) (*CreatedUser, error) { - password, err := pkg.MakePassword(attr.Password) - - if err != nil { - return nil, err - } - - user := &database.User{ - UUID: uuid.New().String(), - FirstName: attr.FirstName, - LastName: attr.LastName, - Username: attr.Username, - DisplayName: attr.DisplayName, - Email: attr.Email, - PasswordHash: password.GetHash(), - PublicToken: attr.PublicToken, - Bio: attr.Bio, - PictureFileName: attr.PictureFileName, - ProfilePictureURL: attr.ProfilePictureURL, - VerifiedAt: time.Now(), - IsAdmin: strings.Trim(attr.Username, " ") == adminUserName, - } - - result := r.Connection.Sql().Create(&user) - - if result.Error != nil { - return nil, result.Error - } - - return &CreatedUser{ - UUID: user.UUID, - }, nil -} - -func (r Repository) FindByUserName(username string) *database.User { - user := &database.User{} - - result := r.Connection.Sql(). - Where("username = ?", username). - First(&user) - - if gorm.HasDbIssues(result.Error) { - return nil - } - - if strings.Trim(user.UUID, " ") != "" { - return user - } - - return nil -} - -func (r Repository) FindPosts(author database.User) ([]database.Post, error) { - var posts []database.Post - - err := r.Connection.Sql(). - Model(&database.Post{}). - Where("author_id = ?", author.ID). - Where("published_at IS NOT NULL"). - Where("deleted_at IS NULL"). - Order("created_at desc"). - Find(&posts). - Error - - if gorm.IsNotFound(err) { - return nil, fmt.Errorf("posts not found for author [%s]: %s", author.Username, err.Error()) - } - - if gorm.IsFoundButHasErrors(err) { - return nil, fmt.Errorf("issue retrieving author's [%s] posts: %s", author.Username, err.Error()) - } - - return posts, nil -} diff --git a/handler/user/schema.go b/handler/user/schema.go deleted file mode 100644 index dd662de9..00000000 --- a/handler/user/schema.go +++ /dev/null @@ -1,56 +0,0 @@ -package user - -import "github.com/oullin/pkg" - -type RequestHandler struct { - Validator *pkg.Validator - Repository *Repository -} - -type CreatedUser struct { - UUID string `json:"uuid"` -} - -type CreateRequestBag struct { - FirstName string `json:"first_name" validate:"required,min=4,max=250"` - LastName string `json:"last_name" validate:"required,min=4,max=250"` - Username string `json:"username" validate:"required,alphanum,min=4,max=50"` - DisplayName string `json:"display_name" validate:"omitempty,min=3,max=255"` - Email string `json:"email" validate:"required,email,max=250"` - Password string `json:"password" validate:"required,min=8"` - PublicToken string `json:"public_token"` - PasswordConfirmation string `json:"password_confirmation" validate:"required,eqfield=Password"` - Bio string `json:"bio" validate:"omitempty"` - PictureFileName string `json:"picture_file_name" validate:"omitempty"` - ProfilePictureURL string `json:"profile_picture_url" validate:"omitempty,url,max=2048"` -} - -type RawCreateRequestBag struct { - file []byte - payload []byte - headerName string -} - -func (n *RawCreateRequestBag) SetFile(file []byte) { - n.file = file -} - -func (n *RawCreateRequestBag) SetPayload(payload []byte) { - n.payload = payload -} - -func (n *RawCreateRequestBag) SetHeaderName(headerName string) { - n.headerName = headerName -} - -func (n *RawCreateRequestBag) GetFile() []byte { - return n.file -} - -func (n *RawCreateRequestBag) GetPayload() []byte { - return n.payload -} - -func (n *RawCreateRequestBag) GetHeaderName() string { - return n.headerName -} diff --git a/main.go b/main.go index 7a343890..15f6adf4 100644 --- a/main.go +++ b/main.go @@ -3,47 +3,30 @@ package main import ( _ "github.com/lib/pq" "github.com/oullin/boost" - "github.com/oullin/env" - "github.com/oullin/pkg" "log/slog" - "net/http" + baseHttp "net/http" ) -var environment *env.Environment -var validator *pkg.Validator +var app *boost.App func init() { - secrets, validate := boost.Spark("./.env") + secrets, validate := boost.Ignite("./.env") - environment = secrets - validator = validate + app = boost.MakeApp(secrets, validate) } func main() { - dbConnection := boost.MakeDbConnection(environment) - logs := boost.MakeLogs(environment) - localSentry := boost.MakeSentry(environment) + defer app.CloseDB() + defer app.CloseLogs() - defer (*logs).Close() - defer (*dbConnection).Close() + app.Boot() - mux := http.NewServeMux() + // --- Testing + app.GetDB().Ping() + slog.Info("Starting new server on :" + app.GetEnv().Network.HttpPort) + // --- - app := boost.MakeApp(mux, &boost.App{ - Validator: validator, - Logs: logs, - DbConnection: dbConnection, - Env: environment, - Mux: mux, - Sentry: localSentry, - }) - - app.RegisterUsers() - - (*dbConnection).Ping() - slog.Info("Starting new server on :" + environment.Network.HttpPort) - - if err := http.ListenAndServe(environment.Network.GetHostURL(), mux); err != nil { + if err := baseHttp.ListenAndServe(app.GetEnv().Network.GetHostURL(), app.GetMux()); err != nil { slog.Error("Error starting server", "error", err) panic("Error starting server." + err.Error()) } diff --git a/pkg/auth/token.go b/pkg/auth/token.go index 86b74584..df6c798a 100644 --- a/pkg/auth/token.go +++ b/pkg/auth/token.go @@ -7,9 +7,8 @@ import ( ) type Token struct { - Username string `validate:"required,lowercase,alpha,min=5"` - Public string `validate:"required,min=10"` - Private string `validate:"required,min=10"` + Public string `validate:"required,min=10"` + Private string `validate:"required,min=10"` } func (t Token) IsInvalid(seed string) bool { diff --git a/pkg/http/handler.go b/pkg/http/handler.go new file mode 100644 index 00000000..1777da54 --- /dev/null +++ b/pkg/http/handler.go @@ -0,0 +1,27 @@ +package http + +import ( + "encoding/json" + "log/slog" + baseHttp "net/http" +) + +func MakeApiHandler(fn ApiHandler) baseHttp.HandlerFunc { + return func(w baseHttp.ResponseWriter, r *baseHttp.Request) { + if err := fn(w, r); err != nil { + slog.Error("API Error: %s, Status: %d", err.Message, err.Status) + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(err.Status) + + resp := ErrorResponse{ + Error: err.Message, + Status: err.Status, + } + + if result := json.NewEncoder(w).Encode(resp); result != nil { + slog.Error("Could not encode error response", "error", result) + } + } + } +} diff --git a/pkg/http/message.go b/pkg/http/message.go new file mode 100644 index 00000000..85280ab8 --- /dev/null +++ b/pkg/http/message.go @@ -0,0 +1,13 @@ +package http + +import ( + "fmt" + baseHttp "net/http" +) + +func InternalError(msg string) *ApiError { + return &ApiError{ + Message: fmt.Sprintf("Internal Server Error: %s", msg), + Status: baseHttp.StatusInternalServerError, + } +} diff --git a/pkg/http/middleware/pipeline.go b/pkg/http/middleware/pipeline.go new file mode 100644 index 00000000..5ca24c5d --- /dev/null +++ b/pkg/http/middleware/pipeline.go @@ -0,0 +1,21 @@ +package middleware + +import ( + "github.com/oullin/env" + "github.com/oullin/pkg/http" +) + +type Pipeline struct { + Env *env.Environment +} + +// Chain applies a list of middleware handlers to a final ApiHandler. +// It builds the chain in reverse, so the first middleware +// in the list is the outermost one, executing first. +func (m Pipeline) Chain(h http.ApiHandler, handlers ...http.Middleware) http.ApiHandler { + for i := len(handlers) - 1; i >= 0; i-- { + h = handlers[i](h) + } + + return h +} diff --git a/pkg/http/middleware/token.go b/pkg/http/middleware/token.go new file mode 100644 index 00000000..d1807afe --- /dev/null +++ b/pkg/http/middleware/token.go @@ -0,0 +1,39 @@ +package middleware + +import ( + "github.com/oullin/pkg/auth" + "github.com/oullin/pkg/http" + "log/slog" + baseHttp "net/http" +) + +const tokenHeader = "X-API-Key" + +type TokenCheckMiddleware struct { + token auth.Token +} + +func MakeTokenMiddleware(token auth.Token) TokenCheckMiddleware { + return TokenCheckMiddleware{ + token: token, + } +} + +func (t TokenCheckMiddleware) Handle(next http.ApiHandler) http.ApiHandler { + return func(w baseHttp.ResponseWriter, r *baseHttp.Request) *http.ApiError { + + if t.token.IsInvalid(r.Header.Get(tokenHeader)) { + message := "Forbidden: Invalid API key" + slog.Error(message) + + return &http.ApiError{ + Message: message, + Status: baseHttp.StatusForbidden, + } + } + + slog.Info("Token validation successful") + + return next(w, r) + } +} diff --git a/pkg/http/middleware/username.go b/pkg/http/middleware/username.go new file mode 100644 index 00000000..73565ad0 --- /dev/null +++ b/pkg/http/middleware/username.go @@ -0,0 +1,31 @@ +package middleware + +import ( + "fmt" + "github.com/oullin/pkg/http" + "log/slog" + baseHttp "net/http" + "strings" +) + +const usernameHeader = "X-API-Username" + +func UsernameCheck(next http.ApiHandler) http.ApiHandler { + return func(w baseHttp.ResponseWriter, r *baseHttp.Request) *http.ApiError { + username := strings.TrimSpace(r.Header.Get(usernameHeader)) + + if username != "gocanto" { + message := fmt.Sprintf("Unauthorized: Invalid API username received ('%s')", username) + slog.Error(message) + + return &http.ApiError{ + Message: message, + Status: baseHttp.StatusUnauthorized, + } + } + + slog.Info("Successfully authenticated user: gocanto") + + return next(w, r) + } +} diff --git a/pkg/http/schema.go b/pkg/http/schema.go new file mode 100644 index 00000000..6f5329a9 --- /dev/null +++ b/pkg/http/schema.go @@ -0,0 +1,25 @@ +package http + +import baseHttp "net/http" + +type ErrorResponse struct { + Error string `json:"error"` + Status int `json:"status"` +} + +type ApiError struct { + Message string `json:"message"` + Status int `json:"status"` +} + +func (e *ApiError) Error() string { + if e == nil { + return "Internal Server Error" + } + + return e.Message +} + +type ApiHandler func(baseHttp.ResponseWriter, *baseHttp.Request) *ApiError + +type Middleware func(ApiHandler) ApiHandler diff --git a/pkg/middleware/middlewares.go b/pkg/middleware/middlewares.go deleted file mode 100644 index 3e56860d..00000000 --- a/pkg/middleware/middlewares.go +++ /dev/null @@ -1,49 +0,0 @@ -package middleware - -import ( - "fmt" - "github.com/oullin/env" - "github.com/oullin/pkg" - "github.com/oullin/pkg/response" - "log/slog" - "net/http" -) - -func (s MiddlewaresStack) Logging(next pkg.BaseHandler) pkg.BaseHandler { - return func(w http.ResponseWriter, r *http.Request) *response.Response { - slog.Info(fmt.Sprintf("Incoming request: [method:%s] [path:%s].", r.Method, r.URL.Path)) - - err := next(w, r) - - if err != nil { - slog.Error(fmt.Sprintf("Handler returned error: %s", err)) - } - - return err - } -} - -func (s MiddlewaresStack) AdminUser(next pkg.BaseHandler) pkg.BaseHandler { - return func(w http.ResponseWriter, r *http.Request) *response.Response { - salt := r.Header.Get(env.ApiKeyHeader) - - if s.isAdminUser(salt) { - return next(w, r) - } - - return response.Unauthorized("Unauthorized", nil) - } -} - -func (s MiddlewaresStack) isAdminUser(seed string) bool { - return s.userAdminResolver(seed) -} - -func (s MiddlewaresStack) Push(handler pkg.BaseHandler, middlewares ...Middleware) pkg.BaseHandler { - // Apply middleware in reverse order, so the first middleware in the list is executed first. - for i := len(middlewares) - 1; i >= 0; i-- { - handler = middlewares[i](handler) - } - - return handler -} diff --git a/pkg/middleware/schema.go b/pkg/middleware/schema.go deleted file mode 100644 index 0c9d1dbe..00000000 --- a/pkg/middleware/schema.go +++ /dev/null @@ -1,22 +0,0 @@ -package middleware - -import ( - "github.com/oullin/env" - "github.com/oullin/pkg" -) - -type MiddlewaresStack struct { - env *env.Environment - middleware []Middleware - userAdminResolver func(seed string) bool -} - -type Middleware func(pkg.BaseHandler) pkg.BaseHandler - -func MakeMiddlewareStack(env *env.Environment, userAdminResolver func(seed string) bool) *MiddlewaresStack { - return &MiddlewaresStack{ - env: env, - userAdminResolver: userAdminResolver, - middleware: []Middleware{}, - } -} diff --git a/storage/fixture/experience.json b/storage/fixture/experience.json new file mode 100644 index 00000000..a4cfb8ff --- /dev/null +++ b/storage/fixture/experience.json @@ -0,0 +1,96 @@ +{ + "version": "1.0.0", + "date": [ + { + "uuid": "c17a68bc-8832-4d44-b2ed-f9587cf14cd1", + "company": "Perx Technologies", + "employment_type": "Full-Time", + "location_type": "On-Site", + "position": "Head of Engineering", + "start_date": "June, 2024", + "end_date": "April, 2025", + "summary": "Led and integrated cross-functional engineering teams (DevOps, Infrastructure, Data, Frontend, Backend, Support) across time zones, fostering open communication and accountability. Scaled team growth and operations from Singapore, optimized performance (database queries from 3 s to 800 ms; API calls from 2 s to 100 ms), implemented cloud cost savings, and partnered with C-level leaders to expand engineering initiatives.", + "country": "Singapore", + "city": "Singapore", + "skills": "Executive Leadership, Strategic Planning, Engineering Management, Cross-functional Team Leadership, Technical Architecture." + }, + { + "uuid": "99db1ca0-948e-40b1-984f-e3b157a5d336", + "company": "Aspire", + "employment_type": "Full-Time", + "location_type": "On-Site", + "position": "Senior Software Engineer & Manager", + "start_date": "January, 2022", + "end_date": "April, 2024", + "summary": "Led a 12-person APAC team overseeing the software development lifecycle, mentorship, technical direction, and system architecture design. Engineered critical financial systems—prioritized payment request queues and automated credit schemas—and spearheaded SEA wallets from architecture through integration, unifying payment workflows and ledger synchronization. Improved debit account balance queries for real-time access and boosted API response times. Resolved data inconsistencies, refactored code for reliability, designed flexible scheduled payment solutions, and directed the transition from a monolithic to microservices architecture, significantly enhancing platform scalability and maintainability.", + "country": "Singapore", + "city": "Singapore", + "skills": "Leadership, Strategic Planning, Engineering Management, Cross-functional Team Leadership, Technical Architecture." + }, + { + "uuid": "01e33400-6957-4d16-8edb-0802a49e445e", + "company": "BeMyGuest - Tours & Activities", + "employment_type": "Full-Time", + "location_type": "On-Site", + "position": "Engineering Lead", + "start_date": "September, 2017", + "end_date": "November, 2021", + "summary": "Developed and maintained inventory systems with availability calculations and time-slot capacity management. Led SaaS platform development for auto-recurring subscription payments and invoicing. Owned integration of Adyen, Stripe, and PayPal gateways for new white-label e-commerce accounts, and implemented third-party booking supplier APIs across B2B, B2C, and white-label channels, supporting mission-critical operations in Southeast Asian markets.", + "country": "Singapore", + "city": "Singapore", + "skills": "Leadership, Strategic Planning, Cross-functional Team Leadership, Engineering Management, Technical Architecture." + }, + { + "uuid": "1ba5d878-3c48-4d94-aded-4a4294c26e12", + "company": "Freelance", + "employment_type": "Contractor", + "location_type": "Remote", + "position": "Web Developer", + "start_date": "June, 2014", + "end_date": "September, 2017", + "summary": "Built diverse web applications for SMEs—including e-commerce, POS, medical history, and neighborhood feedback platforms—using PHP, Laravel, VueJS, and MySQL. I also designed and delivered a multi-city drop-shipment warehouse management system, enabling real-time inventory control linked to financial reporting and distribution across multiple locations.", + "country": "United States", + "city": "Oklahoma City", + "skills": "Leadership, Strategic Planning, Strategy Alignment, Cross-functional Team Leadership, Complexity Management" + }, + { + "uuid": "8501d986-144d-4f4d-bd3f-7fb066028142", + "company": "Websarrollo", + "employment_type": "Full-Time", + "location_type": "On-Site", + "position": "Founder & Software Engineer", + "start_date": "February, 2011", + "end_date": "May, 2014", + "summary": "Led a team of designers and PHP developers, managing nationwide client projects and overseeing the full app development lifecycle—including iOS/Android social networking apps. I built CMS, shipping-tracking, e-commerce, web portfolio, college enrollment, and university survey systems, plus a City Hall Administrative System covering accounts payable, HR, payroll, treasury, and tax modules. My work leveraged PHP, jQuery (and jQuery Mobile), Cordova-JS, MySQL, HTML5, AngularJS, and Laravel 5, integrating third-party APIs and Facebook/Twitter logins within a SCRUM framework.", + "country": "Venezuela", + "city": "Valencia", + "skills": "Leadership, Strategic Planning, Strategy Alignment, Team Development, Complexity Management." + }, + { + "uuid": "82076e4e-6099-457f-8ed5-b10585125ce5", + "company": "Encava", + "employment_type": "Full-Time", + "location_type": "On-Site", + "position": "Web Developer", + "start_date": "May, 2009", + "end_date": "February, 2011", + "summary": "Maintained the company’s AS400 administrative system and spearheaded development of department-specific applications—an e-commerce inventory control for retail, web reporting for production-line quality control, an online appointment system for the medical department, and a visitor registration/management tool. I leveraged PHP, jQuery, MySQL, HTML5, and AS400 within a SCRUM framework.", + "country": "Venezuela", + "city": "Valencia", + "skills": "Creative Problem Solving, Analytical Skills, Strategy Alignment, Strategic Planning, Complexity Management." + }, + { + "uuid": "d8c3957c-99dd-401a-9d95-f2b6b1dc021a", + "company": "Forja Centro", + "employment_type": "Full-Time", + "location_type": "On-Site", + "position": "Web Developer", + "start_date": "March, 2008", + "end_date": "April, 2009", + "summary": "Maintained a Visual Basic administrative system and built internal applications to streamline operations—mail management, mechanical design support, sales-report automation, and web-based customer invoicing. I trained staff on these tools and provided technical support for Windows 8 and PC servers using PHP, jQuery, MySQL, HTML, and SQL Server.", + "country": "Venezuela", + "city": "Valencia", + "skills": "Creative Problem Solving, Analytical Skills, Strategy Alignment, Strategic Planning, Complexity Management." + } + ] +} diff --git a/storage/fixture/profile.json b/storage/fixture/profile.json new file mode 100644 index 00000000..b134b187 --- /dev/null +++ b/storage/fixture/profile.json @@ -0,0 +1,10 @@ +{ + "version": "1.0.0", + "data": { + "nickname": "gus", + "handle": "gocanto", + "name": "Gustavo Ocanto", + "email": "otnacog@example.com", + "profession": "Software Engineer" + } +} diff --git a/storage/fixture/projects.json b/storage/fixture/projects.json new file mode 100644 index 00000000..2c6bfb6e --- /dev/null +++ b/storage/fixture/projects.json @@ -0,0 +1,95 @@ +{ + "version": "1.0.0", + "data": [ + { + "uuid": "00a0a12e-6af0-4f5a-b96d-3c95cc7c365c", + "language": "PHP / Vue", + "title": "Think of your energy as an invisible compass.", + "excerpt": "After experiencing the highs and lows of going into business with a family member, I reached a significant turning point in my life.", + "url": "https://github.com/aurachakra", + "created_at": "2023-02-25", + "updated_at": "2023-10-05" + }, + { + "uuid": "0b8e6ef9-8b4f-426f-b30a-b887c5a05030", + "language": "Vue / TypeScript", + "title": "Gus's personal website.", + "excerpt": "Gus is a full-stack Software Engineer who has been building web technologies for more two decades.", + "url": "https://github.com/gocantodev/client", + "created_at": "2021-11-03", + "updated_at": "2024-09-29" + }, + { + "uuid": "dc67854e-c8bd-4461-baba-8972bee7bfb5", + "language": "GO", + "title": "users-grpc-service", + "excerpt": "users server & client communications service.", + "url": "https://github.com/gocanto/users-grpc-service", + "created_at": "2022-04-17", + "updated_at": "2025-04-22" + }, + { + "uuid": "32fd43ce-d957-4ad2-9d71-b57f71444f2a", + "language": "PHP", + "title": "laravel-simple-pdf", + "excerpt": "Simple laravel PDF generator.", + "url": "https://github.com/gocanto/laravel-simple-pdf", + "created_at": "2019-06-11", + "updated_at": "2020-12-26" + }, + { + "uuid": "b48d8098-962b-4ff9-884e-264ab33256c9", + "language": "Vue / JS", + "title": "vuemit", + "excerpt": "The smallest Vue.js events handler.", + "url": "https://github.com/gocanto/vuemit", + "created_at": "2017-02-01", + "updated_at": "2021-08-11" + }, + { + "uuid": "19acd1d7-80ca-4828-88da-d3641f8d05e1", + "language": "Vue / JS", + "title": "google-autocomplete", + "excerpt": "Google Autocomplete Vue Component.", + "url": "https://github.com/gocanto/google-autocomplete", + "created_at": "2016-07-02", + "updated_at": "2021-08-11" + }, + { + "uuid": "98b5d71a-1c78-4639-a9ed-343a8ba8c328", + "language": "GO", + "title": "converter-go", + "excerpt": "Currency converter that's data-agnostic.", + "url": "https://github.com/gocanto/go-converter", + "created_at": "2021-09-02", + "updated_at": "2021-10-11" + }, + { + "uuid": "3ce8b01f-406a-474c-80f3-8426617b42fe", + "language": "PHP", + "title": "http-client", + "excerpt": "Http client that handles retries, logging & dynamic headers.", + "url": "https://github.com/gocanto/http-client", + "created_at": "2019-07-01", + "updated_at": "2022-12-22" + }, + { + "uuid": "e517a966-f7d0-46a1-9ee4-494b38a116e5", + "language": "PHP", + "title": "converter", + "excerpt": "Immutable PHP currency converter that's data-agnostic.", + "url": "https://github.com/gocanto/converter", + "created_at": "2019-06-07", + "updated_at": "2019-06-11" + }, + { + "uuid": "928ac7e8-d0ba-4075-9c22-67050ab03755", + "language": "PHP", + "title": "Laravel Framework", + "excerpt": "Contributions to the Laravel Framework.", + "url": "https://github.com/laravel/framework/pulls?q=is%3Apr+is%3Aclosed+author%3Agocanto", + "created_at": "2017-07-06", + "updated_at": "2022-09-15" + } + ] +} diff --git a/storage/fixture/social.json b/storage/fixture/social.json new file mode 100644 index 00000000..2493de44 --- /dev/null +++ b/storage/fixture/social.json @@ -0,0 +1,40 @@ +{ + "version": "1.0.0", + "data": [ + { + "uuid": "a8a6d3a0-4a8d-4a1f-8a48-3c3b5b6f3a6e", + "handle": "@gocanto", + "url": "https://x.com/gocanto", + "description": "Follow me in X.", + "name": "x" + }, + { + "uuid": "f4b1b3e1-7b3b-4c1e-9e7b-9c6d3b5a2e1a", + "handle": "gocanto", + "url": "https://www.youtube.com/@gocanto", + "description": "Subscribe to my YouTube channel.", + "name": "youtube" + }, + { + "uuid": "c3e2a1b4-9c8d-4f3e-a2b1-1b3c4d5e6f7a", + "handle": "gocanto", + "url": "https://www.instagram.com/gocanto", + "description": "Follow me in Instagram.", + "name": "instagram" + }, + { + "uuid": "d1e9c8b2-3a4d-4e5f-b1a2-c3d4e5f6a7b8", + "handle": "gocanto", + "url": "https://www.linkedin.com/in/gocanto/", + "description": "Follow me in LinkedIn.", + "name": "linkedin" + }, + { + "uuid": "b2a1c3d4-e5f6-4a7b-8c9d-1a2b3c4d5e6f", + "handle": "gocanto", + "url": "https://github.com/gocanto", + "description": "Follow me in GitHub.", + "name": "github" + } + ] +} diff --git a/storage/fixture/talks.json b/storage/fixture/talks.json new file mode 100644 index 00000000..3b1eb3b1 --- /dev/null +++ b/storage/fixture/talks.json @@ -0,0 +1,35 @@ +{ + "version": "1.0.0", + "data": [ + { + "uuid": "b222d84c-5bbe-4c21-8ba8-a9baa7e5eaa9", + "title": "Deprecating APIs in production environments.", + "subject": "PHP APIs", + "location": "Singapore", + "url": "https://engineers.sg/v/3204", + "photo": "talks/003.jpg", + "created_at": "2019-02-11", + "updated_at": "2019-02-11" + }, + { + "uuid": "249c50ad-2fd8-45af-a429-5e25d05a6bdd", + "title": "Bootstrapping to objects to control 3rd party integrations.", + "subject": "Systems design patters and conventions.", + "location": "Singapore", + "url": "https://engineers.sg/v/3052", + "photo": "talks/002.jpg", + "created_at": "2018-12-04", + "updated_at": "2018-12-04" + }, + { + "uuid": "36c88e42-b04d-4be1-a183-c53439468769", + "title": "Restful controllers in Laravel to stay lean at the HTTP layer.", + "subject": "Actions abstractions in Laravel controllers.", + "location": "Singapore", + "url": "https://engineers.sg/v/2907", + "photo": "talks/001.jpg", + "created_at": "2018-10-04", + "updated_at": "2018-10-04" + } + ] +}