diff --git a/.env.example b/.env.example index 47fbb77e..f798bd7f 100644 --- a/.env.example +++ b/.env.example @@ -3,7 +3,8 @@ ENV_APP_NAME="Gus Blog" ENV_APP_ENV_TYPE=local ENV_APP_LOG_LEVEL=debug ENV_APP_LOGS_DIR="./storage/logs/logs_%s.log" -ENV_APP_LOGS_DATE_FORMAT="2006_02_01" +ENV_APP_LOGS_DATE_FORMAT="2006-01-02" +ENV_APP_URL= # --- The App master key for encryption. ENV_APP_MASTER_KEY= @@ -32,3 +33,6 @@ ENV_DOCKER_USER_GROUP="ggroup" # type: string, min: 16 characters. ENV_PING_USERNAME= ENV_PING_PASSWORD= + +# --- SEO: SPA application directory +ENV_SPA_DIR= diff --git a/.env.gh.example b/.env.gh.example deleted file mode 100644 index ec9f6582..00000000 --- a/.env.gh.example +++ /dev/null @@ -1,5 +0,0 @@ -ENV_HTTP_PORT=8080 -ENV_DOCKER_USER=gocanto -ENV_DOCKER_USER_GROUP=ggroup -CADDY_LOGS_PATH=./storage/logs/caddy -ENV_PUBLIC_ALLOWED_IP= diff --git a/.env.prod.example b/.env.prod.example deleted file mode 100644 index a0393fed..00000000 --- a/.env.prod.example +++ /dev/null @@ -1,37 +0,0 @@ -# --- App -ENV_APP_NAME="Gus Blog" -ENV_APP_ENV_TYPE=production -ENV_APP_LOG_LEVEL=debug -ENV_APP_LOGS_DIR="./storage/logs/logs_%s.log" -ENV_APP_LOGS_DATE_FORMAT="2006_02_01" - -# --- Public middleware -ENV_PUBLIC_ALLOWED_IP= - -# --- DB -ENV_DB_PORT= -ENV_DB_HOST= -ENV_DB_SSL_MODE= -ENV_DB_TIMEZONE= -DB_VOLUME_DATA_DIRECTORY=./database/infra/data - -# --- Database Secrets -DB_SECRET_USERNAME=./database/infra/secrets/pg_username -DB_SECRET_PASSWORD=./database/infra/secrets/pg_password -DB_SECRET_DBNAME=./database/infra/secrets/pg_dbname - - -# --- Sentry -ENV_SENTRY_DSN="" -ENV_SENTRY_CSP="" - -# --- HTTP -ENV_HTTP_HOST= -ENV_HTTP_PORT= - -# --- Docker -ENV_DOCKER_USER= -ENV_DOCKER_USER_GROUP= - -# --- Caddy -CADDY_LOGS_PATH=./storage/logs/caddy diff --git a/.gitignore b/.gitignore index dda43df9..12e2403d 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,10 @@ tmp database/infra/data +# -- [SEO]: static files +storage/seo/*.* +!storage/seo/.gitkeep + # --- [Caddy]: mtls caddy/mtls/*.* !caddy/mtls/.gitkeep diff --git a/Makefile b/Makefile index 352473bf..c3bfb0e0 100644 --- a/Makefile +++ b/Makefile @@ -54,14 +54,16 @@ help: @printf " $(BOLD)$(GREEN)audit$(NC) : Run code audits and checks.\n" @printf " $(BOLD)$(GREEN)watch$(NC) : Start a file watcher process.\n" @printf " $(BOLD)$(GREEN)format$(NC) : Automatically format code.\n" - @printf " $(BOLD)$(GREEN)test-all$(NC) : Run all the application tests.\n\n" - @printf " $(BOLD)$(GREEN)run-cli$(NC) : Run the application CLI interface.\n\n" - @printf " $(BOLD)$(GREEN)run-cli-local$(NC) : Run the application dev's CLI interface.\n\n" + @printf " $(BOLD)$(GREEN)test-all$(NC) : Run all the application tests.\n" + @printf " $(BOLD)$(GREEN)run-cli$(NC) : Run the application CLI interface.\n" + @printf " $(BOLD)$(GREEN)run-cli-docker$(NC) : Run the application [docker] dev's CLI interface.\n\n" + @printf " $(BOLD)$(GREEN)run-metal$(NC) : Run the application dev's CLI interface.\n\n" @printf "$(BOLD)$(BLUE)Build Commands:$(NC)\n" @printf " $(BOLD)$(GREEN)build-local$(NC) : Build the main application for development.\n" @printf " $(BOLD)$(GREEN)build-ci$(NC) : Build the main application for the CI.\n" - @printf " $(BOLD)$(GREEN)build-release$(NC) : Build a release version of the application.\n\n" + @printf " $(BOLD)$(GREEN)build-release$(NC) : Build a release version of the application.\n" + @printf " $(BOLD)$(GREEN)build-fresh$(NC) : Build a fresh development environment.\n\n" @printf "$(BOLD)$(BLUE)Database Commands:$(NC)\n" @printf " $(BOLD)$(GREEN)db:local$(NC) : Set up or manage the local database environment.\n" @@ -95,7 +97,7 @@ help: @printf " $(BOLD)$(GREEN)supv:api:stop$(NC) : Stop the API service supervisor.\n" @printf " $(BOLD)$(GREEN)supv:api:restart$(NC) : Restart the API service supervisor.\n" @printf " $(BOLD)$(GREEN)supv:api:logs$(NC) : Show the the API service supervisor logs.\n" - @printf " $(BOLD)$(GREEN)supv:api:logs-err$(NC): Show the the API service supervisor error logs.\n" + @printf " $(BOLD)$(GREEN)supv:api:logs-err$(NC): Show the the API service supervisor error logs.\n\n" @printf "$(BOLD)$(BLUE)Caddy Commands:$(NC)\n" @printf " $(BOLD)$(GREEN)caddy-gen-cert$(NC) : Generate the caddy's mtls certificates.\n" diff --git a/database/repository/categories.go b/database/repository/categories.go index 17a2f92d..2a5b1195 100644 --- a/database/repository/categories.go +++ b/database/repository/categories.go @@ -2,17 +2,33 @@ package repository import ( "fmt" + "strings" + "github.com/google/uuid" "github.com/oullin/database" "github.com/oullin/database/repository/pagination" "github.com/oullin/pkg/gorm" - "strings" ) type Categories struct { DB *database.Connection } +func (c Categories) Get() ([]database.Category, error) { + var categories []database.Category + + err := c.DB.Sql(). + Model(&database.Category{}). + Where("categories.deleted_at is null"). + Find(&categories).Error + + if err != nil { + return nil, err + } + + return categories, nil +} + func (c Categories) GetAll(paginate pagination.Paginate) (*pagination.Pagination[database.Category], error) { var numItems int64 var categories []database.Category diff --git a/docker-compose.yml b/docker-compose.yml index 5f2ab818..b76cdb39 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -80,10 +80,11 @@ services: # A dedicated service for running one-off Go commands api-runner: restart: no + env_file: + - ./.env image: golang:1.25.1-alpine@sha256:b6ed3fd0452c0e9bcdef5597f29cc1418f61672e9d3a2f55bf02e7222c014abd volumes: - .:/app - - ./.env:/.env:ro - go_mod_cache:/go/pkg/mod working_dir: /app environment: diff --git a/handler/keep_alive.go b/handler/keep_alive.go index 521a5235..7b1209a0 100644 --- a/handler/keep_alive.go +++ b/handler/keep_alive.go @@ -12,10 +12,10 @@ import ( ) type KeepAliveHandler struct { - env *env.Ping + env *env.PingEnvironment } -func MakeKeepAliveHandler(e *env.Ping) KeepAliveHandler { +func MakeKeepAliveHandler(e *env.PingEnvironment) KeepAliveHandler { return KeepAliveHandler{env: e} } diff --git a/handler/keep_alive_db.go b/handler/keep_alive_db.go index b589377c..cc7ab05c 100644 --- a/handler/keep_alive_db.go +++ b/handler/keep_alive_db.go @@ -13,11 +13,11 @@ import ( ) type KeepAliveDBHandler struct { - env *env.Ping + env *env.PingEnvironment db *database.Connection } -func MakeKeepAliveDBHandler(e *env.Ping, db *database.Connection) KeepAliveDBHandler { +func MakeKeepAliveDBHandler(e *env.PingEnvironment, db *database.Connection) KeepAliveDBHandler { return KeepAliveDBHandler{env: e, db: db} } diff --git a/handler/keep_alive_db_test.go b/handler/keep_alive_db_test.go index 2ce01b2d..da678956 100644 --- a/handler/keep_alive_db_test.go +++ b/handler/keep_alive_db_test.go @@ -15,7 +15,7 @@ import ( func TestKeepAliveDBHandler(t *testing.T) { db, _ := handlertests.MakeTestDB(t) - e := env.Ping{Username: "user", Password: "pass"} + e := env.PingEnvironment{Username: "user", Password: "pass"} h := MakeKeepAliveDBHandler(&e, db) t.Run("valid credentials", func(t *testing.T) { diff --git a/handler/keep_alive_test.go b/handler/keep_alive_test.go index a0296be2..015921d1 100644 --- a/handler/keep_alive_test.go +++ b/handler/keep_alive_test.go @@ -13,7 +13,7 @@ import ( ) func TestKeepAliveHandler(t *testing.T) { - e := env.Ping{Username: "user", Password: "pass"} + e := env.PingEnvironment{Username: "user", Password: "pass"} h := MakeKeepAliveHandler(&e) t.Run("valid credentials", func(t *testing.T) { diff --git a/metal/cli/accounts/handler.go b/metal/cli/accounts/handler.go index fa345d01..9a31f59e 100644 --- a/metal/cli/accounts/handler.go +++ b/metal/cli/accounts/handler.go @@ -15,7 +15,7 @@ func (h Handler) CreateAccount(accountName string) error { return fmt.Errorf("failed to create the given account [%s] tokens pair: %v", accountName, err) } - _, err = h.Tokens.Create(database.APIKeyAttr{ + item, err := h.Tokens.Create(database.APIKeyAttr{ AccountName: token.AccountName, SecretKey: token.EncryptedSecretKey, PublicKey: token.EncryptedPublicKey, @@ -25,12 +25,14 @@ func (h Handler) CreateAccount(accountName string) error { return fmt.Errorf("failed to create account [%s]: %v", accountName, err) } - cli.Successln("Account created successfully.\n") + if err = h.print(token, item); err != nil { + return fmt.Errorf("could not decode the given account [%s] keys: %v", item.AccountName, err) + } return nil } -func (h Handler) ReadAccount(accountName string) error { +func (h Handler) ShowAccount(accountName string) error { item := h.Tokens.FindBy(accountName) if item == nil { @@ -43,30 +45,14 @@ func (h Handler) ReadAccount(accountName string) error { item.PublicKey, ) - if err != nil { + if h.print(token, item) != nil { return fmt.Errorf("could not decode the given account [%s] keys: %v", item.AccountName, err) } - cli.Successln("\nThe given account has been found successfully!\n") - cli.Blueln(" > " + fmt.Sprintf("Account name: %s", token.AccountName)) - cli.Blueln(" > " + fmt.Sprintf("Public Key: %s", token.PublicKey)) - cli.Blueln(" > " + fmt.Sprintf("Secret Key: %s", token.SecretKey)) - cli.Blueln(" > " + fmt.Sprintf("API Signature: %s", auth.CreateSignatureFrom(token.AccountName, token.SecretKey))) - cli.Warningln("----- Encrypted Values -----") - cli.Magentaln(" > " + fmt.Sprintf("Public Key: %x", token.EncryptedPublicKey)) - cli.Magentaln(" > " + fmt.Sprintf("Secret Key: %x", token.EncryptedSecretKey)) - fmt.Println(" ") - return nil } -func (h Handler) CreateSignature(accountName string) error { - item := h.Tokens.FindBy(accountName) - - if item == nil { - return fmt.Errorf("the given account [%s] was not found", accountName) - } - +func (h Handler) print(token *auth.Token, item *database.APIKey) error { token, err := h.TokenHandler.DecodeTokensFor( item.AccountName, item.SecretKey, @@ -77,14 +63,14 @@ func (h Handler) CreateSignature(accountName string) error { return fmt.Errorf("could not decode the given account [%s] keys: %v", item.AccountName, err) } - signature := auth.CreateSignatureFrom(token.AccountName, token.SecretKey) - cli.Successln("\nThe given account has been found successfully!\n") cli.Blueln(" > " + fmt.Sprintf("Account name: %s", token.AccountName)) - cli.Blueln(" > " + fmt.Sprintf("Public Key: %s", auth.SafeDisplay(token.PublicKey))) - cli.Blueln(" > " + fmt.Sprintf("Secret Key: %s", auth.SafeDisplay(token.SecretKey))) + cli.Blueln(" > " + fmt.Sprintf("Public Key: %s", token.PublicKey)) + cli.Blueln(" > " + fmt.Sprintf("Secret Key: %s", token.SecretKey)) + cli.Blueln(" > " + fmt.Sprintf("API Signature: %s", auth.CreateSignatureFrom(token.AccountName, token.SecretKey))) cli.Warningln("----- Encrypted Values -----") - cli.Magentaln(" > " + fmt.Sprintf("Signature: %s", signature)) + cli.Magentaln(" > " + fmt.Sprintf("Public Key: %x", token.EncryptedPublicKey)) + cli.Magentaln(" > " + fmt.Sprintf("Secret Key: %x", token.EncryptedSecretKey)) fmt.Println(" ") return nil diff --git a/metal/cli/accounts/handler_test.go b/metal/cli/accounts/handler_test.go index ac53ce0d..9ac813a8 100644 --- a/metal/cli/accounts/handler_test.go +++ b/metal/cli/accounts/handler_test.go @@ -25,13 +25,9 @@ func TestCreateReadSignature(t *testing.T) { t.Fatalf("create: %v", err) } - if err := h.ReadAccount("tester"); err != nil { + if err := h.ShowAccount("tester"); err != nil { t.Fatalf("read: %v", err) } - - if err := h.CreateSignature("tester"); err != nil { - t.Fatalf("signature: %v", err) - } } func TestCreateAccountInvalid(t *testing.T) { @@ -42,18 +38,10 @@ func TestCreateAccountInvalid(t *testing.T) { } } -func TestReadAccountNotFound(t *testing.T) { - h := setupAccountHandler(t) - - if err := h.ReadAccount("missing"); err == nil { - t.Fatalf("expected error") - } -} - -func TestCreateSignatureNotFound(t *testing.T) { +func TestShowAccountNotFound(t *testing.T) { h := setupAccountHandler(t) - if err := h.CreateSignature("missing"); err == nil { + if err := h.ShowAccount("missing"); err == nil { t.Fatalf("expected error") } } diff --git a/metal/cli/main.go b/metal/cli/main.go index c841cef2..bf916e23 100644 --- a/metal/cli/main.go +++ b/metal/cli/main.go @@ -1,10 +1,14 @@ package main import ( + "fmt" + "time" + "github.com/oullin/database" "github.com/oullin/metal/cli/accounts" "github.com/oullin/metal/cli/panel" "github.com/oullin/metal/cli/posts" + "github.com/oullin/metal/cli/seo" "github.com/oullin/metal/env" "github.com/oullin/metal/kernel" "github.com/oullin/pkg/cli" @@ -57,7 +61,14 @@ func main() { return case 4: - if err = generateApiAccountsHTTPSignature(menu); err != nil { + if err = generateSEO(); err != nil { + cli.Errorln(err.Error()) + continue + } + + return + case 5: + if err = printTimestamp(); err != nil { cli.Errorln(err.Error()) continue } @@ -126,29 +137,58 @@ func showApiAccount(menu panel.Menu) error { return err } - if err = handler.ReadAccount(account); err != nil { + if err = handler.ShowAccount(account); err != nil { return err } return nil } -func generateApiAccountsHTTPSignature(menu panel.Menu) error { - var err error - var account string - var handler *accounts.Handler +func generateSEO() error { + gen, err := seo.NewGenerator( + dbConn, + environment, + portal.GetDefaultValidator(), + ) - if account, err = menu.CaptureAccountName(); err != nil { + if err != nil { return err } - if handler, err = accounts.MakeHandler(dbConn, environment); err != nil { + if err = gen.Generate(); err != nil { return err } - if err = handler.CreateSignature(account); err != nil { - return err - } + return nil +} + +func printTimestamp() error { + now := time.Now() + + fmt.Println("--- Timestamps ---") + + // 1. Unix Timestamp (seconds since epoch) + unixTimestampSeconds := now.Unix() + fmt.Printf("Unix (seconds): %d\n", unixTimestampSeconds) + + // 2. Unix Timestamp (milliseconds since epoch) + unixTimestampMillis := now.UnixMilli() + fmt.Printf("Unix (milliseconds): %d\n", unixTimestampMillis) + + // 3. Unix Timestamp (nanoseconds since epoch) + unixTimestampNanos := now.UnixNano() + fmt.Printf("Unix (nanoseconds): %d\n", unixTimestampNanos) + + fmt.Println("\n--- Formatted Strings ---") + + // 4. Standard RFC3339 format (e.g., "2025-09-22T14:10:16+08:00") + rfc3339Timestamp := now.Format(time.RFC3339) + fmt.Printf("RFC3339: %s\n", rfc3339Timestamp) + + // 5. Custom format (e.g., "YYYY-MM-DD HH:MM:SS") + // time for layouts: Mon Jan 2 15:04:05 MST 2006 + customTimestamp := now.Format("2006-01-02 15:04:05") + fmt.Printf("Custom (YYYY-MM-DD HH:MM:SS): %s\n", customTimestamp) return nil } diff --git a/metal/cli/panel/menu.go b/metal/cli/panel/menu.go index 074c8d7f..99225ef9 100644 --- a/metal/cli/panel/menu.go +++ b/metal/cli/panel/menu.go @@ -89,7 +89,9 @@ func (p *Menu) Print() { p.PrintOption("1) Parse Blog Posts.", inner) p.PrintOption("2) Create new API account.", inner) p.PrintOption("3) Show API accounts.", inner) - p.PrintOption("4) Generate API account HTTP key pair.", inner) + p.PrintOption("4) Generate SEO.", inner) + p.PrintOption("5) Print Timestamp.", inner) + p.PrintOption(" ", inner) p.PrintOption("0) Exit.", inner) diff --git a/metal/cli/seo/categories.go b/metal/cli/seo/categories.go new file mode 100644 index 00000000..601d5f65 --- /dev/null +++ b/metal/cli/seo/categories.go @@ -0,0 +1,37 @@ +package seo + +import ( + "fmt" + "strings" + + "github.com/oullin/database" + "github.com/oullin/database/repository" +) + +type CategoriesSEO struct { + DB *database.Connection + Repository repository.Categories +} + +func NewCategories(db *database.Connection) *CategoriesSEO { + return &CategoriesSEO{ + DB: db, + Repository: repository.Categories{DB: db}, + } +} + +func (c *CategoriesSEO) Generate() ([]string, error) { + var err error + var items []database.Category + + if items, err = c.Repository.Get(); err != nil { + return nil, fmt.Errorf("could not get categories: %w", err) + } + + var categories []string + for item := range items { + categories = append(categories, strings.ToLower(items[item].Name)) + } + + return categories, nil +} diff --git a/metal/cli/seo/client.go b/metal/cli/seo/client.go new file mode 100644 index 00000000..dd9b9815 --- /dev/null +++ b/metal/cli/seo/client.go @@ -0,0 +1,82 @@ +package seo + +import ( + "encoding/json" + "fmt" + "net/http/httptest" + + "github.com/oullin/handler" + "github.com/oullin/handler/payload" + "github.com/oullin/metal/router" +) + +type Client struct { + WebsiteRoutes *router.WebsiteRoutes + Fixture router.Fixture +} + +func NewClient(routes *router.WebsiteRoutes) *Client { + return &Client{ + WebsiteRoutes: routes, + Fixture: routes.Fixture, + } +} + +func (c *Client) GetTalks() (*payload.TalksResponse, error) { + var talks payload.TalksResponse + + fn := func() router.StaticRouteResource { + return handler.MakeTalksHandler(c.Fixture.GetTalksFile()) + } + + if err := fetch[payload.TalksResponse](&talks, fn); err != nil { + return nil, fmt.Errorf("home: error fetching talks: %w", err) + } + + return &talks, nil +} + +func (c *Client) GetProfile() (*payload.ProfileResponse, error) { + var profile payload.ProfileResponse + + fn := func() router.StaticRouteResource { + return handler.MakeProfileHandler(c.Fixture.GetProfileFile()) + } + + if err := fetch[payload.ProfileResponse](&profile, fn); err != nil { + return nil, fmt.Errorf("error fetching profile: %w", err) + } + + return &profile, nil +} + +func (c *Client) GetProjects() (*payload.ProjectsResponse, error) { + var projects payload.ProjectsResponse + + fn := func() router.StaticRouteResource { + return handler.MakeProjectsHandler(c.Fixture.GetProjectsFile()) + } + + if err := fetch[payload.ProjectsResponse](&projects, fn); err != nil { + return nil, fmt.Errorf("error fetching projects: %w", err) + } + + return &projects, nil +} + +func fetch[T any](response *T, handler func() router.StaticRouteResource) error { + req := httptest.NewRequest("GET", "http://localhost:8080/proxy", nil) + rr := httptest.NewRecorder() + + maker := handler() + + if err := maker.Handle(rr, req); err != nil { + return err + } + + if err := json.Unmarshal(rr.Body.Bytes(), response); err != nil { + return err + } + + return nil +} diff --git a/metal/cli/seo/data.go b/metal/cli/seo/data.go new file mode 100644 index 00000000..79ef790f --- /dev/null +++ b/metal/cli/seo/data.go @@ -0,0 +1,65 @@ +package seo + +import "html/template" + +type Page struct { + OutputDir string `validate:"required"` + Template *template.Template `validate:"required"` + SiteName string `validate:"required"` + SameAsURL []string `validate:"required"` + Categories []string `validate:"required"` + SiteURL string `validate:"required,uri"` + LogoURL string `validate:"required,uri"` + WebRepoURL string `validate:"required,uri"` + APIRepoURL string `validate:"required,uri"` + AboutPhotoUrl string `validate:"required,uri"` + Lang string `validate:"required,oneof=en_GB"` + StubPath string `validate:"required,oneof=stub.html"` +} + +type TemplateData struct { + Lang string `validate:"required,oneof=en_GB"` + Title string `validate:"required,min=10"` + Description string `validate:"required,min=10"` + Canonical string `validate:"required,url"` + Robots string `validate:"required"` + ThemeColor string `validate:"required"` + JsonLD template.JS `validate:"required"` + OGTagOg TagOgData `validate:"required"` + Twitter TwitterData `validate:"required"` + HrefLang []HrefLangData `validate:"required"` + Favicons []FaviconData `validate:"required"` + Manifest template.JS `validate:"required"` + AppleTouchIcon string `validate:"required"` + Categories []string `validate:"required"` + BgColor string `validate:"required"` + Body []template.HTML `validate:"required"` +} + +type TagOgData struct { + Type string `validate:"required,oneof=website"` + Image string `validate:"required,url"` + ImageAlt string `validate:"required,min=10"` + ImageWidth string `validate:"required"` + ImageHeight string `validate:"required"` + SiteName string `validate:"required,min=5"` + Locale string `validate:"required,min=5"` +} + +type TwitterData struct { + Card string `validate:"required,oneof=summary_large_image"` + Image string `validate:"required,url"` + ImageAlt string `validate:"required,min=10"` +} + +type HrefLangData struct { + Lang string `validate:"required,oneof=en_GB"` + Href string `validate:"required,url"` +} + +type FaviconData struct { + Rel string `validate:"required,oneof=icon"` + Href string `validate:"required,url"` + Type string `validate:"required"` + Sizes string `validate:"required"` +} diff --git a/metal/cli/seo/defaults.go b/metal/cli/seo/defaults.go new file mode 100644 index 00000000..f387493b --- /dev/null +++ b/metal/cli/seo/defaults.go @@ -0,0 +1,31 @@ +package seo + +// --- Web URLs + +const GocantoUrl = "https://gocanto.dev/" +const RepoApiUrl = "https://github.com/oullin/api" +const RepoWebUrl = "https://github.com/oullin/web" +const LogoUrl = "https://oullin.io/assets/001-BBig3EFt.png" +const AboutPhotoUrl = "https://oullin.io/assets/about-Dt5rMl63.jpg" + +// --- Web Pages + +const WebHomeUrl = "/" +const WebHomeName = "Home" + +const WebAboutName = "About" +const WebAboutUrl = "/about" + +const WebResumeName = "Resume" +const WebResumeUrl = "/resume" + +const WebProjectsName = "Projects" +const WebProjectsUrl = "/projects" + +// --- Web Meta + +const FoundedYear = 2020 +const StubPath = "stub.html" +const ThemeColor = "#0E172B" +const Robots = "index,follow" +const Description = "Gustavo is a full-stack Software Engineer leader with over two decades of experience in building complex web systems and products, specialising in areas like e-commerce, banking, cross-payment solutions, cyber security, and customer success." diff --git a/metal/cli/seo/generator.go b/metal/cli/seo/generator.go new file mode 100644 index 00000000..a38805a9 --- /dev/null +++ b/metal/cli/seo/generator.go @@ -0,0 +1,230 @@ +package seo + +import ( + "bytes" + "embed" + "fmt" + "html/template" + "os" + "path/filepath" + + "github.com/oullin/database" + "github.com/oullin/handler/payload" + "github.com/oullin/metal/env" + "github.com/oullin/metal/router" + "github.com/oullin/pkg/cli" + "github.com/oullin/pkg/portal" +) + +//go:embed stub.html +var templatesFS embed.FS + +type Generator struct { + Page Page + Client *Client + Env *env.Environment + Validator *portal.Validator + DB *database.Connection + WebsiteRoutes *router.WebsiteRoutes +} + +func NewGenerator(db *database.Connection, env *env.Environment, val *portal.Validator) (*Generator, error) { + var err error + var categories []string + var html *template.Template + + if categories, err = NewCategories(db).Generate(); err != nil { + return nil, err + } + + page := Page{ + LogoURL: LogoUrl, + StubPath: StubPath, + WebRepoURL: RepoWebUrl, + APIRepoURL: RepoApiUrl, + Categories: categories, + SiteURL: env.App.URL, + SiteName: env.App.Name, + AboutPhotoUrl: AboutPhotoUrl, + Lang: env.App.Lang(), + OutputDir: env.Seo.SpaDir, + Template: &template.Template{}, + SameAsURL: []string{RepoApiUrl, RepoWebUrl, GocantoUrl}, + } + + if _, err = val.Rejects(page); err != nil { + return nil, fmt.Errorf("invalid template state: %s", val.GetErrorsAsJson()) + } + + if html, err = page.Load(); err != nil { + return nil, fmt.Errorf("could not load initial stub: %w", err) + } else { + page.Template = html + } + + webRoutes := router.NewWebsiteRoutes(env) + + return &Generator{ + DB: db, + Env: env, + Validator: val, + Page: page, + WebsiteRoutes: webRoutes, + Client: NewClient(webRoutes), + }, nil +} + +func (g *Generator) Generate() error { + var err error + + if err = g.GenerateHome(); err != nil { + return err + } + + return nil +} + +func (g *Generator) GenerateHome() error { + var err error + var talks *payload.TalksResponse + var profile *payload.ProfileResponse + var projects *payload.ProjectsResponse + + if profile, err = g.Client.GetProfile(); err != nil { + return err + } + + if talks, err = g.Client.GetTalks(); err != nil { + return err + } + + if projects, err = g.Client.GetProjects(); err != nil { + return err + } + + var html []template.HTML + sections := NewSections() + + html = append(html, sections.Profile(profile)) + html = append(html, sections.Categories(g.Page.Categories)) + html = append(html, sections.Talks(talks)) + html = append(html, sections.Skills(profile)) + html = append(html, sections.Projects(projects)) + + // ----- Template Parsing + + var tData TemplateData + if tData, err = g.Build(html); err != nil { + return fmt.Errorf("home: generating template data: %w", err) + } + + if err = g.Export("home", tData); err != nil { + return fmt.Errorf("home: exporting template data: %w", err) + } + + cli.Successln("Home SEO template generated") + + return nil +} + +func (g *Generator) Export(origin string, data TemplateData) error { + var err error + var buffer bytes.Buffer + fileName := fmt.Sprintf("%s.seo.html", origin) + + if err = g.Page.Template.Execute(&buffer, data); err != nil { + return fmt.Errorf("%s: rendering template: %w", fileName, err) + } + + if err = os.MkdirAll(g.Page.OutputDir, 0o755); err != nil { + return fmt.Errorf("%s: creating directory for %s: %w", fileName, g.Page.OutputDir, err) + } + + out := filepath.Join(g.Page.OutputDir, fileName) + if err = os.WriteFile(out, buffer.Bytes(), 0o644); err != nil { + return fmt.Errorf("%s: writing %s: %w", fileName, out, err) + } + + return nil +} + +func (g *Generator) Build(body []template.HTML) (TemplateData, error) { + og := TagOgData{ + ImageWidth: "600", + ImageHeight: "400", + Type: "website", + Locale: g.Page.Lang, + ImageAlt: g.Page.SiteName, + SiteName: g.Page.SiteName, + Image: g.Page.AboutPhotoUrl, + } + + twitter := TwitterData{ + Card: "summary_large_image", + Image: g.Page.AboutPhotoUrl, + ImageAlt: g.Page.SiteName, + } + + data := TemplateData{ + OGTagOg: og, + Robots: Robots, + Twitter: twitter, + ThemeColor: ThemeColor, + BgColor: ThemeColor, + Lang: g.Page.Lang, + Description: Description, + Canonical: g.Page.SiteURL, + AppleTouchIcon: g.Page.LogoURL, + Title: g.Page.SiteName, + Categories: g.Page.Categories, + JsonLD: NewJsonID(g.Page).Render(), + HrefLang: []HrefLangData{ + {Lang: g.Page.Lang, Href: g.Page.SiteURL}, + }, + Favicons: []FaviconData{ + { + Rel: "icon", + Sizes: "48x48", + Type: "image/ico", + Href: g.Page.SiteURL + "/favicon.ico", + }, + }, + } + + data.Body = body + data.Manifest = NewManifest(g.Page, data).Render() + + if _, err := g.Validator.Rejects(og); err != nil { + return TemplateData{}, fmt.Errorf("invalid og data: %s", g.Validator.GetErrorsAsJson()) + } + + if _, err := g.Validator.Rejects(twitter); err != nil { + return TemplateData{}, fmt.Errorf("invalid twitter data: %s", g.Validator.GetErrorsAsJson()) + } + + if _, err := g.Validator.Rejects(data); err != nil { + return TemplateData{}, fmt.Errorf("invalid template data: %s", g.Validator.GetErrorsAsJson()) + } + + return data, nil +} + +func (t *Page) Load() (*template.Template, error) { + raw, err := templatesFS.ReadFile(t.StubPath) + if err != nil { + return nil, fmt.Errorf("reading template: %w", err) + } + + tmpl, err := template. + New("seo"). + Funcs(template.FuncMap{ + "ManifestDataURL": ManifestDataURL, + }). + Parse(string(raw)) + + if err != nil { + return nil, fmt.Errorf("parsing template: %w", err) + } + + return tmpl, nil +} diff --git a/metal/cli/seo/jsonld.go b/metal/cli/seo/jsonld.go new file mode 100644 index 00000000..1b1225b6 --- /dev/null +++ b/metal/cli/seo/jsonld.go @@ -0,0 +1,151 @@ +package seo + +import ( + "bytes" + "encoding/json" + "fmt" + "html/template" + "time" +) + +type JsonID struct { + SiteURL string + OrgName string + LogoURL string + Lang string + FoundedYear string + SameAs []string + Now func() time.Time + + // Repos and API + WebRepoURL string + APIRepoURL string + APIName string + WebName string +} + +func NewJsonID(tmpl Page) *JsonID { + return &JsonID{ + Lang: tmpl.Lang, + SiteURL: tmpl.SiteURL, + LogoURL: tmpl.LogoURL, + OrgName: tmpl.SiteName, + WebName: tmpl.SiteName, + APIName: tmpl.SiteName, + SameAs: tmpl.SameAsURL, + APIRepoURL: tmpl.APIRepoURL, + WebRepoURL: tmpl.WebRepoURL, + FoundedYear: fmt.Sprintf("%d", FoundedYear), + Now: func() time.Time { return time.Now().UTC() }, + } +} + +func (j *JsonID) Render() template.JS { + now := j.Now().Format(time.RFC3339) + siteID := j.SiteURL + "#org" + + graph := []any{ + + map[string]any{ + "@id": siteID, + "sameAs": j.SameAs, + "brand": j.OrgName, + "image": j.LogoURL, + "name": j.OrgName, + "legalName": j.OrgName, + "url": j.SiteURL, + "foundedYear": j.FoundedYear, + "@type": "Organization", + "logo": map[string]any{"@type": "ImageObject", "url": j.LogoURL}, + }, + + map[string]any{ + "dateModified": now, + "inLanguage": j.Lang, + "@type": "WebSite", + "url": j.SiteURL, + "name": j.OrgName, + "image": j.LogoURL, + "@id": j.SiteURL + "#website", + "publisher": map[string]any{"@id": siteID}, + }, + + map[string]any{ + "operatingSystem": "All", + "url": j.SiteURL, + "name": j.OrgName, + "@type": "WebApplication", + "@id": j.SiteURL + "#app", + "applicationCategory": "DeveloperApplication", + "browserRequirements": "Requires a modern browser", + "publisher": map[string]any{"@id": siteID}, + }, + + map[string]any{ + "@type": "SoftwareSourceCode", + "@id": j.WebRepoURL + "#code", + "name": j.WebName, + "url": j.WebRepoURL, + "codeRepository": j.WebRepoURL, + "programmingLanguage": []string{"TypeScript", "JavaScript", "Vue"}, + "issueTracker": j.WebRepoURL + "/issues", + "license": j.WebRepoURL + "/blob/main/LICENSE", + "publisher": map[string]any{"@id": siteID}, + "dateModified": now, + }, + + map[string]any{ + "dateModified": now, + "inLanguage": j.Lang, + "@type": "WebAPI", + "name": j.APIName, + "documentation": j.APIRepoURL, + "@id": j.SiteURL + "#api", + "provider": map[string]any{"@id": siteID}, + "softwareHelp": []any{ + map[string]any{ + "@type": "CreativeWork", + "learningResourceType": "Documentation", + "url": j.APIRepoURL + "#readme", + }, + }, + "workExample": []any{ + map[string]any{ + "dateModified": now, + "name": j.APIName, + "url": j.APIRepoURL, + "codeRepository": j.APIRepoURL, + "@type": "SoftwareSourceCode", + "@id": j.APIRepoURL + "#code", + "issueTracker": j.APIRepoURL + "/issues", + "programmingLanguage": []string{"Go", "Makefile"}, + "publisher": map[string]any{"@id": siteID}, + "license": j.APIRepoURL + "/blob/main/LICENSE", + }, + }, + }, + } + + root := map[string]any{ + "@graph": graph, + "@context": "https://schema.org", + } + + // Encode without Template escaping and compact. + var buf bytes.Buffer + + enc := json.NewEncoder(&buf) + enc.SetEscapeHTML(false) + + if err := enc.Encode(root); err != nil { + return `{}` + } + + var compact bytes.Buffer + if err := json.Compact(&compact, buf.Bytes()); err != nil { + // Fallback to un-compacted if compaction fails + return template.JS(buf.String()) + } + + return template.JS(compact.String()) +} diff --git a/metal/cli/seo/manifest.go b/metal/cli/seo/manifest.go new file mode 100644 index 00000000..dfc3ec3d --- /dev/null +++ b/metal/cli/seo/manifest.go @@ -0,0 +1,129 @@ +package seo + +import ( + "bytes" + "encoding/json" + "html/template" + "time" +) + +type Manifest struct { + Name string + ShortName string + Description string + StartURL string + Scope string + Lang string + ThemeColor string + BgColor string + Categories []string + Icons []ManifestIcon + Now func() time.Time + Shortcuts []ManifestShortcut +} + +type ManifestIcon struct { + Src string `json:"src"` + Type string `json:"type"` + Sizes string `json:"sizes"` + Purpose string `json:"purpose,omitempty"` +} + +type ManifestShortcut struct { + URL string `json:"url"` + Name string `json:"name"` + ShortName string `json:"short_name"` + Icons []ManifestIcon `json:"icons,omitempty"` + Desc string `json:"description,omitempty"` +} + +func NewManifest(tmpl Page, data TemplateData) *Manifest { + var icons []ManifestIcon + + if len(data.Favicons) > 0 { + f := data.Favicons[0] + icons = []ManifestIcon{{Src: f.Href, Sizes: f.Sizes, Type: f.Type, Purpose: "any"}} + } else { + icons = []ManifestIcon{{Src: tmpl.LogoURL, Sizes: "512x512", Type: "image/png", Purpose: "any"}} + } + + b := &Manifest{ + Icons: icons, + Lang: tmpl.Lang, + Scope: WebHomeUrl, + BgColor: data.BgColor, + StartURL: tmpl.SiteURL, + Name: tmpl.SiteName, + ShortName: tmpl.SiteName, + Categories: data.Categories, + ThemeColor: data.ThemeColor, + Description: data.Description, + Now: func() time.Time { return time.Now().UTC() }, + Shortcuts: []ManifestShortcut{ + { + Icons: icons, + URL: WebHomeUrl, + Name: WebHomeName, + ShortName: WebHomeName, + }, + { + Icons: icons, + URL: WebProjectsUrl, + Name: WebProjectsName, + ShortName: WebProjectsName, + }, + { + Icons: icons, + URL: WebAboutUrl, + Name: WebAboutName, + ShortName: WebAboutName, + }, + { + Icons: icons, + URL: WebResumeUrl, + Name: WebResumeName, + ShortName: WebResumeName, + }, + }, + } + + return b +} + +func (m *Manifest) Render() template.JS { + root := map[string]any{ + "dir": "ltr", + "orientation": "any", + "prefer_related_applications": false, + "name": m.Name, + "lang": m.Lang, + "scope": m.Scope, + "icons": m.Icons, + "screenshots": []any{}, + "background_color": m.BgColor, + "start_url": m.StartURL, + "short_name": m.ShortName, + "shortcuts": m.Shortcuts, + "display": "standalone", + "theme_color": m.ThemeColor, + "categories": m.Categories, + "description": m.Description, + "id": m.StartURL + "#websitemanifest", + } + + var buf bytes.Buffer + + enc := json.NewEncoder(&buf) + enc.SetEscapeHTML(false) + + if err := enc.Encode(root); err != nil { + return `{}` + } + + var compact bytes.Buffer + if err := json.Compact(&compact, buf.Bytes()); err != nil { + return template.JS(buf.String()) + } + + return template.JS(compact.String()) +} diff --git a/metal/cli/seo/sections.go b/metal/cli/seo/sections.go new file mode 100644 index 00000000..5b067186 --- /dev/null +++ b/metal/cli/seo/sections.go @@ -0,0 +1,87 @@ +package seo + +import ( + "html/template" + "strings" + + "github.com/oullin/handler/payload" +) + +type Sections struct{} + +func NewSections() Sections { + return Sections{} +} + +func (s *Sections) Categories(categories []string) template.HTML { + var items []string + + for _, item := range categories { + items = append(items, "
  • "+template.HTMLEscapeString(item)+"
  • ") + } + + return template.HTML("

    Categories

    " + + "

    ", + ) +} + +func (s *Sections) Profile(profile *payload.ProfileResponse) template.HTML { + return "

    Profile

    " + + template.HTML("

    "+ + template.HTMLEscapeString(profile.Data.Name)+", "+ + template.HTMLEscapeString(profile.Data.Profession)+ + "

    ", + ) +} + +func (s *Sections) Skills(profile *payload.ProfileResponse) template.HTML { + var items []string + + for _, item := range profile.Data.Skills { + items = append(items, "
  • "+template.HTMLEscapeString(item.Item)+"
  • ") + } + + return template.HTML("

    Skills

    " + + "

    ", + ) +} + +func (s *Sections) Talks(talks *payload.TalksResponse) template.HTML { + var items []string + + for _, item := range talks.Data { + items = append(items, "
  • "+ + template.HTMLEscapeString(item.Title)+": "+ + template.HTMLEscapeString(item.Subject)+ + "
  • ", + ) + } + + return template.HTML("

    Talks

    " + + "

    ", + ) +} + +func (s *Sections) Projects(projects *payload.ProjectsResponse) template.HTML { + var items []string + + for _, item := range projects.Data { + items = append(items, "
  • "+ + template.HTMLEscapeString(item.Title)+": "+ + template.HTMLEscapeString(item.Excerpt)+ + "
  • ", + ) + } + + return template.HTML("

    Projects

    " + + "

    ", + ) +} diff --git a/metal/cli/seo/stub.html b/metal/cli/seo/stub.html new file mode 100644 index 00000000..c2f419b1 --- /dev/null +++ b/metal/cli/seo/stub.html @@ -0,0 +1,53 @@ + + + + {{.Title}} + + + + + + + + + + + + {{- range .HrefLang }} + + {{- end }} + + + {{- range .Favicons }} + + {{- end }} + + + + + + + + + + + + + + + + + + + + + + + + + + {{- range .Body }} + {{.}} + {{- end }} + + diff --git a/metal/cli/seo/support.go b/metal/cli/seo/support.go new file mode 100644 index 00000000..88a2bd60 --- /dev/null +++ b/metal/cli/seo/support.go @@ -0,0 +1,13 @@ +package seo + +import ( + "encoding/base64" + "html/template" +) + +func ManifestDataURL(manifest template.JS) template.URL { + b64 := base64.StdEncoding.EncodeToString([]byte(manifest)) + u := "data:application/manifest+json;base64," + b64 + + return template.URL(u) +} diff --git a/metal/env/app.go b/metal/env/app.go index 94e8582d..7931a494 100644 --- a/metal/env/app.go +++ b/metal/env/app.go @@ -4,8 +4,11 @@ const local = "local" const staging = "staging" const production = "production" +const defaultLanguage = "en_GB" + type AppEnvironment struct { Name string `validate:"required,min=4"` + URL string `validate:"required,url"` Type string `validate:"required,lowercase,oneof=local production staging"` MasterKey string `validate:"required,min=32"` } @@ -21,3 +24,7 @@ func (e AppEnvironment) IsStaging() bool { func (e AppEnvironment) IsLocal() bool { return e.Type == local } + +func (e AppEnvironment) Lang() string { + return defaultLanguage +} diff --git a/metal/env/env.go b/metal/env/env.go index 186cc1d2..1ce2e13b 100644 --- a/metal/env/env.go +++ b/metal/env/env.go @@ -7,12 +7,13 @@ import ( ) type Environment struct { - App AppEnvironment - DB DBEnvironment - Logs LogsEnvironment - Network NetEnvironment - Sentry SentryEnvironment - Ping Ping + App AppEnvironment `validate:"required"` + DB DBEnvironment `validate:"required"` + Logs LogsEnvironment `validate:"required"` + Network NetEnvironment `validate:"required"` + Sentry SentryEnvironment `validate:"required"` + Ping PingEnvironment `validate:"required"` + Seo SeoEnvironment `validate:"required"` } // SecretsDir defines where secret files are read from. It can be overridden in diff --git a/metal/env/ping.go b/metal/env/ping.go index a9c9cd7b..9ce9b0d8 100644 --- a/metal/env/ping.go +++ b/metal/env/ping.go @@ -2,20 +2,20 @@ package env import "strings" -type Ping struct { +type PingEnvironment struct { Username string `validate:"required,min=16"` Password string `validate:"required,min=16"` } -func (p Ping) GetUsername() string { +func (p PingEnvironment) GetUsername() string { return p.Username } -func (p Ping) GetPassword() string { +func (p PingEnvironment) GetPassword() string { return p.Password } -func (p Ping) HasInvalidCreds(username, password string) bool { +func (p PingEnvironment) HasInvalidCreds(username, password string) bool { return username != strings.TrimSpace(p.Username) || password != strings.TrimSpace(p.Password) } diff --git a/metal/env/seo.go b/metal/env/seo.go new file mode 100644 index 00000000..c8b0368e --- /dev/null +++ b/metal/env/seo.go @@ -0,0 +1,5 @@ +package env + +type SeoEnvironment struct { + SpaDir string `validate:"required"` +} diff --git a/metal/kernel/app.go b/metal/kernel/app.go index e110064b..4629210f 100644 --- a/metal/kernel/app.go +++ b/metal/kernel/app.go @@ -7,6 +7,7 @@ import ( "github.com/oullin/database" "github.com/oullin/database/repository" "github.com/oullin/metal/env" + "github.com/oullin/metal/router" "github.com/oullin/pkg/auth" "github.com/oullin/pkg/llogs" "github.com/oullin/pkg/middleware" @@ -14,7 +15,7 @@ import ( ) type App struct { - router *Router + router *router.Router sentry *portal.Sentry logs llogs.Driver validator *portal.Validator @@ -23,62 +24,77 @@ type App struct { } func MakeApp(e *env.Environment, validator *portal.Validator) (*App, error) { - tokenHandler, err := auth.MakeTokensHandler( - []byte(e.App.MasterKey), - ) - - if err != nil { - return nil, fmt.Errorf("bootstrapping error > could not create a token handler: %w", err) - } - - db := MakeDbConnection(e) - app := App{ env: e, validator: validator, logs: MakeLogs(e), sentry: MakeSentry(e), - db: db, + db: MakeDbConnection(e), } - router := Router{ - Env: e, - Db: db, - Mux: baseHttp.NewServeMux(), - validator: validator, - Pipeline: middleware.Pipeline{ - Env: e, - ApiKeys: &repository.ApiKeys{DB: db}, - TokenHandler: tokenHandler, - PublicMiddleware: middleware.MakePublicMiddleware( - e.Network.PublicAllowedIP, - e.Network.IsProduction, - ), - }, + if modem, err := app.NewRouter(); err != nil { + return nil, err + } else { + app.SetRouter(*modem) } - app.SetRouter(router) - return &app, nil } +func (a *App) NewRouter() (*router.Router, error) { + if a == nil { + return nil, fmt.Errorf("kernel error > router: app is nil") + } + + envi := a.env + + tokenHandler, err := auth.MakeTokensHandler( + []byte(envi.App.MasterKey), + ) + + if err != nil { + return nil, fmt.Errorf("kernel error > router: could not create a token handler: %w", err) + } + + pipe := middleware.Pipeline{ + Env: envi, + TokenHandler: tokenHandler, + ApiKeys: &repository.ApiKeys{DB: a.db}, + PublicMiddleware: middleware.MakePublicMiddleware( + envi.Network.PublicAllowedIP, + envi.Network.IsProduction, + ), + } + + modem := router.Router{ + Env: envi, + Db: a.db, + Pipeline: pipe, + Validator: a.validator, + Mux: baseHttp.NewServeMux(), + WebsiteRoutes: router.NewWebsiteRoutes(envi), + } + + return &modem, nil +} + func (a *App) Boot() { if a == nil || a.router == nil { - panic("bootstrapping error > Invalid setup") + panic("kernel error > boot: Invalid setup") } - router := *a.router - - router.KeepAlive() - router.KeepAliveDB() - router.Profile() - router.Experience() - router.Projects() - router.Social() - router.Talks() - router.Education() - router.Recommendations() - router.Posts() - router.Categories() - router.Signature() + modem := *a.router + + modem.KeepAlive() + modem.KeepAliveDB() + modem.Profile() + modem.Experience() + modem.Projects() + modem.Social() + modem.Talks() + modem.Education() + modem.Recommendations() + modem.Posts() + modem.Categories() + modem.Signature() } diff --git a/metal/kernel/factory.go b/metal/kernel/factory.go index 9bd7e056..a833d33b 100644 --- a/metal/kernel/factory.go +++ b/metal/kernel/factory.go @@ -36,7 +36,7 @@ func MakeDbConnection(env *env.Environment) *database.Connection { dbConn, err := database.MakeConnection(env) if err != nil { - panic("Sql: error connecting to PostgreSQL: " + err.Error()) + panic("Sql: error connecting to PostgresSQL: " + err.Error()) } return dbConn @@ -62,6 +62,7 @@ func MakeEnv(validate *portal.Validator) *env.Environment { app := env.AppEnvironment{ Name: env.GetEnvVar("ENV_APP_NAME"), + URL: env.GetEnvVar("ENV_APP_URL"), Type: env.GetEnvVar("ENV_APP_ENV_TYPE"), MasterKey: env.GetEnvVar("ENV_APP_MASTER_KEY"), } @@ -77,29 +78,33 @@ func MakeEnv(validate *portal.Validator) *env.Environment { TimeZone: env.GetEnvVar("ENV_DB_TIMEZONE"), } - logsCreds := env.LogsEnvironment{ + logsEnv := env.LogsEnvironment{ Level: env.GetEnvVar("ENV_APP_LOG_LEVEL"), Dir: env.GetEnvVar("ENV_APP_LOGS_DIR"), DateFormat: env.GetEnvVar("ENV_APP_LOGS_DATE_FORMAT"), } - net := env.NetEnvironment{ + netEnv := env.NetEnvironment{ HttpHost: env.GetEnvVar("ENV_HTTP_HOST"), HttpPort: env.GetEnvVar("ENV_HTTP_PORT"), PublicAllowedIP: env.GetEnvVar("ENV_PUBLIC_ALLOWED_IP"), IsProduction: app.IsProduction(), // --- only needed for validation purposes } - sentryEnvironment := env.SentryEnvironment{ + sentryEnv := env.SentryEnvironment{ DSN: env.GetEnvVar("ENV_SENTRY_DSN"), CSP: env.GetEnvVar("ENV_SENTRY_CSP"), } - pingEnvironment := env.Ping{ + pingEnv := env.PingEnvironment{ Username: env.GetEnvVar("ENV_PING_USERNAME"), Password: env.GetEnvVar("ENV_PING_PASSWORD"), } + seoEnv := env.SeoEnvironment{ + SpaDir: env.GetEnvVar("ENV_SPA_DIR"), + } + if _, err := validate.Rejects(app); err != nil { panic(errorSuffix + "invalid [APP] model: " + validate.GetErrorsAsJson()) } @@ -108,33 +113,38 @@ func MakeEnv(validate *portal.Validator) *env.Environment { panic(errorSuffix + "invalid [Sql] model: " + validate.GetErrorsAsJson()) } - if _, err := validate.Rejects(logsCreds); err != nil { - panic(errorSuffix + "invalid [logs Creds] model: " + validate.GetErrorsAsJson()) + if _, err := validate.Rejects(logsEnv); err != nil { + panic(errorSuffix + "invalid [logs Credentials] model: " + validate.GetErrorsAsJson()) } - if _, err := validate.Rejects(net); err != nil { + if _, err := validate.Rejects(netEnv); err != nil { panic(errorSuffix + "invalid [NETWORK] model: " + validate.GetErrorsAsJson()) } - if _, err := validate.Rejects(sentryEnvironment); err != nil { + if _, err := validate.Rejects(sentryEnv); err != nil { panic(errorSuffix + "invalid [SENTRY] model: " + validate.GetErrorsAsJson()) } - if _, err := validate.Rejects(pingEnvironment); err != nil { + if _, err := validate.Rejects(pingEnv); err != nil { panic(errorSuffix + "invalid [ping] model: " + validate.GetErrorsAsJson()) } + if _, err := validate.Rejects(seoEnv); err != nil { + panic(errorSuffix + "invalid [seo] model: " + validate.GetErrorsAsJson()) + } + blog := &env.Environment{ App: app, DB: db, - Logs: logsCreds, - Network: net, - Sentry: sentryEnvironment, - Ping: pingEnvironment, + Logs: logsEnv, + Network: netEnv, + Sentry: sentryEnv, + Ping: pingEnv, + Seo: seoEnv, } if _, err := validate.Rejects(blog); err != nil { - panic(errorSuffix + "invalid blog [ENVIRONMENT] model: " + validate.GetErrorsAsJson()) + panic(errorSuffix + "invalid [oullin] model: " + validate.GetErrorsAsJson()) } return blog diff --git a/metal/kernel/helpers.go b/metal/kernel/helpers.go index a5bc1565..401db3ca 100644 --- a/metal/kernel/helpers.go +++ b/metal/kernel/helpers.go @@ -5,9 +5,10 @@ import ( "github.com/oullin/database" "github.com/oullin/metal/env" + "github.com/oullin/metal/router" ) -func (a *App) SetRouter(router Router) { +func (a *App) SetRouter(router router.Router) { a.router = &router } diff --git a/metal/kernel/kernel_test.go b/metal/kernel/kernel_test.go index 24b6bc30..3230cd2f 100644 --- a/metal/kernel/kernel_test.go +++ b/metal/kernel/kernel_test.go @@ -9,6 +9,7 @@ import ( "github.com/oullin/database" "github.com/oullin/database/repository" + "github.com/oullin/metal/router" "github.com/oullin/pkg/auth" "github.com/oullin/pkg/llogs" "github.com/oullin/pkg/middleware" @@ -36,6 +37,8 @@ func validEnvVars(t *testing.T) { t.Setenv("ENV_PUBLIC_ALLOWED_IP", "1.2.3.4") t.Setenv("ENV_PING_USERNAME", "1234567890abcdef") t.Setenv("ENV_PING_PASSWORD", "abcdef1234567890") + t.Setenv("ENV_APP_URL", "http://localhost:8080") + t.Setenv("ENV_SPA_DIR", "/Users/gus/Sites/oullin/web/public/seo") } func TestMakeEnv(t *testing.T) { @@ -119,7 +122,7 @@ func TestAppHelpers(t *testing.T) { app := &App{} mux := http.NewServeMux() - r := Router{Mux: mux, Pipeline: middleware.Pipeline{PublicMiddleware: middleware.MakePublicMiddleware("", false)}} + r := router.Router{Mux: mux, Pipeline: middleware.Pipeline{PublicMiddleware: middleware.MakePublicMiddleware("", false)}} app.SetRouter(r) @@ -156,7 +159,7 @@ func TestAppBootRoutes(t *testing.T) { t.Fatalf("handler err: %v", err) } - router := Router{ + modem := router.Router{ Env: env, Mux: http.NewServeMux(), Pipeline: middleware.Pipeline{ @@ -170,7 +173,7 @@ func TestAppBootRoutes(t *testing.T) { app := &App{} - app.SetRouter(router) + app.SetRouter(modem) app.Boot() diff --git a/metal/makefile/app.mk b/metal/makefile/app.mk index 2c0cd4f7..87b03688 100644 --- a/metal/makefile/app.mk +++ b/metal/makefile/app.mk @@ -1,4 +1,4 @@ -.PHONY: fresh destroy audit watch format run-cli test-all run-cli-local +.PHONY: fresh destroy audit watch format run-cli test-all run-cli-docker run-metal format: gofmt -w -s . @@ -53,8 +53,11 @@ run-cli: DB_SECRET_DBNAME="$(DB_SECRET_DBNAME)" \ docker compose run --rm api-runner go run ./metal/cli/main.go -run-cli-local: +run-cli-docker: make run-cli DB_SECRET_USERNAME=./database/infra/secrets/pg_username DB_SECRET_PASSWORD=./database/infra/secrets/pg_password DB_SECRET_DBNAME=./database/infra/secrets/pg_dbname test-all: go test ./... + +run-metal: + go run metal/cli/main.go diff --git a/metal/makefile/build.mk b/metal/makefile/build.mk index e76d45e1..bb4c9dc0 100644 --- a/metal/makefile/build.mk +++ b/metal/makefile/build.mk @@ -1,10 +1,17 @@ -.PHONY: build-local build-ci build-prod build-release build-deploy build-local-restart build-prod-force +.PHONY: build-local build-ci build-prod build-release build-deploy build-local-restart build-prod-force build-fresh BUILD_VERSION ?= latest BUILD_PACKAGE_OWNER := oullin DB_INFRA_ROOT_PATH ?= $(ROOT_PATH)/database/infra DB_INFRA_SCRIPTS_PATH ?= $(DB_INFRA_ROOT_PATH)/scripts + +build-fresh: + make fresh && \ + make db:fresh && \ + make db:migrate && \ + make db:seed + build-local: docker compose --profile local up --build -d diff --git a/metal/makefile/db.mk b/metal/makefile/db.mk index e18f97f0..1f1ad41a 100644 --- a/metal/makefile/db.mk +++ b/metal/makefile/db.mk @@ -15,9 +15,7 @@ DB_INFRA_SCRIPTS_PATH ?= $(DB_INFRA_ROOT_PATH)/scripts # --- Migrations DB_MIGRATE_DOCKER_ENV_FLAGS = -e ENV_DB_HOST=$(DB_DOCKER_SERVICE_NAME) \ - -e ENV_DB_SSL_MODE=require \ - -e ENV_PING_USERNAME=0101010101010101 \ - -e ENV_PING_PASSWORD=0101010101010101 + -e ENV_DB_SSL_MODE=require # --- SSL Certificate Files DB_INFRA_SERVER_CRT := $(DB_INFRA_SSL_PATH)/server.crt @@ -58,7 +56,8 @@ db\:secure: openssl x509 -req -days 365 -in $(DB_INFRA_SERVER_CSR) -signkey $(DB_INFRA_SERVER_KEY) -out $(DB_INFRA_SERVER_CRT) db\:seed: - docker compose run --rm $(DB_MIGRATE_DOCKER_ENV_FLAGS) $(DB_API_RUNNER_SERVICE) go run ./database/seeder/main.go + docker compose --env-file ./.env run --rm $(DB_MIGRATE_DOCKER_ENV_FLAGS) $(DB_API_RUNNER_SERVICE) \ + go run ./database/seeder/main.go # -------------------------------------------------------------------------------------------------------------------- # # --- Migrations diff --git a/metal/router/fixture.go b/metal/router/fixture.go new file mode 100644 index 00000000..8a3e064c --- /dev/null +++ b/metal/router/fixture.go @@ -0,0 +1,80 @@ +package router + +import ( + "fmt" +) + +const FixtureTalks = "talks" +const FixtureSocial = "social" +const FixtureProfile = "profile" +const FixtureProjects = "projects" +const FixtureEducation = "education" +const FixtureExperience = "experience" +const FixtureRecommendations = "recommendations" + +type Fixture struct { + file string + basePath string + fullPath string + mime string +} + +func NewFixture() Fixture { + return Fixture{ + basePath: "./storage/fixture/", + mime: "json", + } +} + +func (f *Fixture) GetTalks() *Fixture { + return f.resolveFor(FixtureTalks) +} + +func (f *Fixture) GetTalksFile() string { + return f.resolveFor(FixtureTalks).fullPath + +} + +func (f *Fixture) GetSocial() *Fixture { + return f.resolveFor(FixtureSocial) +} + +func (f *Fixture) GetProfile() *Fixture { + return f.resolveFor(FixtureProfile) +} + +func (f *Fixture) GetProfileFile() string { + return f.resolveFor(FixtureProfile).fullPath +} + +func (f *Fixture) GetProjects() *Fixture { + return f.resolveFor(FixtureProjects) +} + +func (f *Fixture) GetProjectsFile() string { + return f.resolveFor(FixtureProjects).fullPath +} + +func (f *Fixture) GetEducation() *Fixture { + return f.resolveFor(FixtureEducation) +} + +func (f *Fixture) GetExperience() *Fixture { + return f.resolveFor(FixtureExperience) +} + +func (f *Fixture) GetRecommendations() *Fixture { + return f.resolveFor(FixtureRecommendations) +} + +func (f *Fixture) resolveFor(slug string) *Fixture { + clone := f + clone.fullPath = clone.getFileFor(slug) + clone.file = slug + + return clone +} + +func (f *Fixture) getFileFor(slug string) string { + return fmt.Sprintf("%s%s.%s", f.basePath, slug, f.mime) +} diff --git a/metal/kernel/router.go b/metal/router/router.go similarity index 52% rename from metal/kernel/router.go rename to metal/router/router.go index 0760117a..5b175f74 100644 --- a/metal/kernel/router.go +++ b/metal/router/router.go @@ -1,7 +1,8 @@ -package kernel +package router import ( baseHttp "net/http" + "strings" "github.com/oullin/database" "github.com/oullin/database/repository" @@ -12,22 +13,13 @@ import ( "github.com/oullin/pkg/portal" ) -type StaticRouteResource interface { - Handle(baseHttp.ResponseWriter, *baseHttp.Request) *http.ApiError -} - -func addStaticRoute[H StaticRouteResource](r *Router, path, file string, maker func(string) H) { - abstract := maker(file) - resolver := r.PipelineFor(abstract.Handle) - r.Mux.HandleFunc("GET "+path, resolver) -} - type Router struct { - Env *env.Environment - Mux *baseHttp.ServeMux - Pipeline middleware.Pipeline - Db *database.Connection - validator *portal.Validator + WebsiteRoutes *WebsiteRoutes + Env *env.Environment + Validator *portal.Validator + Mux *baseHttp.ServeMux + Pipeline middleware.Pipeline + Db *database.Connection } func (r *Router) PublicPipelineFor(apiHandler http.ApiHandler) baseHttp.HandlerFunc { @@ -74,7 +66,7 @@ func (r *Router) Categories() { } func (r *Router) Signature() { - abstract := handler.MakeSignaturesHandler(r.validator, r.Pipeline.ApiKeys) + abstract := handler.MakeSignaturesHandler(r.Validator, r.Pipeline.ApiKeys) generate := r.PublicPipelineFor(abstract.Generate) r.Mux.HandleFunc("POST /generate-signature", generate) @@ -101,29 +93,93 @@ func (r *Router) KeepAliveDB() { } func (r *Router) Profile() { - addStaticRoute(r, "/profile", "./storage/fixture/profile.json", handler.MakeProfileHandler) + maker := handler.MakeProfileHandler + + r.composeFixtures( + r.WebsiteRoutes.Fixture.GetProfile(), + func(file string) StaticRouteResource { + return maker(file) + }, + ) } func (r *Router) Experience() { - addStaticRoute(r, "/experience", "./storage/fixture/experience.json", handler.MakeExperienceHandler) + maker := handler.MakeExperienceHandler + + r.composeFixtures( + r.WebsiteRoutes.Fixture.GetExperience(), + func(file string) StaticRouteResource { + return maker(file) + }, + ) } func (r *Router) Projects() { - addStaticRoute(r, "/projects", "./storage/fixture/projects.json", handler.MakeProjectsHandler) + maker := handler.MakeProjectsHandler + + r.composeFixtures( + r.WebsiteRoutes.Fixture.GetProjects(), + func(file string) StaticRouteResource { + return maker(file) + }, + ) } func (r *Router) Social() { - addStaticRoute(r, "/social", "./storage/fixture/social.json", handler.MakeSocialHandler) + maker := handler.MakeSocialHandler + + r.composeFixtures( + r.WebsiteRoutes.Fixture.GetSocial(), + func(file string) StaticRouteResource { + return maker(file) + }, + ) } func (r *Router) Talks() { - addStaticRoute(r, "/talks", "./storage/fixture/talks.json", handler.MakeTalksHandler) + maker := handler.MakeTalksHandler + + r.composeFixtures( + r.WebsiteRoutes.Fixture.GetTalks(), + func(file string) StaticRouteResource { + return maker(file) + }, + ) } func (r *Router) Education() { - addStaticRoute(r, "/education", "./storage/fixture/education.json", handler.MakeEducationHandler) + maker := handler.MakeEducationHandler + + r.composeFixtures( + r.WebsiteRoutes.Fixture.GetEducation(), + func(file string) StaticRouteResource { + return maker(file) + }, + ) } func (r *Router) Recommendations() { - addStaticRoute(r, "/recommendations", "./storage/fixture/recommendations.json", handler.MakeRecommendationsHandler) + maker := handler.MakeRecommendationsHandler + + r.composeFixtures( + r.WebsiteRoutes.Fixture.GetRecommendations(), + func(file string) StaticRouteResource { + return maker(file) + }, + ) +} + +func (r *Router) composeFixtures(fxt *Fixture, maker func(file string) StaticRouteResource) { + file := fxt.file + fullPath := fxt.fullPath + + addStaticRoute(r, file, fullPath, maker) +} + +func addStaticRoute[H StaticRouteResource](r *Router, route, fixture string, maker func(string) H) { + abstract := maker(fixture) + resolver := r.PipelineFor(abstract.Handle) + + route = strings.TrimLeft(route, "/") + r.Mux.HandleFunc("GET /"+route, resolver) } diff --git a/metal/kernel/router_keep_alive_db_test.go b/metal/router/router_keep_alive_db_test.go similarity index 90% rename from metal/kernel/router_keep_alive_db_test.go rename to metal/router/router_keep_alive_db_test.go index baac81b6..f4e58b78 100644 --- a/metal/kernel/router_keep_alive_db_test.go +++ b/metal/router/router_keep_alive_db_test.go @@ -1,4 +1,4 @@ -package kernel +package router import ( "net/http" @@ -13,7 +13,7 @@ import ( func TestKeepAliveDBRoute(t *testing.T) { db, _ := handlertests.MakeTestDB(t) r := Router{ - Env: &env.Environment{Ping: env.Ping{Username: "user", Password: "pass"}}, + Env: &env.Environment{Ping: env.PingEnvironment{Username: "user", Password: "pass"}}, Db: db, Mux: http.NewServeMux(), Pipeline: middleware.Pipeline{PublicMiddleware: middleware.MakePublicMiddleware("", false)}, diff --git a/metal/kernel/router_keep_alive_test.go b/metal/router/router_keep_alive_test.go similarity index 89% rename from metal/kernel/router_keep_alive_test.go rename to metal/router/router_keep_alive_test.go index bb00b728..2f86ab11 100644 --- a/metal/kernel/router_keep_alive_test.go +++ b/metal/router/router_keep_alive_test.go @@ -1,4 +1,4 @@ -package kernel +package router import ( "net/http" @@ -11,7 +11,7 @@ import ( func TestKeepAliveRoute(t *testing.T) { r := Router{ - Env: &env.Environment{Ping: env.Ping{Username: "user", Password: "pass"}}, + Env: &env.Environment{Ping: env.PingEnvironment{Username: "user", Password: "pass"}}, Mux: http.NewServeMux(), Pipeline: middleware.Pipeline{PublicMiddleware: middleware.MakePublicMiddleware("", false)}, } diff --git a/metal/kernel/router_signature_test.go b/metal/router/router_signature_test.go similarity index 95% rename from metal/kernel/router_signature_test.go rename to metal/router/router_signature_test.go index 2b252cc6..58412ad9 100644 --- a/metal/kernel/router_signature_test.go +++ b/metal/router/router_signature_test.go @@ -1,4 +1,4 @@ -package kernel +package router import ( "fmt" @@ -18,7 +18,7 @@ func TestSignatureRoute_PublicMiddleware(t *testing.T) { Pipeline: middleware.Pipeline{ PublicMiddleware: middleware.MakePublicMiddleware("", false), }, - validator: portal.GetDefaultValidator(), + Validator: portal.GetDefaultValidator(), } r.Signature() diff --git a/metal/router/static.go b/metal/router/static.go new file mode 100644 index 00000000..7ffcc511 --- /dev/null +++ b/metal/router/static.go @@ -0,0 +1,30 @@ +package router + +import ( + baseHttp "net/http" + + "github.com/oullin/metal/env" + "github.com/oullin/pkg/http" +) + +type StaticRouteResource interface { + Handle(baseHttp.ResponseWriter, *baseHttp.Request) *http.ApiError +} + +type WebsiteRoutes struct { + OutputDir string + Lang string + SiteName string + SiteURL string + Fixture Fixture +} + +func NewWebsiteRoutes(e *env.Environment) *WebsiteRoutes { + return &WebsiteRoutes{ + SiteURL: e.App.URL, + SiteName: e.App.Name, + Lang: e.App.Lang(), + OutputDir: e.Seo.SpaDir, + Fixture: NewFixture(), + } +} diff --git a/pkg/middleware/mwguards/valid_timestamp_guard.go b/pkg/middleware/mwguards/valid_timestamp_guard.go index b4af03b7..a90c58cd 100644 --- a/pkg/middleware/mwguards/valid_timestamp_guard.go +++ b/pkg/middleware/mwguards/valid_timestamp_guard.go @@ -49,11 +49,11 @@ func (v ValidTimestamp) Validate(skew time.Duration, disallowFuture bool) *http. } if epoch < minValue { - return &http.ApiError{Message: "Request timestamp expired", Status: baseHttp.StatusUnauthorized} + return TimestampTooOldError("Request timestamp is too old or invalid", "Request timestamp invalid") } if epoch > maxValue { - return &http.ApiError{Message: "Request timestamp invalid", Status: baseHttp.StatusUnauthorized} + return TimestampTooNewError("Request timestamp is too recent or invalid", "Request timestamp invalid") } return nil diff --git a/pkg/middleware/pipeline.go b/pkg/middleware/pipeline.go index ac53b585..080aa6a3 100644 --- a/pkg/middleware/pipeline.go +++ b/pkg/middleware/pipeline.go @@ -10,7 +10,7 @@ import ( type Pipeline struct { Env *env.Environment ApiKeys *repository.ApiKeys - TokenHandler *auth.TokenHandler + TokenHandler *auth.TokenHandler //@todo Remove! PublicMiddleware PublicMiddleware } diff --git a/storage/seo/.gitkeep b/storage/seo/.gitkeep new file mode 100644 index 00000000..e69de29b