diff --git a/.env.development b/.env.development index 745a944e8..5b680e20b 100644 --- a/.env.development +++ b/.env.development @@ -1,7 +1,7 @@ PRISMA_SLOW_LOGS_MS= GRAPHQL_SLOW_LOGS_MS= NODE_ENV=development -COMPOSE_PROFILES='minimal,images,search,payments,wallets,email,capture' +COMPOSE_PROFILES='minimal,images,search,payments,wallets,email,capture,domains' ############################################################################ # OPTIONAL SECRETS # @@ -114,7 +114,7 @@ NEXT_PUBLIC_EXTRA_LONG_POLL_INTERVAL=300000 # containers can't use localhost, so we need to use the container name IMGPROXY_URL_DOCKER=http://imgproxy:8080 -MEDIA_URL_DOCKER=http://s3:4566/uploads +MEDIA_URL_DOCKER=http://aws:4566/uploads # postgres container stuff POSTGRES_PASSWORD=password @@ -177,6 +177,9 @@ AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY PERSISTENCE=1 SKIP_SSL_CERT_DOWNLOAD=1 +# custom domain ACM, ELBv2 +LOCALSTACK_ENDPOINT=http://aws:4566 +ELB_LISTENER_ARN=arn:aws:elasticloadbalancing:us-east-1:123456789012:listener/app/sndev-lb/1234567890abcdef/1234567890abcdef # tor proxy TOR_PROXY=http://tor:7050/ diff --git a/api/acm/index.js b/api/acm/index.js new file mode 100644 index 000000000..322849f88 --- /dev/null +++ b/api/acm/index.js @@ -0,0 +1,41 @@ +import AWS from 'aws-sdk' + +const config = { + region: 'us-east-1', + // for local development, we use the LOCALSTACK_ENDPOINT + endpoint: process.env.NODE_ENV === 'development' ? process.env.LOCALSTACK_ENDPOINT : undefined +} + +export async function requestCertificate (domain) { + const acm = new AWS.ACM(config) + const params = { + DomainName: domain, + ValidationMethod: 'DNS', + Tags: [ + { + Key: 'ManagedBy', + Value: 'stacker.news' + } + ] + } + + const certificate = await acm.requestCertificate(params).promise() + return certificate.CertificateArn +} + +export async function describeCertificate (certificateArn) { + const acm = new AWS.ACM(config) + const certificate = await acm.describeCertificate({ CertificateArn: certificateArn }).promise() + return certificate +} + +export async function getCertificateStatus (certificateArn) { + const certificate = await describeCertificate(certificateArn) + return certificate.Certificate.Status +} + +export async function deleteCertificate (certificateArn) { + const acm = new AWS.ACM(config) + const result = await acm.deleteCertificate({ CertificateArn: certificateArn }).promise() + return result +} diff --git a/api/elb/index.js b/api/elb/index.js new file mode 100644 index 000000000..eced0da12 --- /dev/null +++ b/api/elb/index.js @@ -0,0 +1,42 @@ +import AWS from 'aws-sdk' +import { MockELBv2 } from './mocks' + +const ELB_LISTENER_ARN = process.env.ELB_LISTENER_ARN + +const config = { + region: 'us-east-1' +} + +// attach a certificate to the elb listener +async function attachCertificateToElb (certificateArn) { + const elbv2 = process.env.NODE_ENV === 'development' + ? new MockELBv2() // use the mocked elb for local development + : new AWS.ELBv2(config) + + // attach the certificate + // AWS doesn't throw an error if the certificate is already attached to the listener + await elbv2.addListenerCertificates({ + ListenerArn: ELB_LISTENER_ARN, + Certificates: [{ CertificateArn: certificateArn }] + }).promise() + + return true +} + +// detach a certificate from the elb listener +async function detachCertificateFromElb (certificateArn) { + const elbv2 = process.env.NODE_ENV === 'development' + ? new MockELBv2() // use the mocked elb for local development + : new AWS.ELBv2(config) + + // detach the certificate + // AWS doesn't throw an error if the certificate is not attached to the listener + await elbv2.removeListenerCertificates({ + ListenerArn: ELB_LISTENER_ARN, + Certificates: [{ CertificateArn: certificateArn }] + }).promise() + + return true +} + +export { attachCertificateToElb, detachCertificateFromElb } diff --git a/api/elb/mocks.js b/api/elb/mocks.js new file mode 100644 index 000000000..d0fa434d1 --- /dev/null +++ b/api/elb/mocks.js @@ -0,0 +1,120 @@ +// mock Load Balancers and Listeners for testing +const mockedElb = { + loadBalancers: [ + { + LoadBalancerArn: 'arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/sndev-lb/1234567890abcdef', + DNSName: 'sndev-lb.us-east-1.elb.amazonaws.com', + LoadBalancerName: 'sndev-lb', + Type: 'application' + } + ], + listeners: [ + { + ListenerArn: 'arn:aws:elasticloadbalancing:us-east-1:123456789012:listener/app/sndev-lb/1234567890abcdef/1234567890abcdef', + LoadBalancerArn: 'arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/sndev-lb/1234567890abcdef', + Protocol: 'HTTPS', + Port: 443 + } + ], + certificates: [] +} + +// mock AWS.ELBv2 class +class MockELBv2 { + // mock describeLoadBalancers + // return the load balancers that match the names in the params + describeLoadBalancers (params) { + const { Names } = params + let loadBalancers = [...mockedElb.loadBalancers] + + if (Names && Names.length > 0) { + loadBalancers = loadBalancers.filter(lb => Names.includes(lb.LoadBalancerName)) + } + + return { + promise: () => Promise.resolve({ LoadBalancers: loadBalancers }) + } + } + + // mock describeListeners + // return the listeners that match the load balancer arn in the params + describeListeners (params) { + const { LoadBalancerArn, Filters } = params + let listeners = [...mockedElb.listeners] + + if (LoadBalancerArn) { + listeners = listeners.filter(listener => listener.LoadBalancerArn === LoadBalancerArn) + } + + if (Filters && Filters.length > 0) { + Filters.forEach(filter => { + if (filter.Name === 'protocol' && filter.Values) { + listeners = listeners.filter(listener => + filter.Values.includes(listener.Protocol) + ) + } + }) + } + + return { + promise: () => Promise.resolve({ Listeners: listeners }) + } + } + + // mock describeListenerCertificates + // return the certificates that match the listener arn in the params + describeListenerCertificates (params) { + const { ListenerArn } = params + const certificates = mockedElb.certificates + .filter(cert => cert.ListenerArn === ListenerArn) + .map(cert => ({ CertificateArn: cert.CertificateArn })) + + return { + promise: () => Promise.resolve({ Certificates: certificates }) + } + } + + // mock addListenerCertificates + // add the certificates to the mockedElb.certificates + addListenerCertificates (params) { + const { ListenerArn, Certificates } = params + + Certificates.forEach(cert => { + // ELBv2 checks if the certificate is already attached to the listener + // and doesn't add it again if it is + const exists = mockedElb.certificates.some( + c => c.ListenerArn === ListenerArn && c.CertificateArn === cert.CertificateArn + ) + + if (!exists) { + mockedElb.certificates.push({ + ListenerArn, + CertificateArn: cert.CertificateArn + }) + } + }) + + return { + promise: () => Promise.resolve({}) + } + } + + // mock removeListenerCertificates + // remove the certificates from the mockedElb.certificates + // AWS doesn't throw an error if the certificate is not attached to the listener + removeListenerCertificates (params) { + const { ListenerArn, Certificates } = params + + Certificates.forEach(cert => { + mockedElb.certificates = mockedElb.certificates.filter( + c => !(c.ListenerArn === ListenerArn && c.CertificateArn === cert.CertificateArn) + ) + }) + + return { + promise: () => Promise.resolve({}) + } + } +} + +export { MockELBv2, mockedElb } diff --git a/api/resolvers/domain.js b/api/resolvers/domain.js new file mode 100644 index 000000000..2ed31d1af --- /dev/null +++ b/api/resolvers/domain.js @@ -0,0 +1,140 @@ +import { validateSchema, customDomainSchema } from '@/lib/validate' +import { GqlAuthenticationError, GqlInputError } from '@/lib/error' +import { getDomainMapping } from '@/lib/domains' +import { SN_ADMIN_IDS } from '@/lib/constants' + +async function cleanDomainVerificationJobs (domain, models) { + // delete any existing domain verification job left + await models.$queryRaw` + DELETE FROM pgboss.job + WHERE name = 'domainVerification' + AND data->>'domainId' = ${domain.id}::TEXT` +} + +export default { + Query: { + domain: async (parent, { subName }, { models }) => { + return models.domain.findUnique({ + where: { subName }, + include: { records: true, attempts: true, certificate: true } + }) + }, + domainMapping: async (parent, { domainName }, { models }) => { + const mapping = await getDomainMapping(domainName) + return mapping + } + }, + Mutation: { + setDomain: async (parent, { subName, domainName }, { me, models }) => { + if (!me) { + throw new GqlAuthenticationError() + } + + if (!SN_ADMIN_IDS.includes(Number(me.id))) { + throw new Error('not an admin') + } + + const sub = await models.sub.findUnique({ where: { name: subName } }) + if (!sub) { + throw new GqlInputError('sub not found') + } + + if (sub.userId !== me.id) { + throw new GqlInputError('you do not own this sub') + } + + // we need to get the existing domain if we're updating or re-verifying + const existing = await models.domain.findUnique({ + where: { subName }, + include: { records: true } + }) + + if (domainName) { + // validate the domain name + domainName = domainName.trim() // protect against trailing spaces + await validateSchema(customDomainSchema, { domainName }) + + // updating the domain name and recovering from HOLD is allowed + if (existing && existing.domainName === domainName && existing.status !== 'HOLD') { + throw new GqlInputError('domain already set') + } + + // we should always make sure to get a new updatedAt timestamp + // to know when should we put the domain in HOLD during verification + const initializeDomain = { + domainName, + updatedAt: new Date(), + status: 'PENDING' + } + + const updatedDomain = await models.$transaction(async tx => { + // we're changing the domain name, delete the domain if it exists + if (existing) { + // delete any existing domain verification job left + await cleanDomainVerificationJobs(existing, tx) + // delete the domain if we're not resuming from HOLD + if (existing.status !== 'HOLD') { + await tx.domain.delete({ where: { subName } }) + } + } + + const domain = await tx.domain.create({ + data: { + ...initializeDomain, + sub: { connect: { name: subName } } + } + }) + + // create the CNAME verification record + await tx.domainVerificationRecord.create({ + data: { + domainId: domain.id, + type: 'CNAME', + recordName: domainName, + recordValue: new URL(process.env.NEXT_PUBLIC_URL).host + } + }) + + // create the job to verify the domain in 30 seconds + await tx.$executeRaw` + INSERT INTO pgboss.job (name, data, retrylimit, retrydelay, startafter, keepuntil, singletonkey) + VALUES ('domainVerification', + jsonb_build_object('domainId', ${domain.id}::INTEGER), + 3, + 60, + now() + interval '30 seconds', + now() + interval '2 days', + 'domainVerification:' || ${domain.id}::TEXT -- domain <-> job isolation + )` + + return domain + }) + + return updatedDomain + } else { + try { + if (existing) { + return await models.$transaction(async tx => { + // delete any existing domain verification job left + await cleanDomainVerificationJobs(existing, tx) + // delete the domain + return await tx.domain.delete({ where: { subName } }) + }) + } + return null + } catch (error) { + console.error(error) + throw new GqlInputError('failed to delete domain') + } + } + } + }, + Domain: { + records: async (domain) => { + if (!domain.records) return [] + + // O(1) lookups by type, simpler checks for CNAME and ACM validation records + return Object.fromEntries(domain.records.map(record => [record.type, record])) + } + } +} diff --git a/api/resolvers/index.js b/api/resolvers/index.js index eccfaf1d0..b503518b6 100644 --- a/api/resolvers/index.js +++ b/api/resolvers/index.js @@ -20,6 +20,7 @@ import { GraphQLScalarType, Kind } from 'graphql' import { createIntScalar } from 'graphql-scalar' import paidAction from './paidAction' import vault from './vault' +import domain from './domain' const date = new GraphQLScalarType({ name: 'Date', @@ -56,4 +57,4 @@ const limit = createIntScalar({ export default [user, item, message, wallet, lnurl, notifications, invite, sub, upload, search, growth, rewards, referrals, price, admin, blockHeight, chainFee, - { JSONObject }, { Date: date }, { Limit: limit }, paidAction, vault] + domain, { JSONObject }, { Date: date }, { Limit: limit }, paidAction, vault] diff --git a/api/resolvers/sub.js b/api/resolvers/sub.js index fef5ea737..a9db685a4 100644 --- a/api/resolvers/sub.js +++ b/api/resolvers/sub.js @@ -1,5 +1,5 @@ import { whenRange } from '@/lib/time' -import { validateSchema, territorySchema } from '@/lib/validate' +import { validateSchema, territorySchema, subBrandingSchema } from '@/lib/validate' import { decodeCursor, LIMIT, nextCursorEncoded } from '@/lib/cursor' import { viewGroup } from './growth' import { notifyTerritoryTransfer } from '@/lib/webPush' @@ -170,6 +170,9 @@ export default { cursor: subs.length === limit ? nextCursorEncoded(decodedCursor, limit) : null, subs } + }, + subBranding: async (parent, { subName }, { models }) => { + return models.subBranding.findUnique({ where: { subName } }) } }, Mutation: { @@ -298,6 +301,42 @@ export default { } return await performPaidAction('TERRITORY_UNARCHIVE', data, { me, models, lnd }) + }, + upsertSubBranding: async (parent, { subName, branding }, { me, models }) => { + if (!me) { + throw new GqlAuthenticationError() + } + + const sub = await models.sub.findUnique({ where: { name: subName } }) + if (!sub) { + throw new GqlInputError('sub not found') + } + + if (sub.userId !== me.id) { + throw new GqlInputError('you do not own this sub') + } + + await validateSchema(subBrandingSchema, branding) + + if (branding.logoId) { + const upload = await models.upload.findUnique({ where: { id: Number(branding.logoId) } }) + if (!upload) { + throw new GqlInputError('logo not found') + } + } + + if (branding.faviconId) { + const upload = await models.upload.findUnique({ where: { id: Number(branding.faviconId) } }) + if (!upload) { + throw new GqlInputError('favicon not found') + } + } + + return await models.subBranding.upsert({ + where: { subName }, + update: { ...branding, subName }, + create: { ...branding, subName } + }) } }, Sub: { @@ -331,6 +370,12 @@ export default { return sub.SubSubscription?.length > 0 }, + domain: async (sub, args, { models }) => { + return models.domain.findUnique({ where: { subName: sub.name } }) + }, + branding: async (sub, args, { models }) => { + return models.subBranding.findUnique({ where: { subName: sub.name } }) + }, createdAt: sub => sub.createdAt || sub.created_at } } diff --git a/api/ssrApollo.js b/api/ssrApollo.js index d5513b7d9..6b807f059 100644 --- a/api/ssrApollo.js +++ b/api/ssrApollo.js @@ -16,6 +16,7 @@ import { getAuthOptions } from '@/pages/api/auth/[...nextauth]' import { NOFOLLOW_LIMIT } from '@/lib/constants' import { satsToMsats } from '@/lib/format' import { MULTI_AUTH_ANON, MULTI_AUTH_LIST } from '@/lib/auth' +import { SUB_BRANDING } from '@/fragments/subs' export default async function getSSRApolloClient ({ req, res, me = null }) { const session = req && await getServerSession(req, res, getAuthOptions(req)) @@ -152,6 +153,22 @@ export function getGetServerSideProps ( const client = await getSSRApolloClient({ req, res }) + const isCustomDomain = req.headers.host !== process.env.NEXT_PUBLIC_URL.replace(/^https?:\/\//, '') + const subName = req.headers['x-stacker-news-subname'] || null + let domain = null + if (isCustomDomain && subName) { + const { data: { subBranding } } = await client.query({ + query: SUB_BRANDING, + variables: { subName } + }) + console.log('subBranding', subBranding) + domain = { + domainName: req.headers.host, + subName, + branding: subBranding || null + } + } + let { data: { me } } = await client.query({ query: ME }) // required to redirect to /signup on page reload @@ -216,6 +233,7 @@ export function getGetServerSideProps ( return { props: { ...props, + domain, me, price, blockHeight, diff --git a/api/typeDefs/domain.js b/api/typeDefs/domain.js new file mode 100644 index 000000000..83b956e2e --- /dev/null +++ b/api/typeDefs/domain.js @@ -0,0 +1,100 @@ +import { gql } from 'graphql-tag' + +export default gql` + extend type Query { + domain(subName: String!): Domain + domainMapping(domainName: String!): DomainMapping + } + + extend type Mutation { + setDomain(subName: String!, domainName: String!): Domain + } + + type Domain { + id: Int! + createdAt: Date! + updatedAt: Date! + domainName: String! + subName: String! + status: DomainVerificationStatus + records: DomainVerificationRecordMap + attempts: [DomainVerificationAttempt] + certificate: DomainCertificate + } + + type DomainVerificationRecord { + id: Int! + createdAt: Date! + updatedAt: Date! + lastCheckedAt: Date + domainId: Int! + type: DomainRecordType + recordName: String! + recordValue: String! + status: DomainVerificationStatus + attempts: [DomainVerificationAttempt] + } + + type DomainVerificationRecordMap { + CNAME: DomainVerificationRecord + SSL: DomainVerificationRecord + } + + type DomainVerificationAttempt { + id: Int! + createdAt: Date! + updatedAt: Date! + domainId: Int! + verificationRecordId: Int + stage: DomainVerificationStage + status: DomainVerificationStatus + message: String + } + + type DomainCertificate { + id: Int! + createdAt: Date! + updatedAt: Date! + domainId: Int! + certificateArn: String! + status: DomainCertificateStatus + } + + enum DomainVerificationStage { + GENERAL + CNAME + ACM_REQUEST_CERTIFICATE + ACM_REQUEST_VALIDATION_VALUES + ACM_VALIDATION + ELB_ATTACH_CERTIFICATE + VERIFICATION_COMPLETE + } + + enum DomainRecordType { + CNAME + SSL + } + + enum DomainVerificationStatus { + PENDING + VERIFIED + FAILED + ACTIVE + HOLD + } + + enum DomainCertificateStatus { + PENDING_VALIDATION + ISSUED + INACTIVE + EXPIRED + REVOKED + FAILED + VALIDATION_TIMED_OUT + } + + type DomainMapping { + domainName: String! + subName: String! + } +` diff --git a/api/typeDefs/index.js b/api/typeDefs/index.js index eb4e1e427..0acd7dc44 100644 --- a/api/typeDefs/index.js +++ b/api/typeDefs/index.js @@ -19,6 +19,7 @@ import blockHeight from './blockHeight' import chainFee from './chainFee' import paidAction from './paidAction' import vault from './vault' +import domain from './domain' const common = gql` type Query { @@ -39,4 +40,4 @@ const common = gql` ` export default [common, user, item, itemForward, message, wallet, lnurl, notifications, invite, - sub, upload, growth, rewards, referrals, price, admin, blockHeight, chainFee, paidAction, vault] + sub, upload, growth, rewards, referrals, price, admin, blockHeight, chainFee, domain, paidAction, vault] diff --git a/api/typeDefs/sub.js b/api/typeDefs/sub.js index 6167d5350..fe3d56027 100644 --- a/api/typeDefs/sub.js +++ b/api/typeDefs/sub.js @@ -8,6 +8,7 @@ export default gql` topSubs(cursor: String, when: String, from: String, to: String, by: String, limit: Limit): Subs userSubs(name: String!, cursor: String, when: String, from: String, to: String, by: String, limit: Limit): Subs subSuggestions(q: String!, limit: Limit): [Sub!]! + subBranding(subName: String!): SubBranding } type Subs { @@ -29,6 +30,7 @@ export default gql` replyCost: Int!, postTypes: [String!]!, billingType: String!, billingAutoRenew: Boolean!, moderated: Boolean!, nsfw: Boolean!): SubPaidAction! + upsertSubBranding(subName: String!, branding: SubBrandingInput): SubBranding! } type Sub { @@ -56,7 +58,8 @@ export default gql` nposts(when: String, from: String, to: String): Int! ncomments(when: String, from: String, to: String): Int! meSubscription: Boolean! - + domain: Domain + branding: SubBranding optional: SubOptional! } @@ -68,4 +71,24 @@ export default gql` spent(when: String, from: String, to: String): Int revenue(when: String, from: String, to: String): Int } + + type SubBranding { + id: Int! + subName: String! + title: String + description: String + logoId: Int + faviconId: Int + primaryColor: String + secondaryColor: String + } + + input SubBrandingInput { + title: String + description: String + logoId: Int + faviconId: Int + primaryColor: String + secondaryColor: String + } ` diff --git a/components/form.js b/components/form.js index e28d64c3c..77fb2a1ce 100644 --- a/components/form.js +++ b/components/form.js @@ -24,7 +24,7 @@ import textAreaCaret from 'textarea-caret' import 'react-datepicker/dist/react-datepicker.css' import useDebounceCallback, { debounce } from './use-debounce-callback' import { FileUpload } from './file-upload' -import { AWS_S3_URL_REGEXP } from '@/lib/constants' +import { AWS_S3_URL_REGEXP, MEDIA_URL } from '@/lib/constants' import { whenRange } from '@/lib/time' import { useFeeButton } from './fee-button' import Thumb from '@/svgs/thumb-up-fill.svg' @@ -42,6 +42,7 @@ import dynamic from 'next/dynamic' import { qrImageSettings } from './qr' import { useIsClient } from './use-client' import PageLoading from './page-loading' +import Avatar from './avatar' export class SessionRequiredError extends Error { constructor () { @@ -78,7 +79,7 @@ export function SubmitButton ({ ) } -function CopyButton ({ value, icon, ...props }) { +export function CopyButton ({ value, icon, append, ...props }) { const toaster = useToast() const [copied, setCopied] = useState(false) @@ -101,6 +102,14 @@ function CopyButton ({ value, icon, ...props }) { ) } + if (append) { + return ( + + {append} + + ) + } + return (