From 3dec3895d500eaac547da849c2653015fd277975 Mon Sep 17 00:00:00 2001 From: hampus-fluxer Date: Sat, 3 Jan 2026 15:43:36 +0100 Subject: [PATCH] feat(marketing): add product hunt badge (#10) --- fluxer_marketing/src/fluxer_marketing.gleam | 21 +- .../src/fluxer_marketing/badge_proxy.gleam | 215 ++++++++++++++++++ .../components/hackernews_banner.gleam | 39 ---- .../fluxer_marketing/components/hero.gleam | 23 +- .../middleware/cache_middleware.gleam | 21 +- .../src/fluxer_marketing/router.gleam | 5 + .../src/fluxer_marketing/web.gleam | 2 + 7 files changed, 271 insertions(+), 55 deletions(-) create mode 100644 fluxer_marketing/src/fluxer_marketing/badge_proxy.gleam diff --git a/fluxer_marketing/src/fluxer_marketing.gleam b/fluxer_marketing/src/fluxer_marketing.gleam index a61466b2..da12f56b 100644 --- a/fluxer_marketing/src/fluxer_marketing.gleam +++ b/fluxer_marketing/src/fluxer_marketing.gleam @@ -15,6 +15,7 @@ //// You should have received a copy of the GNU Affero General Public License //// along with Fluxer. If not, see . +import fluxer_marketing/badge_proxy import fluxer_marketing/config import fluxer_marketing/geoip import fluxer_marketing/i18n @@ -24,7 +25,6 @@ import fluxer_marketing/middleware/cache_middleware import fluxer_marketing/router import fluxer_marketing/visionary_slots import fluxer_marketing/web -import gleam/erlang/atom.{type Atom} import gleam/erlang/process import gleam/http/request import gleam/list @@ -40,15 +40,18 @@ pub fn main() { let assert Ok(cfg) = config.load_config() let i18n_db = i18n.setup_database() + let slots_cache = visionary_slots.start(visionary_slots.Settings( api_host: cfg.api_host, rpc_secret: cfg.gateway_rpc_secret, )) + let badge_cache = badge_proxy.start_cache() + let assert Ok(_) = wisp_mist.handler( - handle_request(_, i18n_db, cfg, slots_cache), + handle_request(_, i18n_db, cfg, slots_cache, badge_cache), cfg.secret_key_base, ) |> mist.new @@ -64,11 +67,11 @@ fn handle_request( i18n_db, cfg: config.Config, slots_cache: visionary_slots.Cache, + badge_cache: badge_proxy.Cache, ) -> wisp.Response { let locale = get_request_locale(req) let base_url = cfg.marketing_endpoint <> cfg.base_path - let country_code = geoip.country_code(req, cfg.geoip_host) let user_agent = case request.get_header(req, "user-agent") { @@ -97,6 +100,7 @@ fn handle_request( release_channel: cfg.release_channel, visionary_slots: visionary_slots.current(slots_cache), metrics_endpoint: cfg.metrics_endpoint, + badge_cache: badge_cache, ) use <- wisp.log_request(req) @@ -116,18 +120,21 @@ fn handle_request( } let duration = monotonic_milliseconds() - start - metrics.track_request(ctx, req, response.status, duration) response |> cache_middleware.add_cache_headers } -fn monotonic_milliseconds() -> Int { - do_monotonic_time(atom.create("millisecond")) +type TimeUnit { + Millisecond } @external(erlang, "erlang", "monotonic_time") -fn do_monotonic_time(unit: Atom) -> Int +fn erlang_monotonic_time(unit: TimeUnit) -> Int + +fn monotonic_milliseconds() -> Int { + erlang_monotonic_time(Millisecond) +} fn get_request_locale(req: wisp.Request) -> Locale { case wisp.get_cookie(req, "locale", wisp.PlainText) { diff --git a/fluxer_marketing/src/fluxer_marketing/badge_proxy.gleam b/fluxer_marketing/src/fluxer_marketing/badge_proxy.gleam new file mode 100644 index 00000000..5b64c983 --- /dev/null +++ b/fluxer_marketing/src/fluxer_marketing/badge_proxy.gleam @@ -0,0 +1,215 @@ +//// Copyright (C) 2026 Fluxer Contributors +//// +//// This file is part of Fluxer. +//// +//// Fluxer is free software: you can redistribute it and/or modify +//// it under the terms of the GNU Affero General Public License as published by +//// the Free Software Foundation, either version 3 of the License, or +//// (at your option) any later version. +//// +//// Fluxer is distributed in the hope that it will be useful, +//// but WITHOUT ANY WARRANTY; without even the implied warranty of +//// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +//// GNU Affero General Public License for more details. +//// +//// You should have received a copy of the GNU Affero General Public License +//// along with Fluxer. If not, see . + +import gleam/erlang/process +import gleam/http/request +import gleam/httpc +import gleam/option.{type Option} +import wisp.{type Response} + +const product_hunt_url = "https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=1057558&theme=light" + +const stale_after_ms = 300_000 + +const receive_timeout_ms = 5000 + +const fetch_timeout_ms = 4500 + +pub opaque type Cache { + Cache(subject: process.Subject(ServerMessage)) +} + +type ServerMessage { + Get(process.Subject(Option(String))) + RefreshDone(fetched_at: Int, svg: Option(String)) +} + +type CacheEntry { + CacheEntry(svg: String, fetched_at: Int) +} + +type State { + State(cache: Option(CacheEntry), is_refreshing: Bool) +} + +pub fn start_cache() -> Cache { + let started = process.new_subject() + let _ = process.spawn_unlinked(fn() { run(started) }) + + let assert Ok(subject) = process.receive(started, within: 1000) + Cache(subject: subject) +} + +fn run(started: process.Subject(process.Subject(ServerMessage))) { + let subject = process.new_subject() + process.send(started, subject) + + let initial = State(cache: option.None, is_refreshing: False) + loop(subject, initial) +} + +fn loop(subject: process.Subject(ServerMessage), state: State) { + let new_state = case process.receive(subject, within: stale_after_ms) { + Ok(Get(reply_to)) -> handle_get(subject, reply_to, state) + + Ok(RefreshDone(fetched_at, svg)) -> + handle_refresh_done(fetched_at, svg, state) + + Error(_) -> maybe_refresh_in_background(subject, state) + } + + loop(subject, new_state) +} + +fn handle_get( + subject: process.Subject(ServerMessage), + reply_to: process.Subject(Option(String)), + state: State, +) -> State { + let now = monotonic_time_ms() + + case state.cache { + option.None -> { + let svg = fetch_badge_svg() + process.send(reply_to, svg) + + let new_cache = case svg { + option.Some(content) -> + option.Some(CacheEntry(svg: content, fetched_at: now)) + option.None -> option.None + } + + State(cache: new_cache, is_refreshing: False) + } + + option.Some(entry) -> { + let is_stale = now - entry.fetched_at > stale_after_ms + + process.send(reply_to, option.Some(entry.svg)) + + case is_stale && !state.is_refreshing { + True -> { + spawn_refresh(subject) + State(..state, is_refreshing: True) + } + False -> state + } + } + } +} + +fn handle_refresh_done( + fetched_at: Int, + svg: Option(String), + state: State, +) -> State { + let new_cache = case svg { + option.Some(content) -> + option.Some(CacheEntry(svg: content, fetched_at: fetched_at)) + option.None -> state.cache + } + + State(cache: new_cache, is_refreshing: False) +} + +fn maybe_refresh_in_background( + subject: process.Subject(ServerMessage), + state: State, +) -> State { + let now = monotonic_time_ms() + + case state.cache, state.is_refreshing { + option.Some(entry), False if now - entry.fetched_at > stale_after_ms -> { + spawn_refresh(subject) + State(..state, is_refreshing: True) + } + + _, _ -> state + } +} + +fn spawn_refresh(subject: process.Subject(ServerMessage)) { + let _ = + process.spawn_unlinked(fn() { + let fetched_at = monotonic_time_ms() + let svg = fetch_badge_svg() + process.send(subject, RefreshDone(fetched_at, svg)) + }) + + Nil +} + +pub fn get_badge(cache: Cache) -> Option(String) { + let reply_to = process.new_subject() + process.send(cache.subject, Get(reply_to)) + + case process.receive(reply_to, within: receive_timeout_ms) { + Ok(svg) -> svg + Error(_) -> option.None + } +} + +pub fn product_hunt(cache: Cache) -> Response { + case get_badge(cache) { + option.Some(content) -> { + wisp.response(200) + |> wisp.set_header("content-type", "image/svg+xml") + |> wisp.set_header( + "cache-control", + "public, max-age=300, stale-while-revalidate=600", + ) + |> wisp.set_header("vary", "Accept") + |> wisp.string_body(content) + } + + option.None -> { + wisp.response(503) + |> wisp.set_header("content-type", "text/plain") + |> wisp.set_header("retry-after", "60") + |> wisp.string_body("Badge temporarily unavailable") + } + } +} + +fn fetch_badge_svg() -> Option(String) { + let assert Ok(req0) = request.to(product_hunt_url) + let req = + req0 + |> request.prepend_header("accept", "image/svg+xml") + |> request.prepend_header("user-agent", "FluxerMarketing/1.0") + + let config = + httpc.configure() + |> httpc.timeout(fetch_timeout_ms) + + case httpc.dispatch(config, req) { + Ok(resp) if resp.status >= 200 && resp.status < 300 -> + option.Some(resp.body) + _ -> option.None + } +} + +type TimeUnit { + Millisecond +} + +@external(erlang, "erlang", "monotonic_time") +fn erlang_monotonic_time(unit: TimeUnit) -> Int + +fn monotonic_time_ms() -> Int { + erlang_monotonic_time(Millisecond) +} diff --git a/fluxer_marketing/src/fluxer_marketing/components/hackernews_banner.gleam b/fluxer_marketing/src/fluxer_marketing/components/hackernews_banner.gleam index 5fc226fc..3ee18ec2 100644 --- a/fluxer_marketing/src/fluxer_marketing/components/hackernews_banner.gleam +++ b/fluxer_marketing/src/fluxer_marketing/components/hackernews_banner.gleam @@ -22,40 +22,6 @@ import lustre/attribute import lustre/element.{type Element} import lustre/element/html -fn hn_logo() -> Element(a) { - element.element( - "svg", - [ - attribute.attribute("xmlns", "http://www.w3.org/2000/svg"), - attribute.attribute("viewBox", "4 4 188 188"), - attribute.attribute("width", "20"), - attribute.attribute("height", "20"), - attribute.class("block flex-shrink-0 rounded-[3px]"), - ], - [ - element.element( - "path", - [ - attribute.attribute("d", "m4 4h188v188h-188z"), - attribute.attribute("fill", "#f60"), - ], - [], - ), - element.element( - "path", - [ - attribute.attribute( - "d", - "m73.2521756 45.01 22.7478244 47.39130083 22.7478244-47.39130083h19.56569631l-34.32352071 64.48661468v41.49338532h-15.98v-41.49338532l-34.32352071-64.48661468z", - ), - attribute.attribute("fill", "#fff"), - ], - [], - ), - ], - ) -} - pub fn render(ctx: Context) -> Element(a) { let i18n_ctx = i18n.get_context(ctx.i18n_db, ctx.locale) @@ -66,12 +32,7 @@ pub fn render(ctx: Context) -> Element(a) { ), ], [ - hn_logo(), html.p([attribute.class("text-sm md:text-base text-white/90")], [ - html.span([attribute.class("font-medium")], [ - html.text(g_(i18n_ctx, "From HN?")), - html.text(" "), - ]), html.text(g_(i18n_ctx, "Try it without an email at")), html.text(" "), html.a( diff --git a/fluxer_marketing/src/fluxer_marketing/components/hero.gleam b/fluxer_marketing/src/fluxer_marketing/components/hero.gleam index 31e5d7f2..2f3a42f2 100644 --- a/fluxer_marketing/src/fluxer_marketing/components/hero.gleam +++ b/fluxer_marketing/src/fluxer_marketing/components/hero.gleam @@ -19,7 +19,7 @@ import fluxer_marketing/components/hackernews_banner import fluxer_marketing/components/platform_download_button import fluxer_marketing/i18n import fluxer_marketing/locale -import fluxer_marketing/web.{type Context} +import fluxer_marketing/web.{type Context, prepend_base_path} import kielet.{gettext as g_} import lustre/attribute import lustre/element.{type Element} @@ -84,6 +84,27 @@ pub fn render(ctx: Context) -> Element(a) { ], ), hackernews_banner.render(ctx), + html.div([attribute.class("mt-6 flex justify-center")], [ + html.a( + [ + attribute.href( + "https://www.producthunt.com/products/fluxer?embed=true&utm_source=badge-featured&utm_medium=badge&utm_campaign=badge-fluxer", + ), + attribute.target("_blank"), + attribute.attribute("rel", "noopener noreferrer"), + ], + [ + html.img([ + attribute.alt( + "Fluxer - Open-source Discord-like instant messaging & VoIP platform | Product Hunt", + ), + attribute.attribute("width", "250"), + attribute.attribute("height", "54"), + attribute.src(prepend_base_path(ctx, "/api/badges/product-hunt")), + ]), + ], + ), + ]), ]), html.div( [ diff --git a/fluxer_marketing/src/fluxer_marketing/middleware/cache_middleware.gleam b/fluxer_marketing/src/fluxer_marketing/middleware/cache_middleware.gleam index a6c80d69..cafc2952 100644 --- a/fluxer_marketing/src/fluxer_marketing/middleware/cache_middleware.gleam +++ b/fluxer_marketing/src/fluxer_marketing/middleware/cache_middleware.gleam @@ -22,16 +22,21 @@ import gleam/string import wisp pub fn add_cache_headers(res: Response(wisp.Body)) -> Response(wisp.Body) { - let content_type = - list.key_find(res.headers, "content-type") - |> result.unwrap("") + case list.key_find(res.headers, "cache-control") { + Ok(_) -> res + Error(_) -> { + let content_type = + list.key_find(res.headers, "content-type") + |> result.unwrap("") - let cache_header = case should_cache(content_type) { - True -> "public, max-age=31536000, immutable" - False -> "no-cache" + let cache_header = case should_cache(content_type) { + True -> "public, max-age=31536000, immutable" + False -> "no-cache" + } + + response.set_header(res, "cache-control", cache_header) + } } - - response.set_header(res, "cache-control", cache_header) } fn should_cache(content_type: String) -> Bool { diff --git a/fluxer_marketing/src/fluxer_marketing/router.gleam b/fluxer_marketing/src/fluxer_marketing/router.gleam index 6546e0f6..c45bf050 100644 --- a/fluxer_marketing/src/fluxer_marketing/router.gleam +++ b/fluxer_marketing/src/fluxer_marketing/router.gleam @@ -15,6 +15,7 @@ //// You should have received a copy of the GNU Affero General Public License //// along with Fluxer. If not, see . +import fluxer_marketing/badge_proxy import fluxer_marketing/geoip import fluxer_marketing/help_center import fluxer_marketing/locale @@ -48,6 +49,10 @@ pub fn handle_request(req: Request, ctx: Context) -> Response { [] -> home_page.render(req, ctx) ["_locale"] -> handle_locale_change(req, ctx) ["_debug", "geoip"] -> handle_geoip_debug(req, ctx) + + ["api", "badges", "product-hunt"] -> + badge_proxy.product_hunt(ctx.badge_cache) + ["robots.txt"] -> handle_robots_txt() ["sitemap.xml"] -> handle_sitemap(ctx) ["terms"] -> terms_page.render(req, ctx) diff --git a/fluxer_marketing/src/fluxer_marketing/web.gleam b/fluxer_marketing/src/fluxer_marketing/web.gleam index a490a880..1114313f 100644 --- a/fluxer_marketing/src/fluxer_marketing/web.gleam +++ b/fluxer_marketing/src/fluxer_marketing/web.gleam @@ -15,6 +15,7 @@ //// You should have received a copy of the GNU Affero General Public License //// along with Fluxer. If not, see . +import fluxer_marketing/badge_proxy import fluxer_marketing/locale.{type Locale} import fluxer_marketing/visionary_slots.{type VisionarySlots} import gleam/option.{type Option} @@ -55,6 +56,7 @@ pub type Context { release_channel: String, visionary_slots: VisionarySlots, metrics_endpoint: Option(String), + badge_cache: badge_proxy.Cache, ) }