Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions apps/self-hosted/hosting/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@ services:
image: ecency/self-hosted:${TAG:?TAG environment variable is required}
container_name: ecency-hosting-blog
restart: unless-stopped
# The newsletter subscribe location proxies to the origin vhost on this box;
# host-gateway is what makes host.docker.internal resolve on Linux Docker.
extra_hosts:
- "host.docker.internal:host-gateway"
ports:
- "127.0.0.1:3100:80"
volumes:
Expand Down
44 changes: 44 additions & 0 deletions apps/self-hosted/hosting/nginx-multi-tenant.conf
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,28 @@ server {
# Per-tenant static SEO files, written by the hosting API's sync pass.
# robots falls back to the generic file baked in the image; sitemap and
# rss simply 404 until the first pass writes them.
# Newsletter subscribe (vision-web#1537): the blog's signup form posts here,
# same-origin, and this forwards to the web tier THROUGH THE ORIGIN VHOST ON
# THIS BOX (host.docker.internal, an extra_hosts entry in the compose file),
# deliberately not through public ecency.com: a Cloudflare hop would make
# every tenant's signup look like one datacenter client (one IP for consent
# records, one bucket for rate limits, and a bot challenge would break the
# form outright). The reader's address travels as CF-Connecting-IP, set from
# X-Real-IP, which the HOST's edge vhost asserted from its own remote_addr;
# the origin's /api/ location forwards exactly that header pair to the app.
# Managed instances only, by construction: only they sit behind this nginx.
# Only the subscribe POST is exposed; confirmation and unsubscribe links in
# the mail point at ecency.com directly.
location = /api/newsletter/subscribe {
proxy_pass https://host.docker.internal/api/newsletter/subscribe;
proxy_ssl_server_name on;
proxy_ssl_name eu.ecency.com;
proxy_set_header Host eu.ecency.com;
proxy_set_header CF-Connecting-IP $http_x_real_ip;
proxy_read_timeout 15s;
client_max_body_size 16k;
}

location = /robots.txt {
try_files /configs/$tenant_id.robots.txt /robots.txt =404;
}
Expand Down Expand Up @@ -210,6 +232,28 @@ server {
}

# Same static SEO files, keyed by custom tenant.
# Newsletter subscribe (vision-web#1537): the blog's signup form posts here,
# same-origin, and this forwards to the web tier THROUGH THE ORIGIN VHOST ON
# THIS BOX (host.docker.internal, an extra_hosts entry in the compose file),
# deliberately not through public ecency.com: a Cloudflare hop would make
# every tenant's signup look like one datacenter client (one IP for consent
# records, one bucket for rate limits, and a bot challenge would break the
# form outright). The reader's address travels as CF-Connecting-IP, set from
# X-Real-IP, which the HOST's edge vhost asserted from its own remote_addr;
# the origin's /api/ location forwards exactly that header pair to the app.
# Managed instances only, by construction: only they sit behind this nginx.
# Only the subscribe POST is exposed; confirmation and unsubscribe links in
# the mail point at ecency.com directly.
location = /api/newsletter/subscribe {
proxy_pass https://host.docker.internal/api/newsletter/subscribe;
proxy_ssl_server_name on;
proxy_ssl_name eu.ecency.com;
proxy_set_header Host eu.ecency.com;
proxy_set_header CF-Connecting-IP $http_x_real_ip;
proxy_read_timeout 15s;
client_max_body_size 16k;
}

location = /robots.txt {
try_files /configs/$custom_tenant_id.robots.txt /robots.txt =404;
}
Expand Down
2 changes: 2 additions & 0 deletions apps/self-hosted/src/core/configuration-loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,8 @@ export interface InstanceConfig {
post: {
text2Speech: { enabled: boolean };
};
/** The email-digest signup form (managed instances; the form posts to the host's own /api/newsletter/subscribe). */
newsletter?: { enabled?: boolean };
tipping?: {
enabled?: boolean;
general?: { enabled: boolean; buttonLabel?: string };
Expand Down
26 changes: 25 additions & 1 deletion apps/self-hosted/src/core/i18n-strings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -300,7 +300,19 @@ export type TranslationKey =
| 'panel_configuration_instance_configuration_features_hive_payout_label_description'
| 'panel_validation_learn_more_url'
| 'panel_validation_create_post_url_community'
| 'panel_validation_create_post_url_refused';
| 'panel_validation_create_post_url_refused'
| 'newsletterTitle'
| 'newsletterBlurb'
| 'newsletterCommunityBlurb'
| 'newsletterEmail'
| 'newsletterWeekly'
| 'newsletterMonthly'
| 'newsletterSubscribe'
| 'newsletterCheckInbox'
| 'newsletterError'
| 'panel_configuration_instance_configuration_features_newsletter_label'
| 'panel_configuration_instance_configuration_features_newsletter_enabled_label'
| 'panel_configuration_instance_configuration_features_newsletter_enabled_description';

export type Translations = Record<TranslationKey, string>;

Expand All @@ -323,6 +335,18 @@ export const translations: { en: Translations } & Record<
Partial<Translations>
> = {
en: {
panel_configuration_instance_configuration_features_newsletter_label: 'Newsletter signup',
panel_configuration_instance_configuration_features_newsletter_enabled_label: 'Show the email signup form',
panel_configuration_instance_configuration_features_newsletter_enabled_description: 'Readers can subscribe to a weekly or monthly email digest of this site (managed hosting only; double opt-in).',
newsletterTitle: 'Get new posts by email',
newsletterBlurb: 'A weekly or monthly digest of new posts. Double opt-in, unsubscribe any time.',
newsletterCommunityBlurb: 'The best of this community as a weekly or monthly digest. Double opt-in, unsubscribe any time.',
newsletterEmail: 'Your email',
newsletterWeekly: 'Weekly',
newsletterMonthly: 'Monthly',
newsletterSubscribe: 'Subscribe',
newsletterCheckInbox: 'Almost there: confirm from the email we just sent.',
newsletterError: 'Could not subscribe right now. Please try again.',
loading: "Loading...",
hivesigner_login_failed: 'Sign in could not be completed. Please try again.',
loadingPost: "Loading post...",
Expand Down
1 change: 1 addition & 0 deletions apps/self-hosted/src/features/blog/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,4 @@ export * from './blog-post-item';
export * from './blog-post-page';
export * from './blog-posts-list';
export * from './detect-bottom';
export * from './newsletter-signup';
100 changes: 100 additions & 0 deletions apps/self-hosted/src/features/blog/components/newsletter-signup.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import { type FormEvent, type ReactElement, useEffect, useRef, useState } from 'react';
import { InstanceConfigManager } from '../../../core/configuration-loader';
import { t } from '../../../core/i18n';
import { LiveRegion } from '../../shared/live-region';
import { newsletterSignupTarget, newsletterSubscribeBody } from '../utils/newsletter-signup-target';

/**
* The email-digest signup form (vision-web#1537). Managed instances only, by
* two fences: the form renders only when the served config carries `managed`
* (never on a true self-host or the unclaimed template), and it posts to the
* host's own `/api/newsletter/subscribe`, a path only the managed nginx
* forwards to ecency.com's public relay. Double opt-in end to end: the service
* answers `pending_confirmation` and the reader confirms from their inbox, so
* the form always says "check your inbox" on success and can learn nothing
* about an address it does not own.
*/
export function NewsletterSignup(): ReactElement | null {
const target = InstanceConfigManager.useConfig(({ configuration }) =>
newsletterSignupTarget({
username: configuration.instanceConfiguration.username,
managed: configuration.instanceConfiguration.managed,
template: configuration.instanceConfiguration.template,
enabled: configuration.instanceConfiguration.features.newsletter?.enabled ?? true,
siteTitle: configuration.instanceConfiguration.meta?.title,
})
Comment thread
qodo-code-review[bot] marked this conversation as resolved.
);

const [email, setEmail] = useState('');
const [cadence, setCadence] = useState<'weekly' | 'monthly'>('weekly');
const [state, setState] = useState<'idle' | 'busy' | 'done' | 'error'>('idle');
// The submit awaits a network call; a navigation mid-flight must not update
// an unmounted component.
const mounted = useRef(true);
useEffect(() => {
mounted.current = true;
return () => {
mounted.current = false;
};
}, []);

if (!target) return null;
const isCommunity = target.type === 'community';

const submit = async (e: FormEvent) => {
e.preventDefault();
if (state === 'busy' || !email.trim()) return;
setState('busy');
try {
const res = await fetch('/api/newsletter/subscribe', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(newsletterSubscribeBody(target, email, cadence)),
});
if (mounted.current) setState(res.ok ? 'done' : 'error');
} catch {
if (mounted.current) setState('error');
}
};

return (
<div className="border-t border-theme pt-4 mt-4 sidebar-newsletter-section" data-testid="newsletter-signup">
<h3 className="text-sm font-semibold mb-1">{t('newsletterTitle')}</h3>
<p className="text-xs text-theme-muted mb-2">{t(isCommunity ? 'newsletterCommunityBlurb' : 'newsletterBlurb')}</p>
{/* Mounted from the first render, per the live-region contract: a region
that appears at the same moment as its message is often not announced. */}
<LiveRegion message={state === 'done' ? t('newsletterCheckInbox') : state === 'error' ? t('newsletterError') : null} className="text-xs block mb-1" />
{state !== 'done' && (
<form onSubmit={submit} className="flex flex-col gap-2">
<input
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder={t('newsletterEmail')}
aria-label={t('newsletterEmail')}
className="input-theme w-full text-sm px-2 py-1.5 rounded"
/>
<div className="flex gap-2">
<select
value={cadence}
onChange={(e) => setCadence(e.target.value as 'weekly' | 'monthly')}
aria-label={t('newsletterSubscribe')}
className="input-theme flex-1 text-sm px-2 py-1.5 rounded"
>
<option value="weekly">{t('newsletterWeekly')}</option>
<option value="monthly">{t('newsletterMonthly')}</option>
</select>
<button
type="submit"
disabled={state === 'busy'}
className="btn-theme-primary text-sm px-3 py-1.5 rounded disabled:opacity-60"
>
{t('newsletterSubscribe')}
</button>
</div>
</form>
)}
</div>
);
}
5 changes: 4 additions & 1 deletion apps/self-hosted/src/features/blog/layout/blog-sidebar.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { formatMonthYear, InstanceConfigManager, t } from "@/core";
import { NewsletterSignup } from "../components/newsletter-signup";
import { useAuth } from "@/features/auth";
import { InlineError } from "@/features/shared/inline-error";
import {
Expand Down Expand Up @@ -103,6 +104,7 @@ function BlogSidebarContent({ username }: { username: string }) {
</div>
</div>
)}
<NewsletterSignup />
{data && (
<div className="border-t border-theme pt-4 mt-4 sidebar-hive-info-section">
<div className="text-xs font-medium mb-2 text-theme-muted">
Expand Down Expand Up @@ -317,7 +319,8 @@ function CommunitySidebar() {
<CommunityJoinButton communityId={communityId} />
</div>

<div className="border-t border-theme pt-4 mt-4 sidebar-hive-info-section">
<NewsletterSignup />
<div className="border-t border-theme pt-4 mt-4 sidebar-hive-info-section">
<div className="text-xs font-medium mb-2 text-theme-muted">
{t("community_info")}
</div>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { describe, expect, it } from 'vitest';
import { newsletterSignupTarget, newsletterSubscribeBody } from './newsletter-signup-target';

describe('newsletterSignupTarget', () => {
const managedBlog = { username: 'Alice', managed: true, siteTitle: 'Alice Writes' };

it('offers the form only on a managed, claimed instance with the feature on', () => {
expect(newsletterSignupTarget(managedBlog)).toEqual({ type: 'creator', target: 'alice', targetLabel: 'Alice Writes' });
// A true self-host never gets it: the config has no managed marker.
expect(newsletterSignupTarget({ username: 'alice' })).toBeNull();
expect(newsletterSignupTarget({ ...managedBlog, managed: undefined })).toBeNull();
// The unclaimed template, though served as managed, is not a tenant.
expect(newsletterSignupTarget({ ...managedBlog, template: true })).toBeNull();
// The owner's toggle.
expect(newsletterSignupTarget({ ...managedBlog, enabled: false })).toBeNull();
expect(newsletterSignupTarget({ ...managedBlog, enabled: true })).not.toBeNull();
expect(newsletterSignupTarget({ managed: true })).toBeNull();
});

it('a hive-… instance subscribes to the community digest; labels fall back to the handle', () => {
expect(newsletterSignupTarget({ username: 'hive-125125', managed: true, siteTitle: 'Town Square' })).toEqual({
type: 'community',
target: 'hive-125125',
targetLabel: 'Town Square',
});
expect(newsletterSignupTarget({ username: 'HIVE-125125', managed: true, siteTitle: ' ' })).toEqual({
type: 'community',
target: 'hive-125125',
targetLabel: 'hive-125125',
});
expect(newsletterSignupTarget({ username: 'bob', managed: true })).toEqual({ type: 'creator', target: 'bob', targetLabel: '@bob' });
});

it('builds the exact relay body, source self-hosted-blog', () => {
const t = newsletterSignupTarget(managedBlog)!;
expect(newsletterSubscribeBody(t, ' reader@example.com ', 'monthly')).toEqual({
email: 'reader@example.com',
type: 'creator',
target: 'alice',
targetLabel: 'Alice Writes',
cadence: 'monthly',
source: 'self-hosted-blog',
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
/**
* The pure half of the newsletter signup form (vision-web#1537): whether this
* instance offers it and what a submission subscribes to. Kept out of the
* component so the rules are testable in the node test environment.
*
* Managed instances only: the form renders only when the served config carries
* `managed` (never a true self-host, never the unclaimed template), and its
* POST goes to the host's own /api/newsletter/subscribe, a path only the
* managed nginx forwards to ecency.com's public relay.
*/
export interface NewsletterSignupTarget {
type: 'creator' | 'community';
target: string;
targetLabel: string;
}

const COMMUNITY_RE = /^hive-\d+$/i;

export function newsletterSignupTarget(cfg: {
username?: string;
managed?: boolean;
template?: boolean;
enabled?: boolean;
siteTitle?: string;
}): NewsletterSignupTarget | null {
if (cfg.managed !== true || cfg.template === true || cfg.enabled === false || !cfg.username) return null;
const target = cfg.username.toLowerCase();
const isCommunity = COMMUNITY_RE.test(target);
return {
type: isCommunity ? 'community' : 'creator',
target,
targetLabel: cfg.siteTitle?.trim() || (isCommunity ? target : `@${target}`),
};
}

export interface NewsletterSubscribeBody {
email: string;
type: 'creator' | 'community';
target: string;
targetLabel: string;
cadence: 'weekly' | 'monthly';
source: 'self-hosted-blog';
}

/** The body the form posts, exactly as the relay expects it. */
export function newsletterSubscribeBody(t: NewsletterSignupTarget, email: string, cadence: 'weekly' | 'monthly'): NewsletterSubscribeBody {
return {
email: email.trim(),
type: t.type,
target: t.target,
targetLabel: t.targetLabel,
cadence,
source: 'self-hosted-blog',
};
}
13 changes: 13 additions & 0 deletions apps/self-hosted/src/features/floating-menu/config-fields.ts
Original file line number Diff line number Diff line change
Expand Up @@ -410,6 +410,19 @@ export function buildConfigFields(
type: 'array',
description: t('panel_configuration_instance_configuration_features_posts_filters_description'),
},
newsletter: {
label: t('panel_configuration_instance_configuration_features_newsletter_label'),
type: 'section',
fields: {
enabled: {
label: t('panel_configuration_instance_configuration_features_newsletter_enabled_label'),
type: 'boolean',
// The runtime treats an absent value as ON; the editor must show the same.
default: true,
description: t('panel_configuration_instance_configuration_features_newsletter_enabled_description'),
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Comment thread
qodo-code-review[bot] marked this conversation as resolved.
Comment thread
qodo-free-for-open-source-projects[bot] marked this conversation as resolved.
},
},
},
likes: {
label: t('panel_configuration_instance_configuration_features_likes_label'),
type: 'section',
Expand Down
Loading
Loading