diff --git a/api/images_list.go b/api/images_list.go new file mode 100644 index 0000000..fe13645 --- /dev/null +++ b/api/images_list.go @@ -0,0 +1,170 @@ +package api + +import ( + "context" + "encoding/json" + "strconv" + "strings" + "time" + + "github.com/stephenafamo/bob" + "github.com/stephenafamo/bob/dialect/sqlite" + "github.com/stephenafamo/bob/dialect/sqlite/dialect" + "github.com/stephenafamo/bob/dialect/sqlite/sm" + "github.com/tigorlazuardi/redmage/models" + "github.com/tigorlazuardi/redmage/pkg/errs" +) + +type ImageListParams struct { + Q string + SFW bool + OrderBy string + Sort string + Offset int64 + Limit int64 + Device int32 + Subreddit int32 + CreatedAt time.Time +} + +func (ilp *ImageListParams) FillFromQuery(query Queryable) { + ilp.Q = query.Get("q") + ilp.SFW, _ = strconv.ParseBool(query.Get("sfw")) + ilp.OrderBy = query.Get("order_by") + ilp.Sort = strings.ToLower(query.Get("sort")) + ilp.Offset, _ = strconv.ParseInt(query.Get("offset"), 10, 64) + ilp.Limit, _ = strconv.ParseInt(query.Get("limit"), 10, 64) + if ilp.Limit > 100 { + ilp.Limit = 100 + } + if ilp.Limit < 1 { + ilp.Limit = 25 + } + device, _ := strconv.ParseInt(query.Get("device"), 10, 32) + ilp.Device = int32(device) + subreddit, _ := strconv.ParseInt(query.Get("subreddit"), 10, 32) + ilp.Subreddit = int32(subreddit) + + createdAtint, _ := strconv.ParseInt(query.Get("created_at"), 10, 64) + if createdAtint > 0 { + ilp.CreatedAt = time.Unix(createdAtint, 0) + } +} + +func (ilp ImageListParams) CountQuery() (expr []bob.Mod[*dialect.SelectQuery]) { + if ilp.Q != "" { + arg := sqlite.Arg("%" + ilp.Q + "%") + expr = append(expr, + sm.Where( + models.ImageColumns.Title.Like(arg). + Or(models.ImageColumns.Poster.Like(arg)). + Or(models.ImageColumns.ImageRelativePath.Like(arg)), + ), + ) + } + + if ilp.SFW { + expr = append(expr, models.SelectWhere.Images.NSFW.EQ(0)) + } + + if ilp.Device > 0 { + expr = append(expr, models.SelectWhere.Images.DeviceID.EQ(ilp.Device)) + } + + if ilp.Subreddit > 0 { + expr = append(expr, models.SelectWhere.Images.SubredditID.EQ(ilp.Subreddit)) + } + + if !ilp.CreatedAt.IsZero() { + expr = append(expr, models.SelectWhere.Images.CreatedAt.GTE(ilp.CreatedAt.Format(time.RFC3339))) + } + + return expr +} + +func (ilp ImageListParams) Query() (expr []bob.Mod[*dialect.SelectQuery]) { + expr = append(expr, ilp.CountQuery()...) + if ilp.Limit > 0 { + expr = append(expr, sm.Limit(ilp.Limit)) + } + + if ilp.Offset > 0 { + expr = append(expr, sm.Offset(ilp.Offset)) + } + + if ilp.OrderBy != "" { + order := sm.OrderBy(sqlite.Quote(ilp.OrderBy)) + if ilp.Sort == "desc" { + expr = append(expr, order.Desc()) + } else { + expr = append(expr, order.Asc()) + } + } else { + expr = append(expr, sm.OrderBy(models.ImageColumns.CreatedAt).Desc()) + } + return expr +} + +type ImageListResult struct { + Total int64 + Images models.ImageSlice +} + +func (im ImageListResult) MarshalJSON() ([]byte, error) { + type I struct { + *models.Image + Device *models.Device `json:"device,omitempty"` + Subreddit *models.Subreddit `json:"subreddit,omitempty"` + } + type A struct { + Total int64 `json:"total"` + Images []I `json:"images"` + } + + a := A{Total: im.Total} + a.Images = make([]I, len(im.Images)) + for i := 0; i < len(a.Images); i++ { + a.Images[i].Image = im.Images[i] + a.Images[i].Device = im.Images[i].R.Device + a.Images[i].Subreddit = im.Images[i].R.Subreddit + } + + return json.Marshal(a) +} + +func (api *API) ImagesList(ctx context.Context, params ImageListParams) (result ImageListResult, err error) { + ctx, span := tracer.Start(ctx, "*API.ImagesList") + defer span.End() + + result.Images, err = models.Images.Query(ctx, api.db, params.Query()...).All() + if err != nil { + return result, errs.Wrapw(err, "failed to query for images", "params", params) + } + + result.Total, err = models.Images.Query(ctx, api.db, params.CountQuery()...).Count() + if err != nil { + return result, errs.Wrapw(err, "failed to query for images", "params", params) + } + + return result, err +} + +func (api *API) ImagesListWithDevicesAndSubreddits(ctx context.Context, params ImageListParams) (result ImageListResult, err error) { + ctx, span := tracer.Start(ctx, "*API.ImagesListWithDevicesAndSubreddits") + defer span.End() + + result, err = api.ImagesList(ctx, params) + if err != nil { + return result, err + } + + if err := result.Images.LoadImageDevice(ctx, api.db); err != nil { + return result, errs.Wrapw(err, "failed to load image devices", "params", params) + } + + if err := result.Images.LoadImageSubreddit(ctx, api.db); err != nil { + return result, errs.Wrapw(err, "failed to load image subreddits", "params", params) + } + + return result, err +} diff --git a/api/queryable.go b/api/queryable.go new file mode 100644 index 0000000..ec7f53d --- /dev/null +++ b/api/queryable.go @@ -0,0 +1,5 @@ +package api + +type Queryable interface { + Get(string) string +} diff --git a/config/default.go b/config/default.go index 7229d23..48c939a 100644 --- a/config/default.go +++ b/config/default.go @@ -23,7 +23,7 @@ var DefaultConfig = map[string]any{ "pubsub.db.driver": "sqlite3", "pubsub.db.string": "pubsub.db", - "pubsub.ack.deadline": "3h", + "pubsub.ack.deadline": "30m", "download.concurrency.images": 5, "download.concurrency.subreddits": 3, @@ -34,8 +34,6 @@ var DefaultConfig = map[string]any{ "download.timeout.idlespeed": "10KB", "download.useragent": "redmage", - "download.pubsub.ack.deadline": "3h", - "http.port": "8080", "http.host": "0.0.0.0", "http.shutdown_timeout": "5s", diff --git a/server/routes/images_list.go b/server/routes/images_list.go new file mode 100644 index 0000000..7be9c63 --- /dev/null +++ b/server/routes/images_list.go @@ -0,0 +1,34 @@ +package routes + +import ( + "encoding/json" + "net/http" + + "github.com/tigorlazuardi/redmage/api" + "github.com/tigorlazuardi/redmage/pkg/errs" + "github.com/tigorlazuardi/redmage/pkg/log" +) + +func (routes *Routes) ImagesListAPI(rw http.ResponseWriter, r *http.Request) { + ctx, span := tracer.Start(r.Context(), "*Routes.ImagesList") + defer span.End() + + var params api.ImageListParams + + params.FillFromQuery(r.URL.Query()) + + enc := json.NewEncoder(rw) + + result, err := routes.API.ImagesListWithDevicesAndSubreddits(ctx, params) + if err != nil { + log.New(ctx).Err(err).Error("failed to list images") + code, message := errs.HTTPMessage(err) + rw.WriteHeader(code) + _ = enc.Encode(map[string]string{"error": message}) + return + } + + if err := enc.Encode(result); err != nil { + log.New(ctx).Err(err).Error("failed to encode images") + } +} diff --git a/server/routes/routes.go b/server/routes/routes.go index 371c8b5..b53b8e5 100644 --- a/server/routes/routes.go +++ b/server/routes/routes.go @@ -3,6 +3,7 @@ package routes import ( "io/fs" "net/http" + "os" "github.com/go-chi/chi/v5" chimiddleware "github.com/go-chi/chi/v5/middleware" @@ -19,7 +20,7 @@ type Routes struct { } func (routes *Routes) Register(router chi.Router) { - router.Use(chimiddleware.Compress(5, "text/html", "text/css", "application/javascript", "application/json")) + router.Use(chimiddleware.Compress(5, "text/html", "text/css", "application/javascript", "application/json", "image/svg+xml", "image/x-icon")) router.HandleFunc("/ping", routes.HealthCheck) router.HandleFunc("/health", routes.HealthCheck) @@ -45,12 +46,18 @@ func (routes *Routes) registerV1APIRoutes(router chi.Router) { router.Post("/devices", routes.APIDeviceCreate) router.Patch("/devices/{id}", routes.APIDeviceUpdate) + router.Get("/images", routes.ImagesListAPI) + router.Get("/events", routes.EventsAPI) } func (routes *Routes) registerWWWRoutes(router chi.Router) { router.Mount("/public", http.StripPrefix("/public", http.FileServer(http.FS(routes.PublicDir)))) + imagesDir := http.FS(os.DirFS(routes.Config.String("download.directory"))) + + router.Mount("/img", http.StripPrefix("/img", http.FileServer(imagesDir))) + router.Group(func(r chi.Router) { r.Use(otelchi.Middleware("redmage")) r.Use(chimiddleware.RequestID)