From ba69152ca8b6224d850a98905be09549278569d1 Mon Sep 17 00:00:00 2001 From: Nicolas Carlier Date: Fri, 26 Jul 2024 08:57:11 +0200 Subject: [PATCH] feat(article): smooth illutsration lazy loading --- autogen/db/postgres/db_sql_migration.go | 3 + cmd/serve/server.go | 13 +- go.mod | 6 +- go.sum | 10 +- internal/api/download.go | 17 +- internal/api/image-proxy.go | 2 +- internal/api/info.go | 5 +- internal/auth/methods/basic.go | 5 +- internal/auth/methods/oidc.go | 12 +- internal/auth/methods/proxy.go | 5 +- internal/config/config.go | 9 + internal/config/defaults.toml | 14 ++ internal/config/types.go | 10 + internal/db/article.go | 1 + internal/db/postgres/article.go | 21 ++ internal/db/postgres/migration.go | 2 +- internal/db/postgres/sql/db_migration_15.sql | 1 + internal/model/article.go | 1 + internal/schema/article/types.go | 3 + internal/service/event-thumbhash.go | 59 +++++ internal/service/event.go | 3 + internal/service/registry.go | 25 ++- internal/service/test/setup.go | 6 +- internal/service/test/test.toml | 3 + pkg/downloader/internal.go | 29 ++- pkg/downloader/types.go | 2 +- pkg/oidc/client.go | 14 ++ pkg/thumbhash/thumbhash.go | 28 +++ pkg/types/duration.go | 6 +- pkg/utils/json-problem.go | 26 +-- .../components/ArticleCard.module.css | 4 +- ui/src/articles/components/ArticleImage.tsx | 26 ++- ui/src/articles/models.ts | 1 + ui/src/articles/queries.ts | 2 + ui/src/components/LazyImage.module.css | 38 +++- ui/src/components/LazyImage.tsx | 49 +++-- ui/src/helpers/index.ts | 1 + ui/src/helpers/thumbhash.js | 208 ++++++++++++++++++ 38 files changed, 578 insertions(+), 92 deletions(-) create mode 100644 internal/db/postgres/sql/db_migration_15.sql create mode 100644 internal/service/event-thumbhash.go create mode 100644 pkg/thumbhash/thumbhash.go create mode 100644 ui/src/helpers/thumbhash.js diff --git a/autogen/db/postgres/db_sql_migration.go b/autogen/db/postgres/db_sql_migration.go index a2227a52f..e6b025b10 100644 --- a/autogen/db/postgres/db_sql_migration.go +++ b/autogen/db/postgres/db_sql_migration.go @@ -119,6 +119,8 @@ drop type notification_strategy_type; `, "db_migration_14": `alter table outgoing_webhooks add column secrets varchar null; update outgoing_webhooks set secrets = config; +`, + "db_migration_15": `alter table articles add column thumbhash varchar null; `, "db_migration_2": `create table devices ( id serial not null, @@ -178,6 +180,7 @@ var DatabaseSQLMigrationChecksums = map[string]string{ "db_migration_12": "b24497bb03f04fb4705ae752f8a5bf69dad26f168bc8ec196af93aee29deef49", "db_migration_13": "4a52465eeb50a236d7f7a94cc51cd78238de0f885a6d29da4a548b5c389ebe81", "db_migration_14": "f2c6e03988386e662f943d0f37255cf6db19b69e2c4f63c312f3778b401bb96a", + "db_migration_15": "edf9f683832d4b5c8c0d681f479750794ca19aea115a89b69700d4f415104fc3", "db_migration_2": "0be0d1ef1e9481d61db425a7d54378f3667c091949525b9c285b18660b6e8a1d", "db_migration_3": "5cd0d3628d990556c0b85739fd376c42244da7e98b66852b6411d27eda20c3fc", "db_migration_4": "d5fb83c15b523f15291310ff27d36c099c4ba68de2fd901c5ef5b70a18fedf65", diff --git a/cmd/serve/server.go b/cmd/serve/server.go index c1b856c44..165362d02 100644 --- a/cmd/serve/server.go +++ b/cmd/serve/server.go @@ -16,7 +16,6 @@ import ( "github.com/ncarlier/readflow/internal/metric" "github.com/ncarlier/readflow/internal/server" "github.com/ncarlier/readflow/internal/service" - "github.com/ncarlier/readflow/pkg/cache" "github.com/ncarlier/readflow/pkg/logger" ) @@ -29,14 +28,8 @@ func startServer(conf *config.Config) error { return fmt.Errorf("unable to configure the database: %w", err) } - // configure download cache - downloadCache, err := cache.NewDefault("readflow-downloads") - if err != nil { - return fmt.Errorf("unable to configure the downloader cache storage: %w", err) - } - // configure the service registry - err = service.Configure(*conf, database, downloadCache) + err = service.Configure(*conf, database) if err != nil { database.Close() return fmt.Errorf("unable to configure the service registry: %w", err) @@ -93,10 +86,6 @@ func startServer(conf *config.Config) error { service.Shutdown() - if err := downloadCache.Close(); err != nil { - logger.Error().Err(err).Msg("unable to gracefully shutdown the cache storage") - } - if err := database.Close(); err != nil { logger.Fatal().Err(err).Msg("could not gracefully shutdown database connection") } diff --git a/go.mod b/go.mod index 7f7002b8d..8896e0fec 100644 --- a/go.mod +++ b/go.mod @@ -34,7 +34,7 @@ require ( github.com/valyala/fasttemplate v1.2.2 go.etcd.io/bbolt v1.3.7 golang.org/x/net v0.14.0 - golang.org/x/sync v0.1.0 + golang.org/x/sync v0.7.0 ) require ( @@ -49,6 +49,7 @@ require ( github.com/dustin/go-humanize v1.0.1 // indirect github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21 // indirect github.com/emersion/go-smtp v0.18.0 + github.com/galdor/go-thumbhash v1.0.0 github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f // indirect github.com/golang-jwt/jwt v3.2.2+incompatible github.com/golang/protobuf v1.5.3 // indirect @@ -76,8 +77,9 @@ require ( github.com/tdewolff/parse/v2 v2.6.6 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect golang.org/x/crypto v0.12.0 + golang.org/x/image v0.18.0 golang.org/x/sys v0.11.0 // indirect - golang.org/x/text v0.12.0 // indirect + golang.org/x/text v0.16.0 // indirect google.golang.org/protobuf v1.30.0 // indirect gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect diff --git a/go.sum b/go.sum index 13094fdfb..1bcde809b 100644 --- a/go.sum +++ b/go.sum @@ -44,6 +44,8 @@ github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21/go.mod h1:iL2twTe github.com/emersion/go-smtp v0.18.0 h1:lrVQqB0JdxYjC8CsBt55pSwB756bRRN6vK0DSr0pXfM= github.com/emersion/go-smtp v0.18.0/go.mod h1:qm27SGYgoIPRot6ubfQ/GpiPy/g3PaZAVRxiO/sDUgQ= github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw= +github.com/galdor/go-thumbhash v1.0.0 h1:Q7xSnaDvSC91SuNmQI94JuUVHva29FDdA4/PkV0EHjU= +github.com/galdor/go-thumbhash v1.0.0/go.mod h1:gEK2wZqIxS2W4mXNf48lPl6HWjX0vWsH1LpK/cU74Ho= github.com/getsentry/raven-go v0.2.0 h1:no+xWJRb5ZI7eE8TWgIq1jLulQiIoLG0IfYxv5JYMGs= github.com/getsentry/raven-go v0.2.0/go.mod h1:KungGk8q33+aIAZUIVWZDr2OfAEBsO49PX4NzFV5kcQ= github.com/go-shiori/dom v0.0.0-20210627111528-4e4722cd0d65 h1:zx4B0AiwqKDQq+AgqxWeHwbbLJQeidq20hgfP+aMNWI= @@ -190,6 +192,8 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.12.0 h1:tFM/ta59kqch6LlvYnPa0yx5a83cL2nHflFhYKvv9Yk= golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw= +golang.org/x/image v0.18.0 h1:jGzIakQa/ZXI1I0Fxvaa9W7yP25TqT6cHIHn+6CqvSQ= +golang.org/x/image v0.18.0/go.mod h1:4yyo5vMFQjVjUcVk4jEQcU9MGy/rulF5WvUILseCM2E= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/net v0.0.0-20180218175443-cbe0f9307d01/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -207,8 +211,9 @@ golang.org/x/net v0.14.0/go.mod h1:PpSgVXXLK0OxS0F31C1/tv6XNguvCrnXIDrFMspZIUI= golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M= +golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -238,8 +243,9 @@ golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= -golang.org/x/text v0.12.0 h1:k+n5B8goJNdU7hSvEtMUz3d1Q6D/XW4COJSJR6fN0mc= golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4= +golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= diff --git a/internal/api/download.go b/internal/api/download.go index db72d5597..7e4003a91 100644 --- a/internal/api/download.go +++ b/internal/api/download.go @@ -15,12 +15,20 @@ func download() http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { id := strings.TrimPrefix(r.URL.Path, "/articles/") if id == "" { - utils.WriteJSONProblem(w, downloadProblem, "missing article ID", http.StatusBadRequest) + utils.WriteJSONProblem(w, utils.JSONProblem{ + Title: downloadProblem, + Detail: "missing article ID", + Status: http.StatusBadRequest, + }) return } idArticle := utils.ConvGraphQLID(id) if idArticle == nil { - utils.WriteJSONProblem(w, downloadProblem, "invalid article ID", http.StatusBadRequest) + utils.WriteJSONProblem(w, utils.JSONProblem{ + Title: downloadProblem, + Detail: "invalid article ID", + Status: http.StatusBadRequest, + }) return } // Extract and validate token parameter @@ -33,7 +41,10 @@ func download() http.Handler { // Archive the article asset, err := service.Lookup().DownloadArticle(r.Context(), *idArticle, format) if err != nil { - utils.WriteJSONProblem(w, downloadProblem, err.Error(), http.StatusInternalServerError) + utils.WriteJSONProblem(w, utils.JSONProblem{ + Title: downloadProblem, + Detail: err.Error(), + }) return } diff --git a/internal/api/image-proxy.go b/internal/api/image-proxy.go index 62567c97a..fdef504fe 100644 --- a/internal/api/image-proxy.go +++ b/internal/api/image-proxy.go @@ -24,7 +24,7 @@ func imgProxyHandler(conf *config.Config) http.Handler { if err != nil { logger.Fatal().Err(err).Msg("unable to setup Image Proxy cache") } - down := downloader.NewInternalDownloader(defaults.HTTPClient, defaults.UserAgent, c, 0) + down := downloader.NewInternalDownloader(defaults.HTTPClient, defaults.UserAgent, c, 0, defaults.Timeout) return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { start := time.Now() diff --git a/internal/api/info.go b/internal/api/info.go index 0d987a574..86e2d04c6 100644 --- a/internal/api/info.go +++ b/internal/api/info.go @@ -7,6 +7,7 @@ import ( "github.com/ncarlier/readflow/internal/config" "github.com/ncarlier/readflow/internal/service" "github.com/ncarlier/readflow/internal/version" + "github.com/ncarlier/readflow/pkg/utils" ) // Info API informations model structure. @@ -29,7 +30,9 @@ func info(conf *config.Config) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { data, err := json.Marshal(v) if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) + utils.WriteJSONProblem(w, utils.JSONProblem{ + Detail: err.Error(), + }) return } w.Header().Set("Content-Type", "application/json") diff --git a/internal/auth/methods/basic.go b/internal/auth/methods/basic.go index 4bd2ccf37..37ac7561f 100644 --- a/internal/auth/methods/basic.go +++ b/internal/auth/methods/basic.go @@ -43,7 +43,10 @@ func newBasicAuthMiddlleware(cfg *config.AuthNConfig) (middleware.Middleware, er return } w.Header().Set("WWW-Authenticate", `Basic realm="readflow", charset="UTF-8"`) - utils.WriteJSONProblem(w, "", "invalid credentials", http.StatusUnauthorized) + utils.WriteJSONProblem(w, utils.JSONProblem{ + Detail: "invalid credentials", + Status: http.StatusUnauthorized, + }) }) }, nil } diff --git a/internal/auth/methods/oidc.go b/internal/auth/methods/oidc.go index 77d2bd2d0..01015a0dc 100644 --- a/internal/auth/methods/oidc.go +++ b/internal/auth/methods/oidc.go @@ -38,13 +38,21 @@ func newOIDCAuthMiddleware(cfg *config.AuthNConfig) (middleware.Middleware, erro // retrieve username from access_token username, err := getUsernameFromBearer(r, oidcClient, keyFunc) if err != nil { - utils.WriteJSONProblem(w, "", err.Error(), http.StatusUnauthorized) + utils.WriteJSONProblem(w, utils.JSONProblem{ + Detail: err.Error(), + Status: http.StatusUnauthorized, + Context: map[string]interface{}{ + "redirect": "/login", + }, + }) } // retrieve or register user user, err := service.Lookup().GetOrRegisterUser(ctx, username) if err != nil { - utils.WriteJSONProblem(w, "", err.Error(), http.StatusInternalServerError) + utils.WriteJSONProblem(w, utils.JSONProblem{ + Detail: err.Error(), + }) return } diff --git a/internal/auth/methods/proxy.go b/internal/auth/methods/proxy.go index 1e8c8762d..a3b34704f 100644 --- a/internal/auth/methods/proxy.go +++ b/internal/auth/methods/proxy.go @@ -47,7 +47,10 @@ func newProxyAuthMiddleware(cfg *config.AuthNConfig) (middleware.Middleware, err return } w.Header().Set("Proxy-Authenticate", `Basic realm="readflow"`) - utils.WriteJSONProblem(w, "", "invalid authentication headers", http.StatusUnauthorized) + utils.WriteJSONProblem(w, utils.JSONProblem{ + Detail: "invalid authentication header", + Status: http.StatusUnauthorized, + }) }) }, nil } diff --git a/internal/config/config.go b/internal/config/config.go index 39cc4121e..7efb41ba2 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -6,6 +6,7 @@ import ( "github.com/BurntSushi/toml" "github.com/imdario/mergo" + "github.com/ncarlier/readflow/pkg/defaults" ratelimiter "github.com/ncarlier/readflow/pkg/rate-limiter" "github.com/ncarlier/readflow/pkg/types" ) @@ -50,6 +51,14 @@ func NewConfig() *Config { Value: []byte("pepper"), }, }, + Downloader: DownloaderConfig{ + UserAgent: defaults.UserAgent, + Cache: "boltdb:///tmp/readflow-downloads.cache?maxSize=256,maxEntries=5000,maxEntrySize=1", + MaxConcurentDownloads: 10, + Timeout: types.Duration{ + Duration: defaults.Timeout, + }, + }, Avatar: AvatarConfig{ ServiceProvider: "https://robohash.org/{seed}?set=set4&size=48x48", }, diff --git a/internal/config/defaults.toml b/internal/config/defaults.toml index 2a5e26631..bceb60c26 100644 --- a/internal/config/defaults.toml +++ b/internal/config/defaults.toml @@ -94,6 +94,20 @@ secret_key = "${READFLOW_HASH_SECRET_KEY}" # Default: "706570706572" (aka "pepper") secret_salt = "${READFLOW_HASH_SECRET_SALT}" +[downloader] +## User-Agent used by the internal downloader +# Default: "Mozilla/5.0 (compatible; Readflow/1.0; +https://github.com/ncarlier/readflow)" +user_agent = "${READFLOW_DOWNLOADER_USER_AGENT}" +## Cache paramters +# Default: "boltdb:///tmp/readflow-downloads.cache?maxSize=256,maxEntries=5000,maxEntrySize=5" +cache = "${READFLOW_DOWNLOADER_CACHE}" +## Max concurent downloads +# Default: 10 +#max_concurent_downloads = 10 +## Timeout +# Default: 5s +timeout = "${READFLOW_DOWNLOADER_TIMEOUT}" + [scraping] ## External Web Scraper URL, using internal if empty # Example: "https://example.org/scrap" diff --git a/internal/config/types.go b/internal/config/types.go index ae3bc7d41..848d0adf8 100644 --- a/internal/config/types.go +++ b/internal/config/types.go @@ -15,6 +15,7 @@ type Config struct { AuthN AuthNConfig `toml:"authn"` UI UIConfig `toml:"ui"` Hash HashConfig `toml:"hash"` + Downloader DownloaderConfig `toml:"downloader"` Scraping ScrapingConfig `toml:"scraping"` Avatar AvatarConfig `toml:"avatar"` Image ImageConfig `toml:"image"` @@ -93,6 +94,15 @@ type HashConfig struct { SecretSalt types.HexString `toml:"secret_salt"` } +// DownloaderConfig for downloader configuration +type DownloaderConfig struct { + UserAgent string `toml:"user_agent"` + Cache string `toml:"cache"` + MaxConCache string `toml:"cache"` + MaxConcurentDownloads uint `toml:"max_concurent_downloads"` + Timeout types.Duration `toml:"timeout"` +} + // ScrapingConfig for scraping configuration section type ScrapingConfig struct { ServiceProvider string `toml:"service_provider"` diff --git a/internal/db/article.go b/internal/db/article.go index 98ae93914..a46e528b8 100644 --- a/internal/db/article.go +++ b/internal/db/article.go @@ -18,4 +18,5 @@ type ArticleRepository interface { DeleteArticle(id uint) error DeleteReadArticlesOlderThan(delay time.Duration) (int64, error) DeleteAllReadArticlesByUser(uid uint) (int64, error) + SetArticleThumbHash(id uint, hash string) (*model.Article, error) } diff --git a/internal/db/postgres/article.go b/internal/db/postgres/article.go index dc9511e50..68859881b 100644 --- a/internal/db/postgres/article.go +++ b/internal/db/postgres/article.go @@ -24,6 +24,7 @@ var articleColumns = []string{ "html", "url", "image", + "thumbhash", "hash", "status", "stars", @@ -44,6 +45,7 @@ func mapRowToArticle(row *sql.Row) (*model.Article, error) { &article.HTML, &article.URL, &article.Image, + &article.ThumbHash, &article.Hash, &article.Status, &article.Stars, @@ -69,6 +71,7 @@ func mapRowsToArticle(rows *sql.Rows, article *model.Article) error { &article.HTML, &article.URL, &article.Image, + &article.ThumbHash, &article.Hash, &article.Status, &article.Stars, @@ -253,3 +256,21 @@ func (pg *DB) DeleteAllReadArticlesByUser(uid uint) (int64, error) { } return result.RowsAffected() } + +// SetArticleThumbHash set article thumb hash value +func (pg *DB) SetArticleThumbHash(id uint, hash string) (*model.Article, error) { + update := map[string]interface{}{ + "updated_at": "NOW()", + "thumbhash": hash, + } + query, args, _ := pg.psql.Update( + "articles", + ).SetMap(update).Where( + sq.Eq{"id": id}, + ).Suffix( + "RETURNING " + strings.Join(articleColumns, ","), + ).ToSql() + + row := pg.db.QueryRow(query, args...) + return mapRowToArticle(row) +} diff --git a/internal/db/postgres/migration.go b/internal/db/postgres/migration.go index 31aa99bd8..49877df5f 100644 --- a/internal/db/postgres/migration.go +++ b/internal/db/postgres/migration.go @@ -8,7 +8,7 @@ import ( "github.com/ncarlier/readflow/pkg/logger" ) -const schemaVersion = 14 +const schemaVersion = 15 // Migrate executes database migrations. func Migrate(db *sql.DB) { diff --git a/internal/db/postgres/sql/db_migration_15.sql b/internal/db/postgres/sql/db_migration_15.sql new file mode 100644 index 000000000..6a09098d1 --- /dev/null +++ b/internal/db/postgres/sql/db_migration_15.sql @@ -0,0 +1 @@ +alter table articles add column thumbhash varchar null; diff --git a/internal/model/article.go b/internal/model/article.go index 783124cd8..eba40e50e 100644 --- a/internal/model/article.go +++ b/internal/model/article.go @@ -88,6 +88,7 @@ type Article struct { HTML *string `json:"html,omitempty"` URL *string `json:"url,omitempty"` Image *string `json:"image,omitempty"` + ThumbHash *string `json:"thumbhash,omitempty"` Hash string `json:"hash,omitempty"` Status string `json:"status,omitempty"` Stars uint `json:"stars,omitempty"` diff --git a/internal/schema/article/types.go b/internal/schema/article/types.go index 31736ef51..37ef97f47 100644 --- a/internal/schema/article/types.go +++ b/internal/schema/article/types.go @@ -101,6 +101,9 @@ var articleType = graphql.NewObject( "image": &graphql.Field{ Type: graphql.String, }, + "thumbhash": &graphql.Field{ + Type: graphql.String, + }, "thumbnails": &graphql.Field{ Type: graphql.NewList(thumbnailType), Resolve: thumbnailsResolver, diff --git a/internal/service/event-thumbhash.go b/internal/service/event-thumbhash.go new file mode 100644 index 000000000..f8760ae3a --- /dev/null +++ b/internal/service/event-thumbhash.go @@ -0,0 +1,59 @@ +package service + +import ( + "bytes" + "context" + "errors" + + "github.com/ncarlier/readflow/internal/model" + "github.com/ncarlier/readflow/pkg/event" + "github.com/ncarlier/readflow/pkg/logger" + "github.com/ncarlier/readflow/pkg/thumbhash" +) + +const thumbhashErrorMessage = "unable to create thumbhash" + +func newThumbhashEventHandler(srv *Registry) event.EventHandler { + return func(evt event.Event) { + article, ok := evt.Payload.(model.Article) + if !ok || article.Image == nil || article.ThumbHash != nil { + // Ignore if not a article event + // OR if the article have no image + // OR if the article have already a thumbhash + return + } + + // download aricle image + asset, res, err := srv.dl.Get(context.Background(), *article.Image, nil) + if err != nil { + logger.Info().Err(err).Msg(thumbhashErrorMessage) + return + } + + if res != nil && res.StatusCode != 200 { + err := errors.New("bad status code") + logger.Info().Err(err).Int("status", res.StatusCode).Msg(thumbhashErrorMessage) + return + } + + if asset == nil { + return + } + + // generate thumbhash + r := bytes.NewReader(asset.Data) + hash, err := thumbhash.GetThumbhash(r) + if err != nil { + logger.Info().Err(err).Msg(thumbhashErrorMessage) + return + } + + // save article thumbhash + if _, err := srv.db.SetArticleThumbHash(article.ID, hash); err != nil { + logger.Info().Err(err).Msg(thumbhashErrorMessage) + return + } + + logger.Debug().Uint("id", article.ID).Str("hash", hash).Msg("acrticle thumbash created") + } +} diff --git a/internal/service/event.go b/internal/service/event.go index 311ecbaa2..0995d3966 100644 --- a/internal/service/event.go +++ b/internal/service/event.go @@ -34,6 +34,9 @@ func (reg *Registry) registerEventHandlers() { } reg.events.Subscribe(EventCreateArticle, newCreateArticleMetricEventHandler()) reg.events.Subscribe(EventCreateArticle, newNotificationEventHandler(reg)) + thumbhashEventHandler := newThumbhashEventHandler(reg) + reg.events.Subscribe(EventCreateArticle, thumbhashEventHandler) + reg.events.Subscribe(EventUpdateArticle, thumbhashEventHandler) } func newExternalEventHandler(dis dispatcher.Dispatcher) event.EventHandler { diff --git a/internal/service/registry.go b/internal/service/registry.go index e26ae9f3f..9ba891e7e 100644 --- a/internal/service/registry.go +++ b/internal/service/registry.go @@ -45,7 +45,12 @@ type Registry struct { } // Configure the global service registry -func Configure(conf config.Config, database db.DB, downloadCache cache.Cache) error { +func Configure(conf config.Config, database db.DB) error { + // configure download cache + downloadCache, err := cache.New(conf.Downloader.Cache) + if err != nil { + return err + } webScraper, err := scraper.NewWebScraper(defaults.HTTPClient, defaults.UserAgent, conf.Scraping.ServiceProvider) if err != nil { return err @@ -74,13 +79,21 @@ func Configure(conf config.Config, database db.DB, downloadCache cache.Cache) er db.NewCleanupDatabaseJob(database), ) + dl := downloader.NewInternalDownloader( + defaults.HTTPClient, + conf.Downloader.UserAgent, + downloadCache, + conf.Downloader.MaxConcurentDownloads, + conf.Downloader.Timeout.Duration, + ) + instance = &Registry{ conf: conf, db: database, logger: logger.With().Str("component", "service").Logger(), downloadCache: downloadCache, webScraper: webScraper, - dl: downloader.NewDefaultDownloader(downloadCache), + dl: dl, hashid: hid, notificationRateLimiter: notificationRateLimiter, sanitizer: sanitizer.NewSanitizer(blockList), @@ -100,8 +113,12 @@ func (reg *Registry) GetConfig() config.Config { // Shutdown service internals jobs func Shutdown() { - if instance != nil { - instance.scheduler.Shutdown() + if instance == nil { + return + } + instance.scheduler.Shutdown() + if err := instance.downloadCache.Close(); err != nil { + instance.logger.Error().Err(err).Msg("unable to gracefully shutdown the cache storage") } } diff --git a/internal/service/test/setup.go b/internal/service/test/setup.go index b70c979dd..b70274e6e 100644 --- a/internal/service/test/setup.go +++ b/internal/service/test/setup.go @@ -11,7 +11,6 @@ import ( "github.com/ncarlier/readflow/internal/global" "github.com/ncarlier/readflow/internal/model" "github.com/ncarlier/readflow/internal/service" - "github.com/ncarlier/readflow/pkg/cache" "github.com/ncarlier/readflow/pkg/logger" ) @@ -62,17 +61,16 @@ func SetupTestCase(t *testing.T) func(t *testing.T) { testUser = requireUserExists(t, defaultUsername) testContext = context.Background() testContext = context.WithValue(testContext, global.ContextUserID, *testUser.ID) - downloadCache, _ := cache.NewDefault("readflow-tests") - service.Configure(*conf, testDB, downloadCache) + service.Configure(*conf, testDB) if err != nil { t.Fatalf("unable to setup service registry: %v", err) } return func(t *testing.T) { t.Log("teardown test case") + defer service.Shutdown() defer testDB.Close() - defer downloadCache.Close() testDB.DeleteUser(*testUser) } } diff --git a/internal/service/test/test.toml b/internal/service/test/test.toml index f00f8dbdf..2f5a3caee 100644 --- a/internal/service/test/test.toml +++ b/internal/service/test/test.toml @@ -3,6 +3,9 @@ [database] uri = "${READFLOW_DB}" +[downloader] +cache = "boltdb:///tmp/readflow-tests.cache?maxSize=100,maxEntries=100,maxEntrySize=5" + ## User Plans [[user_plans]] name = "test" diff --git a/pkg/downloader/internal.go b/pkg/downloader/internal.go index f6c94ee49..edcaa69e7 100644 --- a/pkg/downloader/internal.go +++ b/pkg/downloader/internal.go @@ -8,10 +8,12 @@ import ( "net/http" nurl "net/url" "strings" + "time" "golang.org/x/sync/semaphore" "github.com/ncarlier/readflow/pkg/cache" + "github.com/ncarlier/readflow/pkg/defaults" "github.com/ncarlier/readflow/pkg/utils" ) @@ -24,23 +26,28 @@ var errInvalidURL = errors.New("invalid URL") // InternalDownloader interface type InternalDownloader struct { cache cache.Cache - maxConcurrentDownload int64 + maxConcurrentDownload uint + timeout time.Duration httpClient *http.Client userAgent string dlSemaphore *semaphore.Weighted } // NewInternalDownloader create new downloader instance -func NewInternalDownloader(client *http.Client, userAgent string, downloadCache cache.Cache, maxConcurrentDownload int64) Downloader { - if maxConcurrentDownload <= 0 { +func NewInternalDownloader(client *http.Client, userAgent string, downloadCache cache.Cache, maxConcurrentDownload uint, timeout time.Duration) Downloader { + if maxConcurrentDownload == 0 { maxConcurrentDownload = defaultMaxConcurentDownload } + if timeout == 0 { + timeout = defaults.Timeout + } return &InternalDownloader{ cache: downloadCache, httpClient: client, userAgent: userAgent, maxConcurrentDownload: maxConcurrentDownload, - dlSemaphore: semaphore.NewWeighted(maxConcurrentDownload), + timeout: timeout, + dlSemaphore: semaphore.NewWeighted(int64(maxConcurrentDownload)), } } @@ -65,14 +72,22 @@ func (dl *InternalDownloader) Get(ctx context.Context, url string, header *http. return asset, nil, err } + if _, ok := ctx.Deadline(); !ok { + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(ctx, dl.timeout) + defer cancel() + } + // Download the asset, use semaphore to limit concurrent downloads err = dl.dlSemaphore.Acquire(ctx, 1) if err != nil { + err = fmt.Errorf("unable to acquire download slot (%s)", err) return nil, nil, err } - resp, err := dl.get(url, header) + resp, err := dl.get(ctx, url, header) dl.dlSemaphore.Release(1) if err != nil { + err = fmt.Errorf("unable to download Web asset: %w", err) return nil, nil, err } defer resp.Body.Close() @@ -111,8 +126,8 @@ func (dl *InternalDownloader) Get(ctx context.Context, url string, header *http. return asset, resp, nil } -func (dl *InternalDownloader) get(url string, header *http.Header) (*http.Response, error) { - req, err := http.NewRequest("GET", url, http.NoBody) +func (dl *InternalDownloader) get(ctx context.Context, url string, header *http.Header) (*http.Response, error) { + req, err := http.NewRequestWithContext(ctx, "GET", url, http.NoBody) if err != nil { return nil, err } diff --git a/pkg/downloader/types.go b/pkg/downloader/types.go index 5ba0c8936..4f9e80d4f 100644 --- a/pkg/downloader/types.go +++ b/pkg/downloader/types.go @@ -15,5 +15,5 @@ type Downloader interface { // NewDefaultDownloader create new downloader with defaults func NewDefaultDownloader(downloadCache cache.Cache) Downloader { - return NewInternalDownloader(defaults.HTTPClient, defaults.UserAgent, downloadCache, defaultMaxConcurentDownload) + return NewInternalDownloader(defaults.HTTPClient, defaults.UserAgent, downloadCache, defaultMaxConcurentDownload, defaults.Timeout) } diff --git a/pkg/oidc/client.go b/pkg/oidc/client.go index dde7ba10e..b5b59e6ad 100644 --- a/pkg/oidc/client.go +++ b/pkg/oidc/client.go @@ -104,6 +104,20 @@ func (c *Client) UserInfo(token string) (*UserInfoResponse, error) { return payload, nil } +// GetAuthorizationEndpoint return authorization endpoint +func (c *Client) GetAuthorizationEndpoint(redirectURI string) *url.URL { + u, _ := url.Parse(c.Config.AuthorizationEndpoint) + q := u.Query() + q.Set("response_type", "code") + q.Set("scope", "openid") + //q.Set("state", "TODO") + q.Set("client_id", c.client_id) + if redirectURI != "" { + q.Set("redirect_uri", redirectURI) + } + return u +} + func decodeErrorResponse(resp *http.Response) error { payload := &ErrorResponse{} if err := json.NewDecoder(resp.Body).Decode(payload); err != nil { diff --git a/pkg/thumbhash/thumbhash.go b/pkg/thumbhash/thumbhash.go new file mode 100644 index 000000000..475eb5737 --- /dev/null +++ b/pkg/thumbhash/thumbhash.go @@ -0,0 +1,28 @@ +package thumbhash + +import ( + "encoding/base64" + "fmt" + "image" + _ "image/jpeg" + _ "image/png" + "io" + + _ "golang.org/x/image/webp" + + "github.com/galdor/go-thumbhash" +) + +// GetThumbhash get thumbhash from image +func GetThumbhash(r io.Reader) (string, error) { + img, _, err := image.Decode(r) + if err != nil { + return "", err + } + width := img.Bounds().Size().X + + binHash := thumbhash.EncodeImage(img) + hash := base64.StdEncoding.EncodeToString(binHash) + + return fmt.Sprintf("%d|%s", width, hash), nil +} diff --git a/pkg/types/duration.go b/pkg/types/duration.go index a6b7272e5..ed238cc0e 100644 --- a/pkg/types/duration.go +++ b/pkg/types/duration.go @@ -9,7 +9,11 @@ type Duration struct { } func (d *Duration) UnmarshalText(text []byte) error { + val := string(text) + if val == "" { + val = "0s" + } var err error - d.Duration, err = time.ParseDuration(string(text)) + d.Duration, err = time.ParseDuration(val) return err } diff --git a/pkg/utils/json-problem.go b/pkg/utils/json-problem.go index 082df02fd..a131f1ab1 100644 --- a/pkg/utils/json-problem.go +++ b/pkg/utils/json-problem.go @@ -5,24 +5,24 @@ import ( "net/http" ) -type problemObject struct { - Title string `json:"title"` - Detail string `json:"detail"` - Status int `json:"status"` +type JSONProblem struct { + Title string `json:"title"` + Detail string `json:"detail"` + Status int `json:"status"` + Context map[string]interface{} `json:"context"` } // WriteJSONProblem write error as JSON Problem Details format -func WriteJSONProblem(w http.ResponseWriter, title, detail string, status int) { - if title == "" { - title = http.StatusText(status) +func WriteJSONProblem(w http.ResponseWriter, problem JSONProblem) { + if problem.Status == 0 { + problem.Status = http.StatusInternalServerError } - err := problemObject{ - Title: title, - Detail: detail, - Status: status, + + if problem.Title == "" { + problem.Title = http.StatusText(problem.Status) } w.Header().Set("Content-Type", "application/problem+json; charset=utf-8") w.Header().Set("X-Content-Type-Options", "nosniff") - w.WriteHeader(status) - json.NewEncoder(w).Encode(err) + w.WriteHeader(problem.Status) + json.NewEncoder(w).Encode(problem) } diff --git a/ui/src/articles/components/ArticleCard.module.css b/ui/src/articles/components/ArticleCard.module.css index 51e34eab4..3b8f396e5 100644 --- a/ui/src/articles/components/ArticleCard.module.css +++ b/ui/src/articles/components/ArticleCard.module.css @@ -83,7 +83,7 @@ height: fit-content; } -.illustration > img { +.illustration img { align-self: center; max-width: 320px; mix-blend-mode: darken; @@ -95,7 +95,7 @@ border-radius: 1em; overflow: clip; } - .illustration > img { + .illustration img { max-width: 100%; } } diff --git a/ui/src/articles/components/ArticleImage.tsx b/ui/src/articles/components/ArticleImage.tsx index 9bac46b42..6ee801e29 100644 --- a/ui/src/articles/components/ArticleImage.tsx +++ b/ui/src/articles/components/ArticleImage.tsx @@ -1,7 +1,8 @@ -import React, { FC, ImgHTMLAttributes } from 'react' +import React, { FC, ImgHTMLAttributes, useEffect, useState } from 'react' import { Article, ArticleThumbnail } from '../models' import { getAPIURL } from '../../helpers' +import { LazyImage } from '../../components' const getThumbnailURL = (thumbnail: ArticleThumbnail, src: string) => `${getAPIURL()}/img/${thumbnail.hash}/resize:fit:${thumbnail.size}/${btoa(src)}` @@ -23,20 +24,23 @@ interface Props { } export const ArticleImage: FC = ({ article }) => { - let attrs :ImgHTMLAttributes = {} - if (article.image && article.image.match(/^https?:\/\//)) { - try { - attrs = getThumbnailAttributes(article) - } catch (err) { - console.error('unable to get article thumbnail attributes', article, err) + const [attrs, setAttrs] = useState>({}) + useEffect(() => { + if (article.image && article.image.match(/^https?:\/\//)) { + try { + setAttrs(getThumbnailAttributes(article)) + } catch (err) { + console.error('unable to get article thumbnail attributes', article, err) + } } - } + }, [article]) + return ( - (e.currentTarget.style.display = 'none')} - crossOrigin='anonymous' + // crossOrigin='anonymous' /> ) } diff --git a/ui/src/articles/models.ts b/ui/src/articles/models.ts index d1f2b5cfb..0200e8807 100644 --- a/ui/src/articles/models.ts +++ b/ui/src/articles/models.ts @@ -6,6 +6,7 @@ export interface Article { html: string text: string image: string + thumbhash: string thumbnails?: ArticleThumbnail[] url: string status: ArticleStatus diff --git a/ui/src/articles/queries.ts b/ui/src/articles/queries.ts index d27f71d4d..1e2a291b6 100644 --- a/ui/src/articles/queries.ts +++ b/ui/src/articles/queries.ts @@ -30,6 +30,7 @@ export const GetArticles = gql` text url image + thumbhash thumbnails { size hash @@ -74,6 +75,7 @@ export const GetFullArticle = gql` html url image + thumbhash thumbnails { size hash diff --git a/ui/src/components/LazyImage.module.css b/ui/src/components/LazyImage.module.css index 5c75b03a3..9b0bbdf3a 100644 --- a/ui/src/components/LazyImage.module.css +++ b/ui/src/components/LazyImage.module.css @@ -2,20 +2,38 @@ display: grid; overflow: hidden; } -.lqip { - filter: blur(5px); - width: -moz-available; - width: -webkit-fill-available; - width: stretch; -} .wrapper > img { grid-area: 1 / 1 / 2 / 2; } +.lqip { + opacity: 1; +} +.lqip.loaded { + animation: fade-out .5s both; +} + .source { opacity: 0; - transition: opacity 1s; - z-index: 99; + z-index: 9; } -.loaded { - opacity: 1; +.source.loaded { + animation: fade-in .5s both; +} + +@keyframes fade-in { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +@keyframes fade-out { + from { + opacity: 1; + } + to { + opacity: 0; + } } diff --git a/ui/src/components/LazyImage.tsx b/ui/src/components/LazyImage.tsx index 31d9bbf18..dbe73b659 100644 --- a/ui/src/components/LazyImage.tsx +++ b/ui/src/components/LazyImage.tsx @@ -1,40 +1,63 @@ import React, { useState, useRef, useEffect, ImgHTMLAttributes, FC } from 'react' -import { useMedia } from '../hooks' -import { classNames, getAPIURL } from '../helpers' +import { classNames, thumbHashToDataURL } from '../helpers' import styles from './LazyImage.module.css' -const proxifyImageURL = (url: string, width: number) => getAPIURL(`/img?url=${encodeURIComponent(url)}&width=${width}`) +const base64ToBinary = (b64: string) => new Uint8Array(window.atob(b64).split('').map(x => x.charCodeAt(0))) -export const LazyImage: FC> = ({ src, ...attrs }) => { - const mobileDisplay = useMedia('(max-width: 767px)') +interface Props { + thumbhash: string +} + +const hideFn = (ev: React.SyntheticEvent) => {ev.currentTarget.style.display = 'none'} + +export const LazyImage: FC & Props> = ({thumbhash, ...attrs }) => { const [loaded, setLoaded] = useState(false) + const [data, setData] = useState('') + const [width, setWidth] = useState('0px') const imgRef = useRef(null) + const lqipRef = useRef(null) useEffect(() => { if (imgRef.current && imgRef.current.complete) { setLoaded(true) } }, []) - if (!src) { - return + useEffect(() => { + if (!thumbhash) { + return + } + const [width, hash] = thumbhash.split('|') + setWidth(`${width}px`) + try { + setData(thumbHashToDataURL(base64ToBinary(hash))) + } catch (err) { + console.error('unable to decode thumbhash', err) + } + }, [thumbhash]) + + if (!thumbhash) { + return } return (
- + setLoaded(true)} - onError={(e) => (e.currentTarget.style.display = 'none')} + onError={hideFn} />
) diff --git a/ui/src/helpers/index.ts b/ui/src/helpers/index.ts index 16fa22e05..beac58784 100644 --- a/ui/src/helpers/index.ts +++ b/ui/src/helpers/index.ts @@ -11,4 +11,5 @@ export * from './matchResponse' export * from './matchState' export * from './notification' export * from './regexp' +export * from './thumbhash' export * from './time' diff --git a/ui/src/helpers/thumbhash.js b/ui/src/helpers/thumbhash.js new file mode 100644 index 000000000..3d326e32b --- /dev/null +++ b/ui/src/helpers/thumbhash.js @@ -0,0 +1,208 @@ +/** + * Decodes a ThumbHash to an RGBA image. RGB is not be premultiplied by A. + * + * @param hash The bytes of the ThumbHash. + * @returns The width, height, and pixels of the rendered placeholder image. + */ +export function thumbHashToRGBA(hash) { + let { PI, min, max, cos, round } = Math + + // Read the constants + let header24 = hash[0] | (hash[1] << 8) | (hash[2] << 16) + let header16 = hash[3] | (hash[4] << 8) + let l_dc = (header24 & 63) / 63 + let p_dc = ((header24 >> 6) & 63) / 31.5 - 1 + let q_dc = ((header24 >> 12) & 63) / 31.5 - 1 + let l_scale = ((header24 >> 18) & 31) / 31 + let hasAlpha = header24 >> 23 + let p_scale = ((header16 >> 3) & 63) / 63 + let q_scale = ((header16 >> 9) & 63) / 63 + let isLandscape = header16 >> 15 + let lx = max(3, isLandscape ? (hasAlpha ? 5 : 7) : header16 & 7) + let ly = max(3, isLandscape ? header16 & 7 : hasAlpha ? 5 : 7) + let a_dc = hasAlpha ? (hash[5] & 15) / 15 : 1 + let a_scale = (hash[5] >> 4) / 15 + + // Read the varying factors (boost saturation by 1.25x to compensate for quantization) + let ac_start = hasAlpha ? 6 : 5 + let ac_index = 0 + let decodeChannel = (nx, ny, scale) => { + let ac = [] + for (let cy = 0; cy < ny; cy++) + for (let cx = cy ? 0 : 1; cx * ny < nx * (ny - cy); cx++) + ac.push((((hash[ac_start + (ac_index >> 1)] >> ((ac_index++ & 1) << 2)) & 15) / 7.5 - 1) * scale) + return ac + } + let l_ac = decodeChannel(lx, ly, l_scale) + let p_ac = decodeChannel(3, 3, p_scale * 1.25) + let q_ac = decodeChannel(3, 3, q_scale * 1.25) + let a_ac = hasAlpha && decodeChannel(5, 5, a_scale) + + // Decode using the DCT into RGB + let ratio = thumbHashToApproximateAspectRatio(hash) + let w = round(ratio > 1 ? 32 : 32 * ratio) + let h = round(ratio > 1 ? 32 / ratio : 32) + let rgba = new Uint8Array(w * h * 4), + fx = [], + fy = [] + for (let y = 0, i = 0; y < h; y++) { + for (let x = 0; x < w; x++, i += 4) { + let l = l_dc, + p = p_dc, + q = q_dc, + a = a_dc + + // Precompute the coefficients + for (let cx = 0, n = max(lx, hasAlpha ? 5 : 3); cx < n; cx++) fx[cx] = cos((PI / w) * (x + 0.5) * cx) + for (let cy = 0, n = max(ly, hasAlpha ? 5 : 3); cy < n; cy++) fy[cy] = cos((PI / h) * (y + 0.5) * cy) + + // Decode L + for (let cy = 0, j = 0; cy < ly; cy++) + for (let cx = cy ? 0 : 1, fy2 = fy[cy] * 2; cx * ly < lx * (ly - cy); cx++, j++) l += l_ac[j] * fx[cx] * fy2 + + // Decode P and Q + for (let cy = 0, j = 0; cy < 3; cy++) { + for (let cx = cy ? 0 : 1, fy2 = fy[cy] * 2; cx < 3 - cy; cx++, j++) { + let f = fx[cx] * fy2 + p += p_ac[j] * f + q += q_ac[j] * f + } + } + + // Decode A + if (hasAlpha) + for (let cy = 0, j = 0; cy < 5; cy++) + for (let cx = cy ? 0 : 1, fy2 = fy[cy] * 2; cx < 5 - cy; cx++, j++) a += a_ac[j] * fx[cx] * fy2 + + // Convert to RGB + let b = l - (2 / 3) * p + let r = (3 * l - b + q) / 2 + let g = r - q + rgba[i] = max(0, 255 * min(1, r)) + rgba[i + 1] = max(0, 255 * min(1, g)) + rgba[i + 2] = max(0, 255 * min(1, b)) + rgba[i + 3] = max(0, 255 * min(1, a)) + } + } + return { w, h, rgba } +} + +/** + * Extracts the approximate aspect ratio of the original image. + * + * @param hash The bytes of the ThumbHash. + * @returns The approximate aspect ratio (i.e. width / height). + */ +export function thumbHashToApproximateAspectRatio(hash) { + let header = hash[3] + let hasAlpha = hash[2] & 0x80 + let isLandscape = hash[4] & 0x80 + let lx = isLandscape ? (hasAlpha ? 5 : 7) : header & 7 + let ly = isLandscape ? header & 7 : hasAlpha ? 5 : 7 + return lx / ly +} + +/** + * Encodes an RGBA image to a PNG data URL. RGB should not be premultiplied by + * A. This is optimized for speed and simplicity and does not optimize for size + * at all. This doesn't do any compression (all values are stored uncompressed). + * + * @param w The width of the input image. Must be ≤100px. + * @param h The height of the input image. Must be ≤100px. + * @param rgba The pixels in the input image, row-by-row. Must have w*h*4 elements. + * @returns A data URL containing a PNG for the input image. + */ +export function rgbaToDataURL(w, h, rgba) { + let row = w * 4 + 1 + let idat = 6 + h * (5 + row) + let bytes = [ + 137, + 80, + 78, + 71, + 13, + 10, + 26, + 10, + 0, + 0, + 0, + 13, + 73, + 72, + 68, + 82, + 0, + 0, + w >> 8, + w & 255, + 0, + 0, + h >> 8, + h & 255, + 8, + 6, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + idat >>> 24, + (idat >> 16) & 255, + (idat >> 8) & 255, + idat & 255, + 73, + 68, + 65, + 84, + 120, + 1, + ] + let table = [ + 0, 498536548, 997073096, 651767980, 1994146192, 1802195444, 1303535960, 1342533948, -306674912, -267414716, + -690576408, -882789492, -1687895376, -2032938284, -1609899400, -1111625188, + ] + let a = 1, + b = 0 + for (let y = 0, i = 0, end = row - 1; y < h; y++, end += row - 1) { + bytes.push(y + 1 < h ? 0 : 1, row & 255, row >> 8, ~row & 255, (row >> 8) ^ 255, 0) + for (b = (b + a) % 65521; i < end; i++) { + let u = rgba[i] & 255 + bytes.push(u) + a = (a + u) % 65521 + b = (b + a) % 65521 + } + } + bytes.push(b >> 8, b & 255, a >> 8, a & 255, 0, 0, 0, 0, 0, 0, 0, 0, 73, 69, 78, 68, 174, 66, 96, 130) + for (let [start, end] of [ + [12, 29], + [37, 41 + idat], + ]) { + let c = ~0 + for (let i = start; i < end; i++) { + c ^= bytes[i] + c = (c >>> 4) ^ table[c & 15] + c = (c >>> 4) ^ table[c & 15] + } + c = ~c + bytes[end++] = c >>> 24 + bytes[end++] = (c >> 16) & 255 + bytes[end++] = (c >> 8) & 255 + bytes[end++] = c & 255 + } + return 'data:image/png;base64,' + window.btoa(String.fromCharCode(...bytes)) +} + +/** + * Decodes a ThumbHash to a PNG data URL. This is a convenience function that + * just calls "thumbHashToRGBA" followed by "rgbaToDataURL". + * + * @param hash The bytes of the ThumbHash. + * @returns A data URL containing a PNG for the rendered ThumbHash. + */ +export function thumbHashToDataURL(hash) { + let image = thumbHashToRGBA(hash) + return rgbaToDataURL(image.w, image.h, image.rgba) +}