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,
)
}