diff --git a/.env.development b/.env.development index 7572da564..e82cad44a 100644 --- a/.env.development +++ b/.env.development @@ -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,7 @@ AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY PERSISTENCE=1 SKIP_SSL_CERT_DOWNLOAD=1 +LOCALSTACK_ENDPOINT=http://localhost:4566 # tor proxy TOR_PROXY=http://tor:7050/ @@ -190,4 +191,10 @@ CPU_SHARES_IMPORTANT=1024 CPU_SHARES_MODERATE=512 CPU_SHARES_LOW=256 -NEXT_TELEMETRY_DISABLED=1 \ No newline at end of file +NEXT_TELEMETRY_DISABLED=1 + +# DNS resolver for custom domain verification +DNS_RESOLVER=1.1.1.1 + +# domain debug logger +CUSTOM_DOMAIN_LOGGER=false \ No newline at end of file diff --git a/.env.production b/.env.production index 78e66ab81..e0a4a58d6 100644 --- a/.env.production +++ b/.env.production @@ -23,3 +23,6 @@ DB_APP_CONNECTION_LIMIT=4 DB_WORKER_CONNECTION_LIMIT=2 DB_TRANSACTION_TIMEOUT=10000 NEXT_TELEMETRY_DISABLED=1 + +# DNS resolver for custom domain verification +DNS_RESOLVER=1.1.1.1 \ No newline at end of file diff --git a/api/acm/index.js b/api/acm/index.js new file mode 100644 index 000000000..cf76f4160 --- /dev/null +++ b/api/acm/index.js @@ -0,0 +1,53 @@ +import AWS from 'aws-sdk' + +AWS.config.update({ + region: 'us-east-1' +}) + +const config = {} + +export async function requestCertificate (domain) { + // for local development, we use the LOCALSTACK_ENDPOINT + if (process.env.NODE_ENV === 'development') { + config.endpoint = process.env.LOCALSTACK_ENDPOINT + } + + 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) { + if (process.env.NODE_ENV === 'development') { + config.endpoint = process.env.LOCALSTACK_ENDPOINT + } + 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) { + if (process.env.NODE_ENV === 'development') { + config.endpoint = process.env.LOCALSTACK_ENDPOINT + } + const acm = new AWS.ACM(config) + const result = await acm.deleteCertificate({ CertificateArn: certificateArn }).promise() + console.log(`delete certificate attempt for ${certificateArn}, result: ${JSON.stringify(result)}`) + return result +} diff --git a/api/resolvers/branding.js b/api/resolvers/branding.js new file mode 100644 index 000000000..d48774e45 --- /dev/null +++ b/api/resolvers/branding.js @@ -0,0 +1,75 @@ +import { GqlAuthenticationError, GqlInputError } from '@/lib/error' +import { validateSchema, customBrandingSchema } from '@/lib/validate' + +export default { + Query: { + customBranding: async (parent, { subName }, { models }) => { + return models.customBranding.findUnique({ where: { subName } }) + } + }, + Mutation: { + setCustomBranding: 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') + } + + const { title, colors, logoId, faviconId } = branding + + const parsedLogoId = parseInt(logoId) + const parsedFaviconId = parseInt(faviconId) + + if (parsedLogoId) { + const logo = await models.upload.findUnique({ where: { id: parsedLogoId } }) + if (!logo) { + throw new GqlInputError('logo not found') + } + } + + if (parsedFaviconId) { + const favicon = await models.upload.findUnique({ where: { id: parsedFaviconId } }) + if (!favicon) { + throw new GqlInputError('favicon not found') + } + } + + if (!validateSchema(customBrandingSchema, { + title, + primary: colors.primary, + secondary: colors.secondary, + info: colors.info, + success: colors.success, + danger: colors.danger, + logoId, + faviconId + })) { + throw new GqlInputError('invalid branding') + } + + return await models.customBranding.upsert({ + where: { subName }, + update: { + title: title || subName, + colors, + ...(parsedLogoId && { logo: { connect: { id: parsedLogoId } } }), + ...(parsedFaviconId && { favicon: { connect: { id: parsedFaviconId } } }) + }, + create: { + title: title || subName, + colors, + ...(parsedLogoId && { logo: { connect: { id: parsedLogoId } } }), + ...(parsedFaviconId && { favicon: { connect: { id: parsedFaviconId } } }), + sub: { connect: { name: subName } } + } + }) + } + } +} diff --git a/api/resolvers/domain.js b/api/resolvers/domain.js new file mode 100644 index 000000000..2cb466edc --- /dev/null +++ b/api/resolvers/domain.js @@ -0,0 +1,105 @@ +import { validateSchema, customDomainSchema } from '@/lib/validate' +import { GqlAuthenticationError, GqlInputError } from '@/lib/error' +import { randomBytes } from 'node:crypto' +import { getDomainMappingsCache } from '@/lib/domains' + +export default { + Query: { + customDomain: async (parent, { subName }, { models }) => { + return models.customDomain.findUnique({ where: { subName } }) + }, + domainMapping: async (parent, { domain }, { models }) => { + const domainMappings = await getDomainMappingsCache() + return domainMappings?.[domain] + } + }, + Mutation: { + setCustomDomain: async (parent, { subName, domain }, { 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') + } + + domain = domain.trim() // protect against trailing spaces + if (domain && !validateSchema(customDomainSchema, { domain })) { + throw new GqlInputError('invalid domain format') + } + + const existing = await models.customDomain.findUnique({ where: { subName } }) + + if (domain) { + if (existing && existing.domain === domain && existing.status !== 'HOLD') { + throw new GqlInputError('domain already set') + } + + const initializeDomain = { + domain, + createdAt: new Date(), + status: 'PENDING', + verification: { + dns: { + state: 'PENDING', + cname: 'stacker.news', + // generate a random txt record only if it's a new domain + txt: existing?.domain === domain && existing.verification.dns.txt + ? existing.verification.dns.txt + : randomBytes(32).toString('base64') + }, + ssl: { + state: 'WAITING', + arn: null, + cname: null, + value: null + } + } + } + + const updatedDomain = await models.customDomain.upsert({ + where: { subName }, + update: { + ...initializeDomain + }, + create: { + ...initializeDomain, + sub: { + connect: { name: subName } + } + } + }) + + // schedule domain verification in 30 seconds + await models.$executeRaw` + INSERT INTO pgboss.job (name, data, retrylimit, retrydelay, startafter, keepuntil) + VALUES ('domainVerification', + jsonb_build_object('domainId', ${updatedDomain.id}::INTEGER), + 3, + 30, + now() + interval '30 seconds', + now() + interval '2 days')` + + return updatedDomain + } else { + try { + // delete any existing domain verification jobs + await models.$queryRaw` + DELETE FROM pgboss.job + WHERE name = 'domainVerification' + AND data->>'domainId' = ${existing.id}::TEXT` + + return await models.customDomain.delete({ where: { subName } }) + } catch (error) { + console.error(error) + throw new GqlInputError('failed to delete domain') + } + } + } + } +} diff --git a/api/resolvers/index.js b/api/resolvers/index.js index eccfaf1d0..e3a11dc36 100644 --- a/api/resolvers/index.js +++ b/api/resolvers/index.js @@ -20,6 +20,8 @@ import { GraphQLScalarType, Kind } from 'graphql' import { createIntScalar } from 'graphql-scalar' import paidAction from './paidAction' import vault from './vault' +import domain from './domain' +import branding from './branding' const date = new GraphQLScalarType({ name: 'Date', @@ -56,4 +58,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, branding, { JSONObject }, { Date: date }, { Limit: limit }, paidAction, vault] diff --git a/api/resolvers/sub.js b/api/resolvers/sub.js index 320670b66..09918be98 100644 --- a/api/resolvers/sub.js +++ b/api/resolvers/sub.js @@ -310,6 +310,12 @@ export default { return sub.SubSubscription?.length > 0 }, + customDomain: async (sub, args, { models }) => { + return models.customDomain.findUnique({ where: { subName: sub.name } }) + }, + customBranding: async (sub, args, { models }) => { + return models.customBranding.findUnique({ where: { subName: sub.name } }) + }, createdAt: sub => sub.createdAt || sub.created_at } } diff --git a/api/ssrApollo.js b/api/ssrApollo.js index d5513b7d9..6ce7c77dc 100644 --- a/api/ssrApollo.js +++ b/api/ssrApollo.js @@ -11,6 +11,7 @@ import { ME } from '@/fragments/users' import { PRICE } from '@/fragments/price' import { BLOCK_HEIGHT } from '@/fragments/blockHeight' import { CHAIN_FEE } from '@/fragments/chainFee' +import { GET_CUSTOM_BRANDING } from '@/fragments/brandings' import { getServerSession } from 'next-auth/next' import { getAuthOptions } from '@/pages/api/auth/[...nextauth]' import { NOFOLLOW_LIMIT } from '@/lib/constants' @@ -152,6 +153,18 @@ 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 customDomain = null + if (isCustomDomain && subName) { + const { data: { customBranding } } = await client.query({ query: GET_CUSTOM_BRANDING, variables: { subName } }) + customDomain = { + domain: req.headers.host, + subName, + branding: customBranding || null + } + } + let { data: { me } } = await client.query({ query: ME }) // required to redirect to /signup on page reload @@ -216,6 +229,7 @@ export function getGetServerSideProps ( return { props: { ...props, + customDomain, me, price, blockHeight, diff --git a/api/typeDefs/branding.js b/api/typeDefs/branding.js new file mode 100644 index 000000000..085955eb8 --- /dev/null +++ b/api/typeDefs/branding.js @@ -0,0 +1,27 @@ +import { gql } from 'graphql-tag' + +export default gql` + extend type Query { + customBranding(subName: String!): CustomBranding + } + + extend type Mutation { + setCustomBranding(subName: String!, branding: CustomBrandingInput!): CustomBranding + } + + type CustomBranding { + title: String + colors: JSONObject + logoId: String + faviconId: String + subName: String + } + + input CustomBrandingInput { + title: String + colors: JSONObject + logoId: String + faviconId: String + subName: String + } +` diff --git a/api/typeDefs/domain.js b/api/typeDefs/domain.js new file mode 100644 index 000000000..d7c02c1e2 --- /dev/null +++ b/api/typeDefs/domain.js @@ -0,0 +1,46 @@ +import { gql } from 'graphql-tag' + +export default gql` + extend type Query { + customDomain(subName: String!): CustomDomain + domainMapping(domain: String!): DomainMapping + } + + extend type Mutation { + setCustomDomain(subName: String!, domain: String!): CustomDomain + } + + type CustomDomain { + createdAt: Date! + updatedAt: Date! + domain: String! + subName: String! + lastVerifiedAt: Date + failedAttempts: Int + status: String + verification: CustomDomainVerification + } + + type DomainMapping { + domain: String! + subName: String! + } + + type CustomDomainVerification { + dns: CustomDomainVerificationDNS + ssl: CustomDomainVerificationSSL + } + + type CustomDomainVerificationDNS { + state: String + cname: String + txt: String + } + + type CustomDomainVerificationSSL { + state: String + arn: String + cname: String + value: String + } +` diff --git a/api/typeDefs/index.js b/api/typeDefs/index.js index eb4e1e427..5119ec7ee 100644 --- a/api/typeDefs/index.js +++ b/api/typeDefs/index.js @@ -19,6 +19,8 @@ import blockHeight from './blockHeight' import chainFee from './chainFee' import paidAction from './paidAction' import vault from './vault' +import domain from './domain' +import branding from './branding' const common = gql` type Query { @@ -39,4 +41,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, branding, paidAction, vault] diff --git a/api/typeDefs/sub.js b/api/typeDefs/sub.js index 8401f1854..9e612a222 100644 --- a/api/typeDefs/sub.js +++ b/api/typeDefs/sub.js @@ -55,7 +55,8 @@ export default gql` nposts(when: String, from: String, to: String): Int! ncomments(when: String, from: String, to: String): Int! meSubscription: Boolean! - + customDomain: CustomDomain + customBranding: CustomBranding optional: SubOptional! } diff --git a/components/form.js b/components/form.js index c429ab7c9..16753aecf 100644 --- a/components/form.js +++ b/components/form.js @@ -23,7 +23,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' @@ -41,6 +41,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 () { @@ -77,7 +78,7 @@ export function SubmitButton ({ ) } -function CopyButton ({ value, icon, ...props }) { +export function CopyButton ({ value, icon, append, ...props }) { const toaster = useToast() const [copied, setCopied] = useState(false) @@ -100,6 +101,14 @@ function CopyButton ({ value, icon, ...props }) { ) } + if (append) { + return ( + + {append} + + ) + } + return (