diff --git a/.github/workflows/self-hosted.yml b/.github/workflows/self-hosted.yml new file mode 100644 index 0000000000..4b55fac887 --- /dev/null +++ b/.github/workflows/self-hosted.yml @@ -0,0 +1,175 @@ +name: Self-Hosted CI/CD + +on: + push: + branches: + - develop + - main + paths: + - 'apps/self-hosted/**' + - 'packages/**' + +jobs: + tests: + runs-on: ubuntu-latest + strategy: + matrix: + node-version: [20.x] + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v3 + with: + version: 10.18.1 + run_install: false + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + cache: pnpm + cache-dependency-path: pnpm-lock.yaml + - name: Install dependencies + run: pnpm install --frozen-lockfile + env: + CI: true + - name: Run self-hosted tests + run: pnpm --filter @ecency/self-hosted test + # Hosting API is a standalone npm project (not part of pnpm workspace) + # with its own Dockerfile and independent dependency tree + - name: Run hosting API tests + run: cd apps/self-hosted/hosting/api && npm install && npm test + + build-blog: + needs: tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Login to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Determine tags + id: tag + run: | + SHA_TAG="sha-${GITHUB_SHA::7}" + if [ "${{ github.ref }}" = "refs/heads/main" ]; then + CHANNEL_TAG="latest" + else + CHANNEL_TAG="develop" + fi + echo "sha=$SHA_TAG" >> $GITHUB_OUTPUT + echo "channel=$CHANNEL_TAG" >> $GITHUB_OUTPUT + + - name: Build and push blog image + uses: docker/build-push-action@v6 + with: + context: . + file: ./apps/self-hosted/Dockerfile + push: true + tags: | + ecency/self-hosted:${{ steps.tag.outputs.sha }} + ecency/self-hosted:${{ steps.tag.outputs.channel }} + + build-api: + needs: tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Login to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Determine tags + id: tag + run: | + SHA_TAG="sha-${GITHUB_SHA::7}" + if [ "${{ github.ref }}" = "refs/heads/main" ]; then + CHANNEL_TAG="latest" + else + CHANNEL_TAG="develop" + fi + echo "sha=$SHA_TAG" >> $GITHUB_OUTPUT + echo "channel=$CHANNEL_TAG" >> $GITHUB_OUTPUT + + - name: Build and push API image + uses: docker/build-push-action@v6 + with: + context: ./apps/self-hosted/hosting/api + file: ./apps/self-hosted/hosting/api/Dockerfile + push: true + tags: | + ecency/hosting-api:${{ steps.tag.outputs.sha }} + ecency/hosting-api:${{ steps.tag.outputs.channel }} + + deploy-staging: + needs: [build-blog, build-api] + runs-on: ubuntu-latest + if: github.ref == 'refs/heads/develop' + steps: + - name: Deploy to staging + uses: appleboy/ssh-action@v1 + env: + DEPLOY_SHA: ${{ github.sha }} + POSTGRES_PASSWORD: ${{ secrets.HOSTING_POSTGRES_PASSWORD }} + JWT_SECRET: ${{ secrets.HOSTING_JWT_SECRET }} + PAYMENT_ACCOUNT: ${{ secrets.HOSTING_PAYMENT_ACCOUNT }} + ACME_EMAIL: ${{ secrets.HOSTING_ACME_EMAIL }} + CF_API_EMAIL: ${{ secrets.CF_API_EMAIL }} + CF_API_KEY: ${{ secrets.CF_API_KEY }} + with: + host: ${{ secrets.SSH_HOST_HOSTING_STAGING }} + username: ${{ secrets.SSH_USERNAME }} + key: ${{ secrets.SSH_KEY }} + port: ${{ secrets.SSH_PORT }} + envs: DEPLOY_SHA,POSTGRES_PASSWORD,JWT_SECRET,PAYMENT_ACCOUNT,ACME_EMAIL,CF_API_EMAIL,CF_API_KEY + script: | + IMAGE_TAG="sha-${DEPLOY_SHA::7}" + export POSTGRES_PASSWORD=$POSTGRES_PASSWORD + export JWT_SECRET=$JWT_SECRET + export PAYMENT_ACCOUNT=$PAYMENT_ACCOUNT + export ACME_EMAIL=$ACME_EMAIL + export CF_API_EMAIL=$CF_API_EMAIL + export CF_API_KEY=$CF_API_KEY + cd ~/hosting + docker pull ecency/self-hosted:$IMAGE_TAG + docker pull ecency/hosting-api:$IMAGE_TAG + TAG=$IMAGE_TAG docker compose up -d + + deploy: + needs: [build-blog, build-api] + runs-on: ubuntu-latest + if: github.ref == 'refs/heads/main' + steps: + - name: Deploy to production + uses: appleboy/ssh-action@v1 + env: + DEPLOY_SHA: ${{ github.sha }} + POSTGRES_PASSWORD: ${{ secrets.HOSTING_POSTGRES_PASSWORD }} + JWT_SECRET: ${{ secrets.HOSTING_JWT_SECRET }} + PAYMENT_ACCOUNT: ${{ secrets.HOSTING_PAYMENT_ACCOUNT }} + ACME_EMAIL: ${{ secrets.HOSTING_ACME_EMAIL }} + CF_API_EMAIL: ${{ secrets.CF_API_EMAIL }} + CF_API_KEY: ${{ secrets.CF_API_KEY }} + with: + host: ${{ secrets.SSH_HOST_HOSTING }} + username: ${{ secrets.SSH_USERNAME }} + key: ${{ secrets.SSH_KEY }} + port: ${{ secrets.SSH_PORT }} + envs: DEPLOY_SHA,POSTGRES_PASSWORD,JWT_SECRET,PAYMENT_ACCOUNT,ACME_EMAIL,CF_API_EMAIL,CF_API_KEY + script: | + IMAGE_TAG="sha-${DEPLOY_SHA::7}" + export POSTGRES_PASSWORD=$POSTGRES_PASSWORD + export JWT_SECRET=$JWT_SECRET + export PAYMENT_ACCOUNT=$PAYMENT_ACCOUNT + export ACME_EMAIL=$ACME_EMAIL + export CF_API_EMAIL=$CF_API_EMAIL + export CF_API_KEY=$CF_API_KEY + cd ~/hosting + docker pull ecency/self-hosted:$IMAGE_TAG + docker pull ecency/hosting-api:$IMAGE_TAG + TAG=$IMAGE_TAG docker compose up -d diff --git a/apps/self-hosted/DEPLOYMENT.md b/apps/self-hosted/DEPLOYMENT.md index 77fcef0af6..c224e5a243 100644 --- a/apps/self-hosted/DEPLOYMENT.md +++ b/apps/self-hosted/DEPLOYMENT.md @@ -417,32 +417,26 @@ docker-compose build --no-cache ## Managed Hosting by Ecency -> **⚠️ PLANNED - NOT YET AVAILABLE** -> -> The managed hosting service described below is under development and not yet launched. -> The endpoints, payment accounts, and features listed are placeholders for the planned service. -> Check [ecency.com](https://ecency.com) for announcements when this service becomes available. - Don't want to manage your own infrastructure? Let Ecency host your blog. -### Planned Pricing +### Pricing | Plan | Price | Features | |------|-------|----------| | **Standard** | 1 HBD/month | Custom subdomain, SSL, CDN, 99.9% uptime | | **Pro** | 3 HBD/month | Custom domain, priority support, analytics | -### How It Will Work (Planned) +### How It Works -1. **Visit** the Ecency blog hosting page (URL TBD) +1. **Visit** [https://blogs.ecency.com](https://blogs.ecency.com) 2. **Connect** your Hive wallet 3. **Configure** your blog (username, theme, features) 4. **Pay** via HBD transfer 5. **Go live** instantly! -### Custom Domain Setup (Planned) +### Custom Domain Setup -For custom domains, you would add a CNAME record: +For custom domains, add a CNAME record: ``` Type: CNAME @@ -451,10 +445,10 @@ Value: YOUR-BLOG-ID.blogs.ecency.com TTL: 3600 ``` -### Payment Memo Format (Planned) +### Payment Memo Format ``` -To: (TBD - payment account not yet active) +To: ecency.hosting Amount: 1.000 HBD Memo: blog:YOUR_HIVE_USERNAME ``` diff --git a/apps/self-hosted/hosting/api/package.json b/apps/self-hosted/hosting/api/package.json index 15a50c4115..b70377fc6e 100644 --- a/apps/self-hosted/hosting/api/package.json +++ b/apps/self-hosted/hosting/api/package.json @@ -7,12 +7,16 @@ "build": "tsc", "start": "node dist/index.js", "start:listener": "node dist/payment-listener.js", - "db:migrate": "tsx src/db/migrate.ts" + "db:migrate": "tsx src/db/migrate.ts", + "test": "vitest run" }, "dependencies": { "@hiveio/dhive": "^1.3.5", "@hiveio/x402": "^0.1.2", + "@hono/node-server": "^1.13.0", + "@hono/zod-validator": "^0.4.0", "hono": "^4.4.0", + "jsonwebtoken": "^9.0.0", "pg": "^8.12.0", "redis": "^4.6.0", "zod": "^3.23.0", @@ -20,9 +24,11 @@ "nanoid": "^5.0.0" }, "devDependencies": { + "@types/jsonwebtoken": "^9.0.0", "@types/node": "^20.14.0", "@types/pg": "^8.11.0", "tsx": "^4.15.0", - "typescript": "^5.5.0" + "typescript": "^5.5.0", + "vitest": "^3.0.0" } } diff --git a/apps/self-hosted/hosting/api/src/index.ts b/apps/self-hosted/hosting/api/src/index.ts index a36c6a7685..b2522730cf 100644 --- a/apps/self-hosted/hosting/api/src/index.ts +++ b/apps/self-hosted/hosting/api/src/index.ts @@ -19,8 +19,20 @@ const app = new Hono(); // Middleware app.use('*', logger()); app.use('*', secureHeaders()); +const baseDomain = process.env.BASE_DOMAIN || 'blogs.ecency.com'; app.use('*', cors({ - origin: ['https://ecency.com', 'http://localhost:3000'], + origin: (origin) => { + const allowed = [ + 'https://ecency.com', + 'https://alpha.ecency.com', + `https://${baseDomain}`, + 'http://localhost:3000', + ]; + if (allowed.includes(origin)) return origin; + // Allow any subdomain of the base domain (tenant blogs) + if (origin.endsWith(`.${baseDomain}`) && origin.startsWith('https://')) return origin; + return null; + }, allowMethods: ['GET', 'POST', 'PATCH', 'DELETE'], allowHeaders: ['Content-Type', 'Authorization', 'x-payment'], exposeHeaders: ['x-payment', 'x-payment-response'], diff --git a/apps/self-hosted/hosting/api/src/payment-listener.ts b/apps/self-hosted/hosting/api/src/payment-listener.ts index b58e9545bc..dada0395fc 100644 --- a/apps/self-hosted/hosting/api/src/payment-listener.ts +++ b/apps/self-hosted/hosting/api/src/payment-listener.ts @@ -9,7 +9,8 @@ import { Client } from '@hiveio/dhive'; import { db } from './db/client'; import { TenantService } from './services/tenant-service'; import { ConfigService } from './services/config-service'; -import { parseMemo, type ParsedMemo } from '../types'; +import { parseMemo, type ParsedMemo } from './types'; +import { AuditService } from './services/audit-service'; // Configuration const CONFIG = { @@ -289,9 +290,15 @@ class PaymentListener { ); }); - // 5. Generate config file AFTER transaction commits successfully + // 5. Post-commit side effects (only if transaction succeeded) if (updatedTenant) { await ConfigService.generateConfigFile(updatedTenant); + + void AuditService.log({ + tenantId: updatedTenant.id, + eventType: 'payment.processed', + eventData: { username, months, amount, trxId: transfer.trxId }, + }); } } catch (error) { console.error('[PaymentListener] Failed to process subscription for', username, error); @@ -320,6 +327,12 @@ class PaymentListener { await TenantService.upgradeToPro(username); await this.logPayment(transfer, amount, 'processed', 0, null, 'Upgraded to Pro'); + void AuditService.log({ + tenantId: tenant.id, + eventType: 'payment.upgrade', + eventData: { username, amount, trxId: transfer.trxId }, + }); + console.log('[PaymentListener] Upgraded', username, 'to Pro plan'); } catch (error) { console.error('[PaymentListener] Failed to process upgrade for', username, error); diff --git a/apps/self-hosted/hosting/api/src/routes/auth.ts b/apps/self-hosted/hosting/api/src/routes/auth.ts index a04aa38c30..9929a4b4d0 100644 --- a/apps/self-hosted/hosting/api/src/routes/auth.ts +++ b/apps/self-hosted/hosting/api/src/routes/auth.ts @@ -12,6 +12,7 @@ import { TenantService } from '../services/tenant-service'; import { nanoid } from 'nanoid'; import { createToken, verifyToken, getTokenExpiry } from '../utils/auth'; import { challengeStore } from '../utils/redis'; +import { AuditService, parseClientIp } from '../services/audit-service'; export const authRoutes = new Hono(); @@ -110,6 +111,13 @@ authRoutes.post( const token = createToken(username, expiresInMs); const expiresAt = getTokenExpiry(token); + void AuditService.log({ + eventType: 'auth.login', + eventData: { username }, + ipAddress: parseClientIp(c.req.header('x-forwarded-for')), + userAgent: c.req.header('user-agent'), + }); + return c.json({ token, username, diff --git a/apps/self-hosted/hosting/api/src/routes/domains.ts b/apps/self-hosted/hosting/api/src/routes/domains.ts index ab722c6cd8..d7404c2602 100644 --- a/apps/self-hosted/hosting/api/src/routes/domains.ts +++ b/apps/self-hosted/hosting/api/src/routes/domains.ts @@ -8,6 +8,7 @@ import { zValidator } from '@hono/zod-validator'; import { TenantService } from '../services/tenant-service'; import { DomainService } from '../services/domain-service'; import { authMiddleware } from '../middleware/auth'; +import { AuditService, parseClientIp } from '../services/audit-service'; export const domainRoutes = new Hono(); @@ -46,6 +47,14 @@ domainRoutes.post('/', authMiddleware, zValidator('json', addDomainSchema), asyn await TenantService.setCustomDomain(username, domain); const verification = await DomainService.createVerification(username, domain); + void AuditService.log({ + tenantId: tenant.id, + eventType: 'domain.added', + eventData: { domain, username }, + ipAddress: parseClientIp(c.req.header('x-forwarded-for')), + userAgent: c.req.header('user-agent'), + }); + return c.json({ domain, verification: { @@ -89,6 +98,14 @@ domainRoutes.post('/verify', authMiddleware, async (c) => { await TenantService.verifyCustomDomain(username); await DomainService.markVerified(username, tenant.customDomain); + void AuditService.log({ + tenantId: tenant.id, + eventType: 'domain.verified', + eventData: { domain: tenant.customDomain, username }, + ipAddress: parseClientIp(c.req.header('x-forwarded-for')), + userAgent: c.req.header('user-agent'), + }); + return c.json({ verified: true, domain: tenant.customDomain, @@ -102,7 +119,17 @@ domainRoutes.delete('/', authMiddleware, async (c) => { const username = authUser.username; try { + const tenant = await TenantService.getByUsername(username); await TenantService.removeCustomDomain(username); + + void AuditService.log({ + tenantId: tenant?.id ?? null, + eventType: 'domain.removed', + eventData: { username }, + ipAddress: parseClientIp(c.req.header('x-forwarded-for')), + userAgent: c.req.header('user-agent'), + }); + return c.json({ message: 'Custom domain removed' }); } catch (error: any) { if (error.message === 'Tenant not found') { diff --git a/apps/self-hosted/hosting/api/src/routes/tenants.ts b/apps/self-hosted/hosting/api/src/routes/tenants.ts index 240cf2a88d..e3dab85206 100644 --- a/apps/self-hosted/hosting/api/src/routes/tenants.ts +++ b/apps/self-hosted/hosting/api/src/routes/tenants.ts @@ -7,10 +7,11 @@ import { z } from 'zod'; import { zValidator } from '@hono/zod-validator'; import { db } from '../db/client'; import { TenantService } from '../services/tenant-service'; -import { mapTenantFromDb } from '../../types'; +import { mapTenantFromDb } from '../types'; import { ConfigService } from '../services/config-service'; import { authMiddleware } from '../middleware/auth'; import { subscriptionPaywall, proUpgradePaywall } from '../middleware/x402-paywall'; +import { AuditService, parseClientIp } from '../services/audit-service'; export const tenantRoutes = new Hono(); @@ -104,6 +105,14 @@ tenantRoutes.post('/', zValidator('json', createTenantSchema), async (c) => { const paymentAccount = process.env.PAYMENT_ACCOUNT || 'ecency.hosting'; const monthlyPrice = process.env.MONTHLY_PRICE_HBD || '1.000'; + void AuditService.log({ + tenantId: tenant.id, + eventType: 'tenant.created', + eventData: { username: body.username }, + ipAddress: parseClientIp(c.req.header('x-forwarded-for')), + userAgent: c.req.header('user-agent'), + }); + return c.json({ tenant: { username: tenant.username, @@ -221,6 +230,14 @@ tenantRoutes.post('/subscribe', console.error(`Failed to generate config for ${body.username}:`, err); } + void AuditService.log({ + tenantId: activatedTenant.id, + eventType: 'tenant.subscribed', + eventData: { username: body.username, payer, txId }, + ipAddress: parseClientIp(c.req.header('x-forwarded-for')), + userAgent: c.req.header('user-agent'), + }); + return c.json({ tenant: { username: activatedTenant.username, @@ -332,11 +349,19 @@ tenantRoutes.post('/:username/upgrade', } if (!result) { - return c.json({ error: 'Payment already processed' }, 409); + return c.json({ error: 'Upgrade failed' }, 500); } const upgradedTenant = mapTenantFromDb(result); + void AuditService.log({ + tenantId: upgradedTenant.id, + eventType: 'tenant.upgraded', + eventData: { username, plan: 'pro', payer, txId }, + ipAddress: parseClientIp(c.req.header('x-forwarded-for')), + userAgent: c.req.header('user-agent'), + }); + return c.json({ tenant: { username: upgradedTenant.username, @@ -370,6 +395,14 @@ tenantRoutes.patch('/:username', authMiddleware, zValidator('json', updateTenant // Regenerate config file await ConfigService.generateConfigFile(updatedTenant); + void AuditService.log({ + tenantId: updatedTenant.id, + eventType: 'tenant.config_updated', + eventData: { username }, + ipAddress: parseClientIp(c.req.header('x-forwarded-for')), + userAgent: c.req.header('user-agent'), + }); + return c.json({ username: updatedTenant.username, config: updatedTenant.config, @@ -409,15 +442,29 @@ tenantRoutes.get('/:username/status', async (c) => { tenantRoutes.delete('/:username', authMiddleware, async (c) => { const username = c.req.param('username'); const authUser = c.get('user'); - + // Verify user owns this tenant if (authUser.username !== username) { return c.json({ error: 'Unauthorized' }, 403); } - + + // Capture tenant ID before deletion for audit trail + const tenant = await TenantService.getByUsername(username); + if (!tenant) { + return c.json({ error: 'Tenant not found' }, 404); + } + await TenantService.delete(username); await ConfigService.deleteConfigFile(username); - + + void AuditService.log({ + tenantId: tenant.id, + eventType: 'tenant.deleted', + eventData: { username }, + ipAddress: parseClientIp(c.req.header('x-forwarded-for')), + userAgent: c.req.header('user-agent'), + }); + return c.json({ message: 'Tenant deleted' }); }); diff --git a/apps/self-hosted/hosting/api/src/services/audit-service.ts b/apps/self-hosted/hosting/api/src/services/audit-service.ts new file mode 100644 index 0000000000..e6f18182dd --- /dev/null +++ b/apps/self-hosted/hosting/api/src/services/audit-service.ts @@ -0,0 +1,47 @@ +/** + * Audit Logging Service + * + * Fire-and-forget inserts into the audit_log table. + * Never throws — errors are logged but swallowed so callers are unaffected. + */ + +import { db } from '../db/client'; + +export interface AuditEntry { + tenantId?: string | null; + eventType: string; + eventData?: Record; + ipAddress?: string | null; + userAgent?: string | null; +} + +/** + * Extract client IP from X-Forwarded-For header. + * Takes the rightmost entry, which is the IP added by the nearest trusted + * reverse proxy (Traefik). The leftmost entries can be spoofed by the client. + */ +export function parseClientIp(xForwardedFor: string | undefined): string | null { + if (!xForwardedFor) return null; + const parts = xForwardedFor.split(','); + return parts[parts.length - 1]?.trim() || null; +} + +export const AuditService = { + log(entry: AuditEntry): void { + const { tenantId, eventType, eventData, ipAddress, userAgent } = entry; + + db.query( + `INSERT INTO audit_log (tenant_id, event_type, event_data, ip_address, user_agent) + VALUES ($1, $2, $3, $4, $5)`, + [ + tenantId ?? null, + eventType, + eventData ? JSON.stringify(eventData) : null, + ipAddress ?? null, + userAgent ?? null, + ], + ).catch((err) => { + console.error(`[AuditService] Failed to write audit log for eventType=${eventType}:`, err); + }); + }, +}; diff --git a/apps/self-hosted/hosting/api/src/services/domain-service.ts b/apps/self-hosted/hosting/api/src/services/domain-service.ts index 904208acd5..3f85bc8f0d 100644 --- a/apps/self-hosted/hosting/api/src/services/domain-service.ts +++ b/apps/self-hosted/hosting/api/src/services/domain-service.ts @@ -11,7 +11,7 @@ import { type DomainVerification, type DomainVerificationRow, mapDomainVerificationFromDb, -} from '../../types'; +} from '../types'; const baseDomain = process.env.BASE_DOMAIN || 'blogs.ecency.com'; diff --git a/apps/self-hosted/hosting/api/src/services/tenant-service.ts b/apps/self-hosted/hosting/api/src/services/tenant-service.ts index 1c767a9a60..69249fef40 100644 --- a/apps/self-hosted/hosting/api/src/services/tenant-service.ts +++ b/apps/self-hosted/hosting/api/src/services/tenant-service.ts @@ -4,10 +4,10 @@ import { db } from '../db/client'; import { Client } from '@hiveio/dhive'; -import { Tenant, TenantRow, mapTenantFromDb } from '../../types'; +import { Tenant, TenantRow, mapTenantFromDb } from '../types'; // Re-export Tenant type for backward compatibility -export type { Tenant } from '../../types'; +export type { Tenant } from '../types'; const hiveClient = new Client(process.env.HIVE_API_URL?.split(',') || ['https://api.hive.blog']); const baseDomain = process.env.BASE_DOMAIN || 'blogs.ecency.com'; diff --git a/apps/self-hosted/hosting/api/src/types.test.ts b/apps/self-hosted/hosting/api/src/types.test.ts new file mode 100644 index 0000000000..ea76463254 --- /dev/null +++ b/apps/self-hosted/hosting/api/src/types.test.ts @@ -0,0 +1,183 @@ +import { describe, it, expect } from 'vitest'; +import { + parseMemo, + mapTenantFromDb, + mapTenantToDb, + type TenantRow, + type Tenant, +} from './types'; + +// ============================================================================= +// parseMemo +// ============================================================================= + +describe('parseMemo', () => { + it('parses "blog:username" as 1-month subscription', () => { + const result = parseMemo('blog:alice'); + expect(result).toEqual({ action: 'blog', username: 'alice', months: 1 }); + }); + + it('parses "blog:username:months" with explicit months', () => { + const result = parseMemo('blog:bob:6'); + expect(result).toEqual({ action: 'blog', username: 'bob', months: 6 }); + }); + + it('parses "blog:username:12" for a yearly subscription', () => { + const result = parseMemo('blog:charlie:12'); + expect(result).toEqual({ action: 'blog', username: 'charlie', months: 12 }); + }); + + it('defaults months to 1 when the months segment is non-numeric', () => { + const result = parseMemo('blog:dave:abc'); + expect(result).toEqual({ action: 'blog', username: 'dave', months: 1 }); + }); + + it('handles uppercase memos by lowercasing', () => { + const result = parseMemo('BLOG:Alice'); + expect(result).toEqual({ action: 'blog', username: 'alice', months: 1 }); + }); + + it('trims whitespace around the memo', () => { + const result = parseMemo(' blog:alice '); + expect(result).toEqual({ action: 'blog', username: 'alice', months: 1 }); + }); + + it('parses "upgrade:username"', () => { + const result = parseMemo('upgrade:alice'); + expect(result).toEqual({ action: 'upgrade', username: 'alice', months: 1 }); + }); + + it('returns unknown for empty string', () => { + const result = parseMemo(''); + expect(result).toEqual({ action: 'unknown', username: '', months: 0 }); + }); + + it('returns unknown for random text', () => { + const result = parseMemo('hello world'); + expect(result).toEqual({ action: 'unknown', username: '', months: 0 }); + }); + + it('returns unknown when action is "blog" but username is missing', () => { + const result = parseMemo('blog:'); + expect(result).toEqual({ action: 'unknown', username: '', months: 0 }); + }); + + it('returns unknown when action is "upgrade" but username is missing', () => { + const result = parseMemo('upgrade:'); + expect(result).toEqual({ action: 'unknown', username: '', months: 0 }); + }); + + it('returns unknown for unrecognized action', () => { + const result = parseMemo('refund:alice'); + expect(result).toEqual({ action: 'unknown', username: '', months: 0 }); + }); + + it('defaults months to 1 when months is 0', () => { + const result = parseMemo('blog:alice:0'); + expect(result).toEqual({ action: 'blog', username: 'alice', months: 1 }); + }); + + it('defaults negative months to 1', () => { + const result = parseMemo('blog:alice:-1'); + expect(result).toEqual({ action: 'blog', username: 'alice', months: 1 }); + }); +}); + +// ============================================================================= +// mapTenantFromDb +// ============================================================================= + +describe('mapTenantFromDb', () => { + const baseRow: TenantRow = { + id: '123', + username: 'alice', + subscription_status: 'active', + subscription_plan: 'standard', + subscription_started_at: '2025-01-01T00:00:00Z', + subscription_expires_at: '2025-02-01T00:00:00Z', + custom_domain: 'alice.blog', + custom_domain_verified: true, + custom_domain_verified_at: '2025-01-05T00:00:00Z', + config: { version: 1 }, + created_at: '2025-01-01T00:00:00Z', + updated_at: '2025-01-01T00:00:00Z', + }; + + it('maps snake_case fields to camelCase', () => { + const tenant = mapTenantFromDb(baseRow); + expect(tenant.id).toBe('123'); + expect(tenant.username).toBe('alice'); + expect(tenant.subscriptionStatus).toBe('active'); + expect(tenant.subscriptionPlan).toBe('standard'); + expect(tenant.customDomain).toBe('alice.blog'); + expect(tenant.customDomainVerified).toBe(true); + }); + + it('converts date strings to Date objects', () => { + const tenant = mapTenantFromDb(baseRow); + expect(tenant.subscriptionStartedAt).toBeInstanceOf(Date); + expect(tenant.subscriptionExpiresAt).toBeInstanceOf(Date); + expect(tenant.customDomainVerifiedAt).toBeInstanceOf(Date); + expect(tenant.createdAt).toBeInstanceOf(Date); + expect(tenant.updatedAt).toBeInstanceOf(Date); + }); + + it('handles null date fields', () => { + const row: TenantRow = { + ...baseRow, + subscription_started_at: null, + subscription_expires_at: null, + custom_domain: null, + custom_domain_verified: false, + custom_domain_verified_at: null, + }; + const tenant = mapTenantFromDb(row); + expect(tenant.subscriptionStartedAt).toBeNull(); + expect(tenant.subscriptionExpiresAt).toBeNull(); + expect(tenant.customDomain).toBeNull(); + expect(tenant.customDomainVerifiedAt).toBeNull(); + }); + + it('preserves the config object as-is', () => { + const tenant = mapTenantFromDb(baseRow); + expect(tenant.config).toEqual({ version: 1 }); + }); +}); + +// ============================================================================= +// mapTenantToDb +// ============================================================================= + +describe('mapTenantToDb', () => { + it('maps camelCase to snake_case for all fields', () => { + const partial: Partial = { + username: 'bob', + subscriptionStatus: 'active', + subscriptionPlan: 'pro', + }; + const dbFields = mapTenantToDb(partial); + expect(dbFields).toEqual({ + username: 'bob', + subscription_status: 'active', + subscription_plan: 'pro', + }); + }); + + it('only includes fields that are defined', () => { + const dbFields = mapTenantToDb({ username: 'bob' }); + expect(Object.keys(dbFields)).toEqual(['username']); + }); + + it('JSON.stringifies the config field', () => { + const partial: Partial = { + config: { version: 1 } as any, + }; + const dbFields = mapTenantToDb(partial); + expect(dbFields.config).toBe(JSON.stringify({ version: 1 })); + }); + + it('returns empty object when nothing is set', () => { + const dbFields = mapTenantToDb({}); + expect(dbFields).toEqual({}); + }); +}); diff --git a/apps/self-hosted/hosting/api/types.ts b/apps/self-hosted/hosting/api/src/types.ts similarity index 99% rename from apps/self-hosted/hosting/api/types.ts rename to apps/self-hosted/hosting/api/src/types.ts index ed72a46a30..59ca995bf4 100644 --- a/apps/self-hosted/hosting/api/types.ts +++ b/apps/self-hosted/hosting/api/src/types.ts @@ -318,7 +318,8 @@ export function parseMemo(memo: string): ParsedMemo { if (parts[0] === 'blog' && username !== '') { // Trim months string before parsing to avoid whitespace issues const monthsStr = parts[2]?.trim() || '1'; - const months = parseInt(monthsStr, 10) || 1; + const parsed = parseInt(monthsStr, 10); + const months = parsed > 0 ? parsed : 1; return { action: 'blog', username, diff --git a/apps/self-hosted/hosting/api/vitest.config.ts b/apps/self-hosted/hosting/api/vitest.config.ts new file mode 100644 index 0000000000..93dab3a426 --- /dev/null +++ b/apps/self-hosted/hosting/api/vitest.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + css: { postcss: {} }, + test: { + environment: 'node', + globals: true, + include: ['**/*.test.ts'], + }, +}); diff --git a/apps/self-hosted/hosting/docker-compose.yml b/apps/self-hosted/hosting/docker-compose.yml index d1c1c04921..7fb3b88452 100644 --- a/apps/self-hosted/hosting/docker-compose.yml +++ b/apps/self-hosted/hosting/docker-compose.yml @@ -74,9 +74,10 @@ services: # Blog Server - Serves static SPA with multi-tenant config routing # ========================================================================== blog-server: - build: - context: ../../.. - dockerfile: apps/self-hosted/Dockerfile + image: ecency/self-hosted:${TAG:-latest} + # build: + # context: ../../.. + # dockerfile: apps/self-hosted/Dockerfile container_name: ecency-hosting-blog restart: unless-stopped volumes: @@ -93,6 +94,12 @@ services: labels: - "traefik.enable=true" + # Apex domain routing (landing / signup page) + - "traefik.http.routers.blogs-apex.rule=Host(`${BASE_DOMAIN:-blogs.ecency.com}`)" + - "traefik.http.routers.blogs-apex.entrypoints=websecure" + - "traefik.http.routers.blogs-apex.tls.certresolver=letsencrypt-dns" + - "traefik.http.routers.blogs-apex.service=blog-server" + # Wildcard subdomain routing - "traefik.http.routers.blogs-wildcard.rule=HostRegexp(`{subdomain:[a-z0-9-]+}.${BASE_DOMAIN:-blogs.ecency.com}`)" - "traefik.http.routers.blogs-wildcard.entrypoints=websecure" @@ -115,9 +122,10 @@ services: # Hosting API - Tenant management, subscriptions, config generation # ========================================================================== hosting-api: - build: - context: ./api - dockerfile: Dockerfile + image: ecency/hosting-api:${TAG:-latest} + # build: + # context: ./api + # dockerfile: Dockerfile container_name: ecency-hosting-api restart: unless-stopped environment: @@ -150,9 +158,9 @@ services: # HBD Payment Listener - Monitors blockchain for subscription payments # ========================================================================== payment-listener: - build: - context: ./api - dockerfile: Dockerfile.payment-listener + image: ecency/hosting-api:${TAG:-latest} + command: ["node", "dist/payment-listener.js"] + # Reuses API image with different entrypoint container_name: ecency-hosting-payments restart: unless-stopped environment: diff --git a/apps/self-hosted/package.json b/apps/self-hosted/package.json index 1de5525514..afb758f219 100644 --- a/apps/self-hosted/package.json +++ b/apps/self-hosted/package.json @@ -8,7 +8,8 @@ "check": "biome check --write", "dev": "rsbuild dev --open", "format": "biome format --write", - "preview": "rsbuild preview" + "preview": "rsbuild preview", + "test": "vitest run" }, "dependencies": { "@ecency/render-helper": "workspace:*", @@ -62,6 +63,7 @@ "@types/react-dom": "^19.1.9", "@types/speakingurl": "^13.0.6", "tailwindcss": "^4.1.13", - "typescript": "^5.9.3" + "typescript": "^5.9.3", + "vitest": "^3.0.0" } } diff --git a/apps/self-hosted/src/features/floating-menu/utils.test.ts b/apps/self-hosted/src/features/floating-menu/utils.test.ts new file mode 100644 index 0000000000..7fb0a15dfe --- /dev/null +++ b/apps/self-hosted/src/features/floating-menu/utils.test.ts @@ -0,0 +1,66 @@ +import { describe, it, expect } from 'vitest'; +import { deepClone, updateNestedPath } from './utils'; + +describe('deepClone', () => { + it('creates a deep copy of an object', () => { + const original = { a: 1, b: { c: 2 } }; + const cloned = deepClone(original); + expect(cloned).toEqual(original); + expect(cloned).not.toBe(original); + expect(cloned.b).not.toBe(original.b); + }); + + it('preserves immutability — modifying clone does not affect original', () => { + const original = { a: { b: 1 } }; + const cloned = deepClone(original); + cloned.a.b = 99; + expect(original.a.b).toBe(1); + }); + + it('handles arrays', () => { + const original = { items: [1, 2, 3] }; + const cloned = deepClone(original); + cloned.items.push(4); + expect(original.items).toEqual([1, 2, 3]); + }); + + it('handles null values', () => { + const original = { a: null }; + const cloned = deepClone(original); + expect(cloned.a).toBeNull(); + }); +}); + +describe('updateNestedPath', () => { + it('updates a top-level key', () => { + const obj = { name: 'old' }; + const result = updateNestedPath(obj, 'name', 'new'); + expect(result.name).toBe('new'); + // Original unchanged + expect(obj.name).toBe('old'); + }); + + it('updates a nested key', () => { + const obj = { a: { b: { c: 1 } } }; + const result = updateNestedPath(obj as any, 'a.b.c', 42); + expect((result as any).a.b.c).toBe(42); + }); + + it('creates intermediate objects if they do not exist', () => { + const obj = {}; + const result = updateNestedPath(obj, 'x.y.z', 'created'); + expect((result as any).x.y.z).toBe('created'); + }); + + it('overwrites non-object intermediate values', () => { + const obj = { a: 'string' }; + const result = updateNestedPath(obj as any, 'a.b', 'value'); + expect((result as any).a.b).toBe('value'); + }); + + it('does not mutate the original object', () => { + const obj = { a: { b: 1 } }; + updateNestedPath(obj as any, 'a.b', 2); + expect(obj.a.b).toBe(1); + }); +}); diff --git a/apps/self-hosted/src/features/hosting/components/hosting-signup.tsx b/apps/self-hosted/src/features/hosting/components/hosting-signup.tsx new file mode 100644 index 0000000000..99d23c16e0 --- /dev/null +++ b/apps/self-hosted/src/features/hosting/components/hosting-signup.tsx @@ -0,0 +1,443 @@ +/** + * Hosting Signup Component + * + * Allows users to sign up for Ecency managed blog hosting + */ + +import { useState, useCallback } from 'react'; + +interface HostingSignupProps { + apiBaseUrl?: string; + onSuccess?: (data: { username: string; blogUrl: string }) => void; + onError?: (error: string) => void; +} + +interface PaymentInstructions { + to: string; + amount: string; + memo: string; +} + +type Step = 'username' | 'configure' | 'payment' | 'success'; + +const HIVE_USERNAME_RE = /^[a-z][a-z0-9.-]*$/; +const BLOCKCHAIN_CONFIRMATION_DELAY_MS = 5000; + +function isValidHttpUrl(url: string): boolean { + try { + const parsed = new URL(url); + return parsed.protocol === 'https:' || parsed.protocol === 'http:'; + } catch { + return false; + } +} + +async function fetchJson(url: string, init?: RequestInit): Promise { + const response = await fetch(url, init); + if (!response.ok) { + const body = await response.json().catch(() => ({})); + throw new Error(body.error || `Request failed (${response.status})`); + } + return response.json(); +} + +export function HostingSignup({ + apiBaseUrl = 'https://api.blogs.ecency.com/hosting', + onSuccess, + onError, +}: HostingSignupProps) { + const [step, setStep] = useState('username'); + const [username, setUsername] = useState(''); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const [paymentInstructions, setPaymentInstructions] = useState(null); + const [blogUrl, setBlogUrl] = useState(null); + const [isTransferring, setIsTransferring] = useState(false); + + // Config options + const [config, setConfig] = useState({ + theme: 'system' as 'light' | 'dark' | 'system', + styleTemplate: 'medium' as string, + type: 'blog' as 'blog' | 'community', + title: '', + description: '', + }); + + const statusUrl = `${apiBaseUrl}/v1/tenants/${encodeURIComponent(username)}/status`; + + const checkUsername = useCallback(async () => { + if (!username || username.length < 3) { + setError('Username must be at least 3 characters'); + return; + } + + if (username.length > 16 || !HIVE_USERNAME_RE.test(username)) { + setError('Username must be 3-16 characters, start with a letter, and contain only lowercase letters, numbers, dots, or hyphens'); + return; + } + + setIsLoading(true); + setError(null); + + try { + const data = await fetchJson(statusUrl); + + if (data.exists) { + setError('This username is already taken'); + return; + } + + // Move to configure step + setConfig((prev) => ({ ...prev, title: `${username}'s Blog` })); + setStep('configure'); + } catch (err: any) { + setError(err.message || 'Failed to check username. Please try again.'); + onError?.('Failed to check username'); + } finally { + setIsLoading(false); + } + }, [username, statusUrl, onError]); + + const createTenant = useCallback(async () => { + setIsLoading(true); + setError(null); + + try { + const data = await fetchJson(`${apiBaseUrl}/v1/tenants`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + username, + config: { + theme: config.theme, + styleTemplate: config.styleTemplate, + type: config.type, + title: config.title, + description: config.description, + }, + }), + }); + + if (!data.paymentInstructions || !data.tenant?.blogUrl) { + throw new Error('Invalid response from server. Please try again.'); + } + + setPaymentInstructions(data.paymentInstructions); + setBlogUrl(data.tenant.blogUrl); + setStep('payment'); + } catch (err: any) { + setError(err.message || 'Failed to create blog'); + onError?.(err.message); + } finally { + setIsLoading(false); + } + }, [username, config, apiBaseUrl, onError]); + + const checkPayment = useCallback(async () => { + setIsLoading(true); + setError(null); + + try { + const data = await fetchJson(statusUrl); + + if (data.subscriptionStatus === 'active') { + setStep('success'); + if (blogUrl) { + onSuccess?.({ username, blogUrl }); + } + } else { + setError('Payment not yet received. Please wait a few seconds and try again.'); + } + } catch (err: any) { + setError(err.message || 'Failed to check payment status'); + } finally { + setIsLoading(false); + setIsTransferring(false); + } + }, [username, blogUrl, statusUrl, onSuccess]); + + const sendPaymentWithKeychain = useCallback(async () => { + if (!paymentInstructions || isTransferring) return; + + // Check if Keychain is available + if (typeof window === 'undefined' || !(window as any).hive_keychain) { + setError('Hive Keychain extension not found. Please install it or send payment manually.'); + return; + } + + setIsTransferring(true); + setError(null); + const keychain = (window as any).hive_keychain; + const [amountStr] = paymentInstructions.amount.split(' '); + const amount = parseFloat(amountStr); + if (isNaN(amount) || amount <= 0) { + setError('Invalid payment amount. Please try again.'); + setIsTransferring(false); + return; + } + + keychain.requestTransfer( + username, + paymentInstructions.to, + amount.toFixed(3), + paymentInstructions.memo, + 'HBD', + (response: any) => { + if (response.success) { + setTimeout(checkPayment, BLOCKCHAIN_CONFIRMATION_DELAY_MS); + } else { + setError('Payment cancelled or failed'); + setIsTransferring(false); + } + } + ); + }, [username, paymentInstructions, checkPayment, isTransferring]); + + const isBusy = isLoading || isTransferring; + + return ( +
+

+ Create Your Hive Blog +

+ + {error && ( +
+ {error} +
+ )} + + {/* Step 1: Username */} + {step === 'username' && ( +
+
+ + setUsername(e.target.value.toLowerCase())} + placeholder="your-username" + className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-white" + disabled={isLoading} + /> +

+ Your blog will be at: {username || 'username'}.blogs.ecency.com +

+
+ + +
+ )} + + {/* Step 2: Configure */} + {step === 'configure' && ( +
+
+ + setConfig((prev) => ({ ...prev, title: e.target.value }))} + className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-white" + /> +
+ +
+ +