diff --git a/.air.toml b/.air.toml index d17a93c..d8cedb9 100644 --- a/.air.toml +++ b/.air.toml @@ -4,8 +4,8 @@ tmp_dir = "tmp" [build] args_bin = [] - bin = "./tmp/main" - cmd = "templ generate && go build -o ./tmp/main ." + bin = "./portkey" + cmd = "templ generate . && ./build.sh" delay = 1000 exclude_dir = ["assets", "tmp", "vendor", "testdata", "node_modules"] exclude_file = [] diff --git a/Dockerfile b/Dockerfile index a57f596..95d0fcb 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,7 @@ FROM node:20.11.1-alpine3.18 AS frontend +ARG VERSION=dev + WORKDIR /usr/src/app COPY package.json ./ @@ -27,7 +29,7 @@ COPY internal internal/ RUN apk add --no-cache git bash RUN go install github.com/a-h/templ/cmd/templ@latest && templ generate -RUN bash build.sh +RUN sh build.sh $VERSION FROM alpine:3.19.1 @@ -40,12 +42,15 @@ LABEL org.opencontainers.image.authors='dev@codehat.de' \ WORKDIR /opt -COPY --from=backend /app/portkey /opt/app +COPY --from=backend /app/portkey ./app +# Provide a default config. Can be overwritten by mounting as volume by user. +COPY config.yml . -RUN adduser -D -H nonroot && \ - chmod +x /opt/app +RUN apk add --no-cache tzdata && \ + adduser -D -H nonroot && \ + chmod +x ./app -EXPOSE 3000 +EXPOSE 3000/tcp USER nonroot:nonroot diff --git a/build.sh b/build.sh index 2dca958..3f512a5 100755 --- a/build.sh +++ b/build.sh @@ -1,34 +1,30 @@ #!/bin/sh -clear - -TRG_PKG='main' +TARGET_PACKAGE='github.com/kodehat/portkey/internal/build' BUILD_TIME=$(date -u +"%Y.%m.%d_%H:%M:%S") -CommitHash=N/A -GoVersion=N/A -if [[ $(go version) =~ [0-9]+\.[0-9]+\.[0-9]+ ]]; +commit_hash= +version=${1:-dev} +go_version=unknown + +latest_commit=$(git log -1 --pretty=format:%h || echo 'N/A') +if [[ latest_commit =~ 'fatal' ]]; then - GoVersion=${BASH_REMATCH[0]} + commit_hash= +else + commit_hash=$latest_commit fi -GH=$(git log -1 --pretty=format:%h || echo 'N/A') -if [[ GH =~ 'fatal' ]]; +if [[ $(go version) =~ [0-9]+\.[0-9]+\.[0-9]+ ]]; then - CommitHash=N/A -else - CommitHash=$GH + go_version=${BASH_REMATCH[0]} fi -FLAG="-X $TRG_PKG.BuildTime=$BUILD_TIME" -FLAG="$FLAG -X $TRG_PKG.CommitHash=$CommitHash" -FLAG="$FLAG -X $TRG_PKG.GoVersion=$GoVersion" +FLAG="-X $TARGET_PACKAGE.BuildTime=$BUILD_TIME" +FLAG="$FLAG -X $TARGET_PACKAGE.CommitHash=$commit_hash" +FLAG="$FLAG -X $TARGET_PACKAGE.Version=$version" +FLAG="$FLAG -X $TARGET_PACKAGE.GoVersion=$go_version" -if [[ $1 =~ '-i' ]]; -then - echo 'go install' - go install -v -ldflags "$FLAG" -else - echo 'go build' - go build -v -ldflags "$FLAG" -fi \ No newline at end of file +echo "[Go v${go_version}] Building portkey at ${BUILD_TIME} with commit ${commit_hash:-unknown} in version ${version}." + +CGO_ENABLED=0 go build -ldflags "${FLAG}" \ No newline at end of file diff --git a/internal/build/build.go b/internal/build/build.go new file mode 100644 index 0000000..c939c61 --- /dev/null +++ b/internal/build/build.go @@ -0,0 +1,46 @@ +package build + +// Variables that can be injected during build. +var ( + + // BuildTime time when the application was build. + BuildTime string = "unknown" + + // CommitHash Git commit hash of the built application. + CommitHash string + + // Version version of the built application. + Version string = "dev" + + // GoVersion Go version the application was build with. + GoVersion string = "unknown" +) + +// BuildDetails struct that contains information about the built application. +type BuildDetails struct { + + // BuildTime time when the application was build. + BuildTime string + + // CommitHash Git commit hash of the built application. + CommitHash string + + // Version version of the built application. + Version string + + // GoVersion Go version the application was build with. + GoVersion string +} + +// B contains the build details of the application. +var B BuildDetails = newBuildDetails() + +// newBuildDetails loads the build details struct B during startup. +func newBuildDetails() BuildDetails { + return BuildDetails{ + BuildTime: BuildTime, + CommitHash: CommitHash, + Version: Version, + GoVersion: GoVersion, + } +} diff --git a/internal/components/utils.templ b/internal/components/utils.templ index a592d64..9d05bf1 100644 --- a/internal/components/utils.templ +++ b/internal/components/utils.templ @@ -1,13 +1,14 @@ package components -import "github.com/kodehat/portkey/internal/types" +import "github.com/kodehat/portkey/internal/build" -templ Version(buildDetails types.BuildDetails) { +templ Version(buildDetails build.BuildDetails) { } diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..0c7335b --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,70 @@ +package config + +import ( + "flag" + "fmt" + "log" + "path/filepath" + "sort" + "strings" + + "github.com/kodehat/portkey/internal/models" + "github.com/spf13/viper" +) + +type Config struct { + Host string + Port int + Title string + FooterText string + SortAlphabetically bool + Portals []models.Portal + Pages []models.Page +} + +type Flags struct { + ConfigPath string +} + +var C Config +var F Flags + +func init() { + loadFlags() + configPath, err := filepath.Abs(F.ConfigPath) + if err != nil { + panic(fmt.Errorf("fatal error config file: %w", err)) + } + log.Printf("Looking for config.y[a]ml in: %s\n", configPath) + loadConfig(F.ConfigPath) +} + +func loadFlags() { + var configPath string + flag.StringVar(&configPath, "config-path", ".", "path where config.yml can be found") + flag.Parse() + F = Flags{ + ConfigPath: configPath, + } +} + +func loadConfig(configPath string) { + viper.SetConfigName("config") + viper.SetConfigType("yml") + viper.AddConfigPath(configPath) + viper.SetDefault("host", "localhost") + viper.SetDefault("port", "1414") + viper.SetDefault("title", "Your Portal") + viper.SetDefault("footerText", "Works like a portal.") + viper.AutomaticEnv() + err := viper.ReadInConfig() + if err != nil { + panic(fmt.Errorf("fatal error config file: %w", err)) + } + viper.Unmarshal(&C) + if C.SortAlphabetically { + sort.Slice(C.Portals, func(i, j int) bool { + return strings.ToLower(C.Portals[i].Title) < strings.ToLower(C.Portals[j].Title) + }) + } +} diff --git a/internal/models/models.go b/internal/models/models.go new file mode 100644 index 0000000..3fe1920 --- /dev/null +++ b/internal/models/models.go @@ -0,0 +1,36 @@ +package models + +// Portal struct containing information about a portal. +// This is used later as a link destination shown to the user. +type Portal struct { + + // Link destination link of a portal. + Link string `json:"link"` + + // Title of a destination link. + Title string `json:"title"` + + // Emoji used as a prefix of the title. + Emoji string `json:"emoji"` + + // External defines if a destination link opens an external page or a custom page. + External bool `json:"external"` + + // Keywords allows defining additional keywords used by the search. + // This can make getting reasonable search results a lot easier. + Keywords []string `json:"keywords"` +} + +// Page struct defines a custom page that consists of a heading, content and a path, +// where the page will be available at. +type Page struct { + + // Heading of the custom page. + Heading string `json:"heading"` + + // Content of the custom page interpreted as HTML. + Content string `json:"content"` + + // Path of the custom page. + Path string `json:"path"` +} diff --git a/internal/routes/home_handler.go b/internal/routes/home_handler.go new file mode 100644 index 0000000..f15e393 --- /dev/null +++ b/internal/routes/home_handler.go @@ -0,0 +1,30 @@ +package routes + +import ( + "net/http" + "sync" + + "github.com/a-h/templ" + "github.com/kodehat/portkey/internal/components" + "github.com/kodehat/portkey/internal/config" + "github.com/kodehat/portkey/internal/utils" +) + +func homeHandler() http.HandlerFunc { + var ( + init sync.Once + allFooterPortals []templ.Component + ) + init.Do(func() { + allFooterPortals = getAllFooterPortals() + }) + home := components.HomePage() + return func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/" { + w.WriteHeader(http.StatusNotFound) + templ.Handler(components.ContentLayout(utils.PageTitle("404 Not Found", config.C.Title), "404 Not Found", components.NotFound(), allFooterPortals, config.C.FooterText)).ServeHTTP(w, r) + return + } + templ.Handler(components.HomeLayout(config.C.Title, home)).ServeHTTP(w, r) + } +} diff --git a/internal/routes/page_handler.go b/internal/routes/page_handler.go new file mode 100644 index 0000000..2948e58 --- /dev/null +++ b/internal/routes/page_handler.go @@ -0,0 +1,35 @@ +package routes + +import ( + "net/http" + "sync" + + "github.com/a-h/templ" + "github.com/kodehat/portkey/internal/components" + "github.com/kodehat/portkey/internal/config" + "github.com/kodehat/portkey/internal/utils" +) + +type pageHandlerInfo struct { + pagePath string + handlerFunc func(w http.ResponseWriter, r *http.Request) +} + +func pageHandler() []pageHandlerInfo { + var ( + init sync.Once + allFooterPortals []templ.Component + ) + init.Do(func() { + allFooterPortals = getAllFooterPortals() + }) + + var pageHandlerInfos = make([]pageHandlerInfo, len(config.C.Pages)) + for i, page := range config.C.Pages { + pageHandlerInfos[i] = pageHandlerInfo{ + pagePath: page.Path, + handlerFunc: templ.Handler(components.ContentLayout(utils.PageTitle(page.Heading, config.C.Title), page.Heading, components.ContentPage(page.Content), allFooterPortals, config.C.FooterText)).ServeHTTP, + } + } + return pageHandlerInfos +} diff --git a/internal/routes/portals_handler.go b/internal/routes/portals_handler.go new file mode 100644 index 0000000..cbd649c --- /dev/null +++ b/internal/routes/portals_handler.go @@ -0,0 +1,29 @@ +package routes + +import ( + "net/http" + "strings" + + "github.com/a-h/templ" + "github.com/kodehat/portkey/internal/components" + "github.com/kodehat/portkey/internal/config" + "github.com/kodehat/portkey/internal/utils" +) + +func portalsHandler() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + query := r.URL.Query().Get("search") + var allHomePortals = make([]templ.Component, 0) + for _, configPortal := range config.C.Portals { + portal := components.HomePortal(configPortal.Link, configPortal.Emoji, configPortal.Title, configPortal.External) + if query != "" { + if strings.Contains(configPortal.Title, query) || utils.ArrSubStr(configPortal.Keywords, query) { + allHomePortals = append(allHomePortals, portal) + } + } else { + allHomePortals = append(allHomePortals, portal) + } + } + components.PortalPartial(allHomePortals).Render(r.Context(), w) + } +} diff --git a/internal/routes/rest_handler.go b/internal/routes/rest_handler.go new file mode 100644 index 0000000..699cb7f --- /dev/null +++ b/internal/routes/rest_handler.go @@ -0,0 +1,24 @@ +package routes + +import ( + "encoding/json" + "net/http" + + "github.com/kodehat/portkey/internal/config" +) + +func portalsRestHandler() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(config.C.Portals) + } +} + +func pagesRestHandler() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(config.C.Pages) + } +} diff --git a/internal/routes/routes.go b/internal/routes/routes.go new file mode 100644 index 0000000..07ae3fc --- /dev/null +++ b/internal/routes/routes.go @@ -0,0 +1,41 @@ +package routes + +import ( + "embed" + "net/http" + + "github.com/a-h/templ" + "github.com/kodehat/portkey/internal/components" + "github.com/kodehat/portkey/internal/config" +) + +func AddRoutes(mux *http.ServeMux, static embed.FS) { + // Home + mux.HandleFunc("/", homeHandler()) + + // Custom pages + for _, pageHandler := range pageHandler() { + mux.HandleFunc(pageHandler.pagePath, pageHandler.handlerFunc) + } + + // Fix pages + mux.HandleFunc("/version", versionHandler()) + + // Static + mux.HandleFunc("/static/", staticHandler(static)) + + // htmx + mux.HandleFunc("/_/portals", portalsHandler()) + + // REST + mux.HandleFunc("/api/portals", portalsRestHandler()) + mux.HandleFunc("/api/pages", pagesRestHandler()) +} + +func getAllFooterPortals() []templ.Component { + var allFooterPortals = make([]templ.Component, len(config.C.Portals)) + for i, configPortal := range config.C.Portals { + allFooterPortals[i] = components.FooterPortal(configPortal.Link, configPortal.Emoji, configPortal.Title, configPortal.External) + } + return allFooterPortals +} diff --git a/internal/routes/static_handler.go b/internal/routes/static_handler.go new file mode 100644 index 0000000..72a739c --- /dev/null +++ b/internal/routes/static_handler.go @@ -0,0 +1,12 @@ +package routes + +import ( + "embed" + "net/http" + + "github.com/kodehat/portkey/internal/utils" +) + +func staticHandler(static embed.FS) http.HandlerFunc { + return utils.StaticHandler(http.FileServer(http.FS(static))) +} diff --git a/internal/routes/version_handler.go b/internal/routes/version_handler.go new file mode 100644 index 0000000..ef18608 --- /dev/null +++ b/internal/routes/version_handler.go @@ -0,0 +1,24 @@ +package routes + +import ( + "net/http" + "sync" + + "github.com/a-h/templ" + "github.com/kodehat/portkey/internal/build" + "github.com/kodehat/portkey/internal/components" + "github.com/kodehat/portkey/internal/config" + "github.com/kodehat/portkey/internal/utils" +) + +func versionHandler() http.HandlerFunc { + var ( + init sync.Once + allFooterPortals []templ.Component + ) + init.Do(func() { + allFooterPortals = getAllFooterPortals() + }) + + return templ.Handler(components.ContentLayout(utils.PageTitle("Version", config.C.Title), "Version", components.Version(build.B), allFooterPortals, config.C.FooterText)).ServeHTTP +} diff --git a/internal/types/build.go b/internal/types/build.go deleted file mode 100644 index 748e800..0000000 --- a/internal/types/build.go +++ /dev/null @@ -1,7 +0,0 @@ -package types - -type BuildDetails struct { - BuildTime string - CommitHash string - GoVersion string -} diff --git a/internal/types/settings.go b/internal/types/settings.go deleted file mode 100644 index 748ade5..0000000 --- a/internal/types/settings.go +++ /dev/null @@ -1,29 +0,0 @@ -package types - -type Portal struct { - Link string `json:"link"` - Title string `json:"title"` - Emoji string `json:"emoji"` - External bool `json:"external"` - Keywords []string `json:"keywords"` -} - -type Page struct { - Heading string `json:"heading"` - Path string `json:"path"` - Content string `json:"content"` -} - -type Config struct { - Host string - Port int - Title string - FooterText string - SortAlphabetically bool - Portals []Portal - Pages []Page -} - -type Flags struct { - ConfigPath string -} diff --git a/static.go b/internal/utils/handler.go similarity index 84% rename from static.go rename to internal/utils/handler.go index 3e38149..c705301 100644 --- a/static.go +++ b/internal/utils/handler.go @@ -1,4 +1,4 @@ -package main +package utils import ( "net/http" @@ -23,8 +23,8 @@ func (w *NotFoundRespWr) Write(p []byte) (int, error) { return len(p), nil // Lie that we successfully written it } -// staticHandler Redirect to 404 page if static file not found https://stackoverflow.com/a/47286697 -func staticHandler(h http.Handler) http.HandlerFunc { +// StaticHandler Redirect to 404 page if static file not found https://stackoverflow.com/a/47286697 +func StaticHandler(h http.Handler) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { nfrw := &NotFoundRespWr{ResponseWriter: w} h.ServeHTTP(nfrw, r) diff --git a/internal/utils/http.go b/internal/utils/http.go new file mode 100644 index 0000000..9d29c10 --- /dev/null +++ b/internal/utils/http.go @@ -0,0 +1,7 @@ +package utils + +import "fmt" + +func BuildAddr(host string, port int) string { + return fmt.Sprintf("%s:%d", host, port) +} diff --git a/main.go b/main.go index c897572..24b8f55 100644 --- a/main.go +++ b/main.go @@ -1,139 +1,37 @@ package main import ( + "context" "embed" - "encoding/json" - "flag" "fmt" + "io" "log" "net/http" - "path/filepath" - "sort" - "strings" + "os" - "github.com/a-h/templ" - "github.com/kodehat/portkey/internal/components" - "github.com/kodehat/portkey/internal/types" + "github.com/kodehat/portkey/internal/config" + "github.com/kodehat/portkey/internal/routes" "github.com/kodehat/portkey/internal/utils" - "github.com/spf13/viper" ) +const LOGGER_PREFIX string = "[portkey] " + //go:embed static var static embed.FS -// Injected during build. -var ( - BuildTime string = "N/A" - CommitHash string - GoVersion string = "N/A" -) - -var C types.Config -var F types.Flags -var B types.BuildDetails - func main() { - loadBuildDetails() - loadFlags() - configPath, err := filepath.Abs(F.ConfigPath) - if err != nil { - panic(fmt.Errorf("fatal error config file: %w", err)) - } - log.Printf("Looking for config.y[a]ml in: %s\n", configPath) - loadConfig(configPath) - - if C.SortAlphabetically { - sort.Slice(C.Portals, func(i, j int) bool { - return strings.ToLower(C.Portals[i].Title) < strings.ToLower(C.Portals[j].Title) - }) - } - var allFooterPortals = make([]templ.Component, len(C.Portals)) - for i, configPortal := range C.Portals { - allFooterPortals[i] = components.FooterPortal(configPortal.Link, configPortal.Emoji, configPortal.Title, configPortal.External) + ctx := context.Background() + if err := run(ctx, config.C, os.Stdin, os.Stdout, os.Stderr); err != nil { + fmt.Fprintf(os.Stderr, "%s\n", err) + os.Exit(1) } - home := components.HomePage() - - http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/" { - w.WriteHeader(http.StatusNotFound) - templ.Handler(components.ContentLayout(utils.PageTitle("404 Not Found", C.Title), "404 Not Found", components.NotFound(), allFooterPortals, C.FooterText)).ServeHTTP(w, r) - return - } - templ.Handler(components.HomeLayout(C.Title, home)).ServeHTTP(w, r) - }) - - for _, page := range C.Pages { - http.Handle(page.Path, templ.Handler(components.ContentLayout(utils.PageTitle(page.Heading, C.Title), page.Heading, components.ContentPage(page.Content), allFooterPortals, C.FooterText))) - } - http.Handle("/version", templ.Handler(components.ContentLayout(utils.PageTitle("Version", C.Title), "Version", components.Version(B), allFooterPortals, C.FooterText))) - http.Handle("/static/", staticHandler(http.FileServer(http.FS(static)))) - - http.Handle("/_/portals", http.HandlerFunc(returnSearchedPortals)) - - http.Handle("/api/v1/portals", http.HandlerFunc(returnPortalsAsJson)) - http.Handle("/api/v1/pages", http.HandlerFunc(returnPagessAsJson)) - - log.Printf("Listening on %s:%d\n", C.Host, C.Port) - http.ListenAndServe(fmt.Sprintf("%s:%d", C.Host, C.Port), nil) } -func returnSearchedPortals(w http.ResponseWriter, r *http.Request) { - query := r.URL.Query().Get("search") - var allHomePortals = make([]templ.Component, 0) - for _, configPortal := range C.Portals { - portal := components.HomePortal(configPortal.Link, configPortal.Emoji, configPortal.Title, configPortal.External) - if query != "" { - if strings.Contains(configPortal.Title, query) || utils.ArrSubStr(configPortal.Keywords, query) { - allHomePortals = append(allHomePortals, portal) - } - } else { - allHomePortals = append(allHomePortals, portal) - } - } - components.PortalPartial(allHomePortals).Render(r.Context(), w) -} - -func returnPortalsAsJson(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) - json.NewEncoder(w).Encode(C.Portals) -} - -func returnPagessAsJson(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) - json.NewEncoder(w).Encode(C.Pages) -} - -func loadBuildDetails() { - B = types.BuildDetails{ - BuildTime: BuildTime, - CommitHash: CommitHash, - GoVersion: GoVersion, - } -} - -func loadConfig(configPath string) { - viper.SetConfigName("config") - viper.SetConfigType("yml") - viper.AddConfigPath(configPath) - viper.SetDefault("host", "localhost") - viper.SetDefault("port", "1414") - viper.SetDefault("title", "Your Portal") - viper.SetDefault("footerText", "Works like a portal.") - viper.SafeWriteConfig() - err := viper.ReadInConfig() - if err != nil { - panic(fmt.Errorf("fatal error config file: %w", err)) - } - viper.Unmarshal(&C) -} - -func loadFlags() { - var configPath string - flag.StringVar(&configPath, "config-path", ".", "path where config.yml can be found") - flag.Parse() - F = types.Flags{ - ConfigPath: configPath, - } +func run(ctx context.Context, c config.Config, stdin io.Reader, stdout, stderr io.Writer) error { + logger := log.New(stdout, LOGGER_PREFIX, log.Lmsgprefix|log.Ldate|log.Ltime) + mux := http.NewServeMux() + routes.AddRoutes(mux, static) + addr := utils.BuildAddr(config.C.Host, config.C.Port) + logger.Printf("Listening on %s\n", addr) + return http.ListenAndServe(addr, mux) }