diff --git a/.air.toml b/.air.toml new file mode 100644 index 0000000..cd378fc --- /dev/null +++ b/.air.toml @@ -0,0 +1,52 @@ +root = "." +testdata_dir = "testdata" +tmp_dir = "tmp" + +[build] + args_bin = [] + bin = "./tmp/main" + cmd = "make build-local" + delay = 1000 + exclude_dir = ["assets", "tmp", "vendor", "testdata"] + exclude_file = ["templates/templates_templ.go"] + exclude_regex = ["_test.go"] + exclude_unchanged = false + follow_symlink = false + full_bin = "" + include_dir = [] + include_ext = ["go", "tpl", "tmpl", "html", "templ"] + include_file = [] + kill_delay = "0s" + log = "build-errors.log" + poll = false + poll_interval = 0 + post_cmd = [] + pre_cmd = [] + rerun = false + rerun_delay = 500 + send_interrupt = false + stop_on_error = true + +[color] + app = "" + build = "yellow" + main = "magenta" + runner = "green" + watcher = "cyan" + +[log] + main_only = false + silent = false + time = false + +[misc] + clean_on_exit = false + +[proxy] + app_port = 0 + enabled = false + proxy_port = 0 + +[screen] + clear_on_rebuild = false + keep_scroll = true diff --git a/Makefile b/Makefile index 68a8e27..f786c1f 100644 --- a/Makefile +++ b/Makefile @@ -10,4 +10,13 @@ logs: docker compose logs -f test: - go test ./... \ No newline at end of file + go test ./... + +goimports: + goimports -w -local $(cat go.mod | grep "^module " | cut -d' ' -f 2) . + +templ: + templ generate + +build-local: templ goimports + go build -o ./tmp/main ./cmd/image-text diff --git a/cmd/image-text/main.go b/cmd/image-text/main.go index ee7980f..feab3c6 100644 --- a/cmd/image-text/main.go +++ b/cmd/image-text/main.go @@ -45,10 +45,9 @@ func main() { func mode1(addr string) { slog.Info("Starting server", "addr", addr) - http.HandleFunc("/favicon.ico", server.HandleFavicon) - http.HandleFunc("/healthz", server.HandleHealthz) - http.HandleFunc("/", server.HandleMain()) - if err := http.ListenAndServe(addr, nil); err != nil { + srv := server.NewServer() + + if err := http.ListenAndServe(addr, srv); err != nil { if errors.Is(err, http.ErrServerClosed) { slog.Info("Server closed") return diff --git a/go.mod b/go.mod index 75784c8..ccfbb9a 100644 --- a/go.mod +++ b/go.mod @@ -1,8 +1,12 @@ module github.com/matbur/image-text -go 1.16 +go 1.21 + +toolchain go1.23.0 require ( + github.com/a-h/templ v0.2.793 + github.com/go-chi/chi/v5 v5.1.0 github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 github.com/kelseyhightower/envconfig v1.3.0 golang.org/x/image v0.0.0-20191214001246-9130b4cfad52 diff --git a/go.sum b/go.sum index e8ab148..b042725 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,11 @@ +github.com/a-h/templ v0.2.793 h1:Io+/ocnfGWYO4VHdR0zBbf39PQlnzVCVVD+wEEs6/qY= +github.com/a-h/templ v0.2.793/go.mod h1:lq48JXoUvuQrU0VThrK31yFwdRjTCnIE5bcPCM9IP1w= +github.com/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw= +github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g= github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/kelseyhightower/envconfig v1.3.0 h1:IvRS4f2VcIQy6j4ORGIf9145T/AsUB+oY8LyvN8BXNM= github.com/kelseyhightower/envconfig v1.3.0/go.mod h1:cccZRl6mQpaq41TPp5QxidR+Sa3axMbJDNb//FQX6Gg= golang.org/x/image v0.0.0-20191214001246-9130b4cfad52 h1:2fktqPPvDiVEEVT/vSTeoUPXfmRxRaGy6GU8jypvEn0= diff --git a/image/image.go b/image/image.go index 3c1ba42..29476fa 100644 --- a/image/image.go +++ b/image/image.go @@ -51,6 +51,10 @@ func New(size, background, foreground, text string) (*Image, error) { rgba := image.NewRGBA(image.Rect(0, 0, width, height)) + if text == "" { + text = size + } + return &Image{ Width: width, Height: height, diff --git a/server/interceptors.go b/server/interceptors.go deleted file mode 100644 index 42ea685..0000000 --- a/server/interceptors.go +++ /dev/null @@ -1,46 +0,0 @@ -package server - -import ( - "log/slog" - "net/http" - "net/http/httputil" - "strings" -) - -type interceptor func(http.HandlerFunc) http.HandlerFunc - -func chain(fns ...interceptor) interceptor { - return func(fn http.HandlerFunc) http.HandlerFunc { - for i := len(fns); i > 0; i-- { - fn = fns[i-1](fn) - } - return fn - } -} - -func dumpReq(next http.HandlerFunc) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - dumped, err := httputil.DumpRequest(r, false) - if err != nil { - slog.Error("Failed to dump request", "request", r, "err", err) - } else { - slog.Info("Request", "request", string(dumped)) - } - - next(w, r) - } -} - -func checkMethod(methods ...string) interceptor { - msg := "Expected " + strings.Join(methods, ", ") - return func(next http.HandlerFunc) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - if !isIn(r.Method, methods) { - writeJSON(w, msg, http.StatusMethodNotAllowed) - return - } - - next(w, r) - } - } -} diff --git a/server/server.go b/server/server.go index 289dbbe..cf1f319 100644 --- a/server/server.go +++ b/server/server.go @@ -1,20 +1,116 @@ package server import ( + "bytes" + "encoding/base64" "encoding/json" + "io" "log/slog" "net/http" + "net/url" "time" + "github.com/a-h/templ" + "github.com/go-chi/chi/v5" + "github.com/go-chi/chi/v5/middleware" + "github.com/matbur/image-text/image" "github.com/matbur/image-text/resources" + "github.com/matbur/image-text/templates" ) -func HandleMain() http.HandlerFunc { - return chain( - dumpReq, - checkMethod(http.MethodGet), - )(handle) +func NewServer() chi.Router { + r := chi.NewRouter() + + r.Use(middleware.Logger) + r.Use(middleware.Recoverer) + + r.Get("/", handleMain) + r.Post("/post", handlePost) + r.Get("/favicon.ico", handleFavicon) + r.Get("/docs", handleDocs) + r.Get("/*", handle) + + return r +} + +func handleMain(w http.ResponseWriter, r *http.Request) { + params := templates.IndexPageParams{ + Text: r.URL.Query().Get("text"), + BgColor: r.URL.Query().Get("bg_color"), + FgColor: r.URL.Query().Get("fg_color"), + Size: r.URL.Query().Get("size"), + } + + img, err := image.New(params.Size, params.BgColor, params.FgColor, params.Text) + if err != nil { + slog.Error("Failed to create image", "err", err) + writeJSON(w, "Internal Server Error", http.StatusInternalServerError) + return + } + + buf := bytes.Buffer{} + if err := img.Draw(&buf); err != nil { + slog.Error("Failed to draw image", "err", err) + writeJSON(w, "Internal Server Error", http.StatusInternalServerError) + return + } + + params.Image = "data:image/png;base64," + base64.StdEncoding.EncodeToString(buf.Bytes()) + + templ.Handler(templates.IndexPage(params)).ServeHTTP(w, r) +} + +func handlePost(w http.ResponseWriter, r *http.Request) { + bb, err := io.ReadAll(r.Body) + if err != nil { + slog.Error("Failed to read body", "err", err) + writeJSON(w, "Internal Server Error", http.StatusInternalServerError) + return + } + + if err := r.Body.Close(); err != nil { + slog.Error("Failed to close body", "err", err) + } + + var params templates.IndexPageParams + if err := json.Unmarshal(bb, ¶ms); err != nil { + slog.Error("Failed to unmarshal body", "err", err) + writeJSON(w, "Internal Server Error", http.StatusInternalServerError) + return + } + + slog.Debug("Request to post", "path", r.URL.Path, "body", string(bb)) + + q := url.Values{} + q.Set("text", params.Text) + q.Set("bg_color", params.BgColor) + q.Set("fg_color", params.FgColor) + q.Set("size", params.Size) + + u := url.URL{ + Path: "/", + RawQuery: q.Encode(), + } + + img, err := image.New(params.Size, params.BgColor, params.FgColor, params.Text) + if err != nil { + slog.Error("Failed to create image", "err", err) + writeJSON(w, "Internal Server Error", http.StatusInternalServerError) + return + } + + buf := bytes.Buffer{} + if err := img.Draw(&buf); err != nil { + slog.Error("Failed to draw image", "err", err) + writeJSON(w, "Internal Server Error", http.StatusInternalServerError) + return + } + + params.Image = "data:image/png;base64," + base64.StdEncoding.EncodeToString(buf.Bytes()) + + w.Header().Set("HX-Push-Url", u.String()) + templ.Handler(templates.IndexPage(params)).ServeHTTP(w, r) } func handle(w http.ResponseWriter, r *http.Request) { @@ -55,7 +151,7 @@ func handle(w http.ResponseWriter, r *http.Request) { ).Info("Response") } -func HandleFavicon(w http.ResponseWriter, r *http.Request) { +func handleFavicon(w http.ResponseWriter, r *http.Request) { bb, err := resources.Static.ReadFile("favicon.png") if err != nil { slog.Error("Failed to load favicon", "err", err) @@ -64,6 +160,7 @@ func HandleFavicon(w http.ResponseWriter, r *http.Request) { } if _, err := w.Write(bb); err != nil { slog.Error("Failed to write favicon", "err", err) + return } } @@ -75,8 +172,8 @@ var docs = struct { }{ Path: "HOST/size/background/foreground?text=rendered+text", Examples: map[string]string{ - "with_names": "http://localhost:8021/hd720/steel_blue/yellow?text=rendered+text", - "with_codes": "http://localhost:8021/320x200/000/FFFF00", + "with_names": "/hd720/steel_blue/yellow?text=rendered+text", + "with_codes": "/320x200/000/FFFF00", }, Colors: image.Colors, Sizes: image.Sizes, @@ -94,8 +191,3 @@ func handleDocs(w http.ResponseWriter, r *http.Request) { slog.Error("Failed to write docs", "err", err) } } - -func HandleHealthz(w http.ResponseWriter, r *http.Request) { - w.Write([]byte("OK")) - w.WriteHeader(http.StatusOK) -} diff --git a/templates/templates.templ b/templates/templates.templ new file mode 100644 index 0000000..6269763 --- /dev/null +++ b/templates/templates.templ @@ -0,0 +1,119 @@ +package templates + +templ head() { + + + + image-text + // + + +} + +templ layout() { + + + @head() + + { children... } + + + + + + +} + +type IndexPageParams struct { + Text string `json:"text"` + BgColor string `json:"bg_color"` + FgColor string `json:"fg_color"` + Size string `json:"size"` + Image string `json:"-"` +} + +templ IndexPage(params IndexPageParams) { + @layout() { +
+

image-text

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+
+ if params.Image != "" { + + } +
+
+ } +} diff --git a/templates/templates_templ.go b/templates/templates_templ.go new file mode 100644 index 0000000..d6ca24f --- /dev/null +++ b/templates/templates_templ.go @@ -0,0 +1,215 @@ +// Code generated by templ - DO NOT EDIT. + +// templ: version: v0.2.793 +package templates + +//lint:file-ignore SA4006 This context is only used if a nested component is present. + +import "github.com/a-h/templ" +import templruntime "github.com/a-h/templ/runtime" + +func head() templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var1 := templ.GetChildren(ctx) + if templ_7745c5c3_Var1 == nil { + templ_7745c5c3_Var1 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("image-text") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return templ_7745c5c3_Err + }) +} + +func layout() templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var2 := templ.GetChildren(ctx) + if templ_7745c5c3_Var2 == nil { + templ_7745c5c3_Var2 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = head().Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templ_7745c5c3_Var2.Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return templ_7745c5c3_Err + }) +} + +type IndexPageParams struct { + Text string `json:"text"` + BgColor string `json:"bg_color"` + FgColor string `json:"fg_color"` + Size string `json:"size"` + Image string `json:"-"` +} + +func IndexPage(params IndexPageParams) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var3 := templ.GetChildren(ctx) + if templ_7745c5c3_Var3 == nil { + templ_7745c5c3_Var3 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Var4 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("

image-text

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if params.Image != "" { + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return templ_7745c5c3_Err + }) + templ_7745c5c3_Err = layout().Render(templ.WithChildren(ctx, templ_7745c5c3_Var4), templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return templ_7745c5c3_Err + }) +} + +var _ = templruntime.GeneratedTemplate