diff --git a/.env.example b/.env.example index 9971fe5c..75876d02 100644 --- a/.env.example +++ b/.env.example @@ -21,4 +21,9 @@ AUTH_COOKIE_NAME=proselog.token ENCRYPT_SECRET=uIeqyCW1E5PbK2ztsc+UXcPwsZ0L/Z4k08ArOSaKpHHFViRbdnFJcQ== MAILGUN_APIKEY= -MAILGUN_DOMAIN= +# use separate domain for transcantional email / newsletter email +MAILGUN_DOMAIN_TRANSANCTION= +MAILGUN_DOMAIN_NEWSLETTER= + +# access token for our umami instance +UMAMI_TOKEN= diff --git a/Dockerfile b/Dockerfile index 57bbce87..9806eb45 100644 --- a/Dockerfile +++ b/Dockerfile @@ -24,10 +24,8 @@ COPY --from=deps /app /app ENV NEXT_TELEMETRY_DISABLED 1 ENV NODE_ENV production +ENV BUILD_STEP=1 RUN pnpm build -# Remove devDependencies -RUN pnpm prune --prod - ##### FINAL FROM base @@ -35,10 +33,9 @@ FROM base ENV NODE_ENV=production WORKDIR /app -COPY --from=build /app/node_modules /app/node_modules -COPY --from=build /app/package.json /app/package.json -COPY --from=build /app/.next /app/.next +COPY --from=build /app/.next/standalone /app COPY --from=build /app/public /app/public +COPY --from=build /app/.next/static /app/.next/static CMD ["pnpm", "start"] diff --git a/next.config.js b/next.config.js index e76b5880..5b32e91d 100644 --- a/next.config.js +++ b/next.config.js @@ -7,5 +7,6 @@ module.exports = { }, experimental: { scrollRestoration: true, + outputStandalone: true, }, } diff --git a/package.json b/package.json index 7f0e8e1f..c96b1b47 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ "test-unit": "uvu src .test.ts -r esbuild-register", "test-e2e": "playwright test", "db-generate": "prisma generate", - "start": "next start --port $PORT", + "start": "node server.js", "db-seed": "node -r esbuild-register prisma/seed.ts" }, "dependencies": { @@ -19,7 +19,6 @@ "@codemirror/language": "^0.20.0", "@codemirror/state": "^0.20.0", "@codemirror/view": "^0.20.3", - "@hapi/iron": "^6.0.0", "@headlessui/react": "^1.6.0", "@prisma/client": "^3.14.0", "@proselog/jwt": "^0.1.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b5f277a9..9585a78a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6,7 +6,6 @@ specifiers: '@codemirror/language': ^0.20.0 '@codemirror/state': ^0.20.0 '@codemirror/view': ^0.20.3 - '@hapi/iron': ^6.0.0 '@headlessui/react': ^1.6.0 '@iconify/icons-mdi': ^1.2.15 '@iconify/react': ^3.2.1 @@ -86,7 +85,6 @@ dependencies: '@codemirror/language': 0.20.0 '@codemirror/state': 0.20.0 '@codemirror/view': 0.20.4 - '@hapi/iron': 6.0.0 '@headlessui/react': 1.6.1_ef5jwxihqo6n7gxfmzogljlgcm '@iconify/icons-mdi': 1.2.15 '@iconify/react': 3.2.1 @@ -455,43 +453,6 @@ packages: - supports-color dev: true - /@hapi/b64/5.0.0: - resolution: {integrity: sha512-ngu0tSEmrezoiIaNGG6rRvKOUkUuDdf4XTPnONHGYfSGRmDqPZX5oJL6HAdKTo1UQHECbdB4OzhWrfgVppjHUw==} - dependencies: - '@hapi/hoek': 9.3.0 - dev: false - - /@hapi/boom/9.1.4: - resolution: {integrity: sha512-Ls1oH8jaN1vNsqcaHVYJrKmgMcKsC1wcp8bujvXrHaAqD2iDYq3HoOwsxwo09Cuda5R5nC0o0IxlrlTuvPuzSw==} - dependencies: - '@hapi/hoek': 9.3.0 - dev: false - - /@hapi/bourne/2.1.0: - resolution: {integrity: sha512-i1BpaNDVLJdRBEKeJWkVO6tYX6DMFBuwMhSuWqLsY4ufeTKGVuV5rBsUhxPayXqnnWHgXUAmWK16H/ykO5Wj4Q==} - dev: false - - /@hapi/cryptiles/5.1.0: - resolution: {integrity: sha512-fo9+d1Ba5/FIoMySfMqPBR/7Pa29J2RsiPrl7bkwo5W5o+AN1dAYQRi4SPrPwwVxVGKjgLOEWrsvt1BonJSfLA==} - engines: {node: '>=12.0.0'} - dependencies: - '@hapi/boom': 9.1.4 - dev: false - - /@hapi/hoek/9.3.0: - resolution: {integrity: sha512-/c6rf4UJlmHlC9b5BaNvzAcFv7HZ2QHaV0D4/HNlBdvFnvQq8RI4kYdhyPCl7Xj+oWvTWQ8ujhqS53LIgAe6KQ==} - dev: false - - /@hapi/iron/6.0.0: - resolution: {integrity: sha512-zvGvWDufiTGpTJPG1Y/McN8UqWBu0k/xs/7l++HVU535NLHXsHhy54cfEMdW7EjwKfbBfM9Xy25FmTiobb7Hvw==} - dependencies: - '@hapi/b64': 5.0.0 - '@hapi/boom': 9.1.4 - '@hapi/bourne': 2.1.0 - '@hapi/cryptiles': 5.1.0 - '@hapi/hoek': 9.3.0 - dev: false - /@headlessui/react/1.6.1_ef5jwxihqo6n7gxfmzogljlgcm: resolution: {integrity: sha512-gMd6uIs1U4Oz718Z5gFoV0o/vD43/4zvbyiJN9Dt7PK9Ubxn+TmJwTmYwyNJc5KxxU1t0CmgTNgwZX9+4NjCnQ==} engines: {node: '>=10'} @@ -530,6 +491,7 @@ packages: /@iconify/types/1.1.0: resolution: {integrity: sha512-Jh0llaK2LRXQoYsorIH8maClebsnzTcve+7U3rQUSnC11X4jtPnFuyatqFLvMxZ8MLG8dB4zfHsbPfuvxluONw==} + dev: false /@jridgewell/gen-mapping/0.1.1: resolution: {integrity: sha512-sQXCasFk+U8lWYEe66WxRDOE9PjVz4vSM51fTu3Hw+ClTpUSQb718772vH3pyS5pShp6lvQM7SxgIDXXXmOX7w==} diff --git a/prisma/migrations/20220526175118_add_analytics_id/migration.sql b/prisma/migrations/20220526175118_add_analytics_id/migration.sql new file mode 100644 index 00000000..8f39ec9e --- /dev/null +++ b/prisma/migrations/20220526175118_add_analytics_id/migration.sql @@ -0,0 +1,11 @@ +/* + Warnings: + + - A unique constraint covering the columns `[analyticsId]` on the table `sites` will be added. If there are existing duplicate values, this will fail. + +*/ +-- AlterTable +ALTER TABLE "sites" ADD COLUMN "analyticsId" TEXT; + +-- CreateIndex +CREATE UNIQUE INDEX "sites_analyticsId_key" ON "sites"("analyticsId"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 03e783f9..858d18da 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -91,6 +91,7 @@ model Site { navigation Json? deletedAt DateTime? memberships Membership[] + analyticsId String? @unique @@map("sites") } diff --git a/src/components/common/LoginModal.tsx b/src/components/common/LoginModal.tsx index aad032d4..3be808a8 100644 --- a/src/components/common/LoginModal.tsx +++ b/src/components/common/LoginModal.tsx @@ -3,7 +3,9 @@ import { useStore } from "~/lib/store" import { Dialog } from "@headlessui/react" import { trpc } from "~/lib/trpc" import { useForm } from "react-hook-form" -import { useEffect } from "react" +import { Modal } from "../ui/Modal" +import { UniLink } from "../ui/UniLink" +import { DOCS_DOMAIN } from "~/lib/env" export const LoginModal: React.FC = () => { const [loginModalOpened, setLoginModalOpened] = useStore((store) => [ @@ -31,57 +33,64 @@ export const LoginModal: React.FC = () => { }) return ( - setLoginModalOpened(false)} - className="fixed z-10 inset-0 overflow-y-auto" + setOpen={setLoginModalOpened} > -
- - -
- - Continue with Email - - -
- {data && ( -
- We just emailed you with a link to log in, please check your - inbox and spam folder in case you can{`'`}t find it. -
- )} - {error &&
{error.message}
} -
-
- - -
-
- -
-
+
+ {data && ( +
+ We just emailed you with a link to log in, please check your inbox + and spam folder in case you can{`'`}t find it.
-
+ )} + {error &&
{error.message}
} +
+
+ + +
+
+ +
+
+ By clicking Continue, you agree to our{" "} + + Terms of Service + {" "} + and{" "} + + Privacy Policy + + . +
+
-
+ ) } diff --git a/src/components/common/SubscribeModal.tsx b/src/components/common/SubscribeModal.tsx index 1b377f5b..55ef0752 100644 --- a/src/components/common/SubscribeModal.tsx +++ b/src/components/common/SubscribeModal.tsx @@ -6,6 +6,7 @@ import toast from "react-hot-toast" import { trpc } from "~/lib/trpc" import { Input } from "../ui/Input" import { useForm } from "react-hook-form" +import { useRouter } from "next/router" export const SubscribeModal: React.FC<{ siteId: string @@ -14,6 +15,7 @@ export const SubscribeModal: React.FC<{ } | null isLoggedIn: boolean }> = ({ siteId, subscription, isLoggedIn }) => { + const router = useRouter() const [open, setOpen] = useStore((store) => [ store.subscribeModalOpened, store.setSubscribeModalOpened, @@ -59,7 +61,21 @@ export const SubscribeModal: React.FC<{ }, [unsubscribe, trpcContext]) return ( - + { + setOpen(open) + if (!open && "subscription" in router.query) { + const query = new URLSearchParams(window.location.search) + query.delete("subscription") + const search = query.toString() + router.replace( + `${window.location.pathname}${search ? `?${search}` : ""}`, + ) + } + }} + > {subscribe.isSuccess && !isLoggedIn ? (

diff --git a/src/components/main/MainLayout.tsx b/src/components/main/MainLayout.tsx index 39a5005e..3a805b03 100644 --- a/src/components/main/MainLayout.tsx +++ b/src/components/main/MainLayout.tsx @@ -2,7 +2,13 @@ import { Icon } from "@iconify/react" import discordIcon from "@iconify/icons-mdi/discord" import githubIcon from "@iconify/icons-mdi/github" import { logout } from "~/lib/auth.client" -import { APP_DESCRIPTION, APP_NAME, GITHUB_LINK, OUR_DOMAIN } from "~/lib/env" +import { + APP_DESCRIPTION, + APP_NAME, + DOCS_DOMAIN, + GITHUB_LINK, + OUR_DOMAIN, +} from "~/lib/env" import { useStore } from "~/lib/store" import { SEOHead } from "../common/SEOHead" import { UniLink } from "../ui/UniLink" @@ -17,13 +23,12 @@ export function MainLayout({ title?: string }) { const setLoginModalOpened = useStore((store) => store.setLoginModalOpened) - const docsDomain = `docs.${OUR_DOMAIN}` const discordLink = `https://${OUR_DOMAIN}/discord` const companyLinks = [ { text: "Blog", href: `https://blog.${OUR_DOMAIN}` }, { text: "Changelog", href: `https://changelog.${OUR_DOMAIN}` }, - { text: "Privacy", href: `https://${docsDomain}/privacy.html` }, - { text: "Terms", href: `https://${docsDomain}/terms.html` }, + { text: "Privacy", href: `https://${DOCS_DOMAIN}/privacy.html` }, + { text: "Terms", href: `https://${DOCS_DOMAIN}/terms.html` }, ] return ( diff --git a/src/components/site/SiteHeader.tsx b/src/components/site/SiteHeader.tsx index 7d8457d8..b59635e4 100644 --- a/src/components/site/SiteHeader.tsx +++ b/src/components/site/SiteHeader.tsx @@ -28,7 +28,7 @@ const HeaderLink: React.FC<{ link: HeaderLinkType }> = ({ link }) => { onClick={link.onClick} className={clsx( `h-10 flex items-center border-b-2 space-x-1 hover:border-gray-500 hover:text-gray-700`, - active ? `text-indigo-700 border-accent` : `border-transparent` + active ? `text-indigo-700 border-accent` : `border-transparent`, )} > {link.icon && {link.icon}} @@ -46,7 +46,7 @@ export const SiteHeader: React.FC<{ viewer: Viewer | null }> = ({ siteName, description, icon, navigation, subscribed, viewer }) => { const setSubscribeModalOpened = useStore( - (store) => store.setSubscribeModalOpened + (store) => store.setSubscribeModalOpened, ) const setLoginModalOpened = useStore((store) => store.setLoginModalOpened) @@ -62,7 +62,7 @@ export const SiteHeader: React.FC<{ const dropdownLinks: HeaderLinkType[] = [ { icon: , - label: "Dashboard", + label: "Writer dashboard", url: `${IS_PROD ? "https" : "http"}://${OUR_DOMAIN}/dashboard`, }, { @@ -150,7 +150,7 @@ export const SiteHeader: React.FC<{ key={i} href={link.url} onClick={link.onClick} - className="px-4 h-8 flex items-center w-full hover:bg-zinc-100" + className="px-4 h-8 flex items-center w-full whitespace-nowrap hover:bg-zinc-100" > {link.label} diff --git a/src/components/site/SiteLayout.tsx b/src/components/site/SiteLayout.tsx index 83e78418..abd48829 100644 --- a/src/components/site/SiteLayout.tsx +++ b/src/components/site/SiteLayout.tsx @@ -1,11 +1,13 @@ import clsx from "clsx" -import React from "react" +import React, { useEffect } from "react" import { getUserContentsUrl } from "~/lib/user-contents" import { SubscribeModal } from "../common/SubscribeModal" import { SEOHead } from "../common/SEOHead" import { SiteNavigationItem, Viewer } from "~/lib/types" import { SiteFooter } from "./SiteFooter" import { SiteHeader } from "./SiteHeader" +import { useRouter } from "next/router" +import { useStore } from "~/lib/store" export type SiteLayoutProps = { site: { @@ -30,6 +32,17 @@ export const SiteLayout: React.FC = ({ title, ogDescription, }) => { + const router = useRouter() + const setSubscribeModalOpened = useStore( + (store) => store.setSubscribeModalOpened, + ) + + useEffect(() => { + if ("subscription" in router.query) { + setSubscribeModalOpened(true) + } + }, [setSubscribeModalOpened, router.query]) + return ( <> singleton("mailgun", () => { @@ -29,35 +35,57 @@ const getClient = () => return client }) -const sendEmail = async (message: MailgunMessageData) => { +const sendEmail = async (message: MailgunMessageData, stream: MAIL_STREAM) => { console.log(message) if (!enableMailgun) { console.error( - "not sending email because no mailgun apikey or domain configured" + "not sending email because no mailgun apikey or domain configured", ) return } const client = getClient() - await client.messages.create(MAILGUN_DOMAIN, message).then(console.log) + await client.messages + .create( + stream === MAIL_STREAM.NEWSLETTER + ? MAILGUN_DOMAIN_NEWSLETTER! + : MAILGUN_DOMAIN_TRANSANCTION!, + message, + ) + .then(console.log) } +const sendTransanctionEmail = async (message: MailgunMessageData) => + sendEmail(message, MAIL_STREAM.TRANSANCTION) + +const sendNewsletterEmail = async (message: MailgunMessageData) => + sendEmail(message, MAIL_STREAM.NEWSLETTER) + export const sendLoginEmail = async (payload: { - token: string email: string url: string - subscribeForm?: SubscribeFormData + toSubscribeSiteId?: string }) => { const { protocol, host, pathname } = new URL(payload.url) + const token = await generateLoginToken( + payload.toSubscribeSiteId + ? { + type: "subscribe", + email: payload.email, + siteId: payload.toSubscribeSiteId, + } + : { + type: "login", + email: payload.email, + }, + ) const query = new URLSearchParams([ - ["token", payload.token], + ["token", token], ["next", `${protocol}//${host}${pathname}`], ]) - if (payload.subscribeForm) { - query.append("subscribe", JSON.stringify(payload.subscribeForm)) - } + const loginLink = `${ IS_PROD ? "https" : "http" }://${OUR_DOMAIN}/api/login?${query.toString()}` @@ -78,15 +106,15 @@ export const sendLoginEmail = async (payload: {

Your Proselog Team

` - if (payload.subscribeForm) { - const site = await getSite(payload.subscribeForm.siteId) + if (payload.toSubscribeSiteId) { + const site = await getSite(payload.toSubscribeSiteId) subject = `Confirm your subscription to ${site.name}` html = ` -

Please confirm your subscription to ${site.name} by clicking the link below:

+

Please confirm your subscription to "${site.name}" by clicking the link below:

confirm -

This link will expire in 10 minutes.

` +

This link will expire in 20 minutes.

` } const message: MailgunMessageData = { @@ -96,7 +124,7 @@ export const sendLoginEmail = async (payload: { html, } - await sendEmail(message) + await sendTransanctionEmail(message) } export const sendEmailForNewPost = async (payload: { @@ -125,7 +153,7 @@ export const sendEmailForNewPost = async (payload: {

Unsubscribe @@ -146,29 +174,27 @@ export const sendEmailForNewPost = async (payload: { await Promise.allSettled( payload.subscribers.map(async (sub) => { - const unsubscribeToken = await Iron.seal( - { - userId: sub.id, - siteId: payload.site.id, - }, - ENCRYPT_SECRET, - Iron.defaults - ) + const unsubscribeToken = await generateLoginToken({ + type: "unsubscribe", + userId: sub.id, + siteId: payload.site.id, + }) recipientVariables[sub.email] = { unsubscribeToken, } - }) + }), ) const message: MailgunMessageData = { from, subject, html, + // TODO: segment by every 800 subscribers to: Object.keys(recipientVariables), "recipient-variables": JSON.stringify(recipientVariables), } - await sendEmail(message) + await sendNewsletterEmail(message) } catch (error) { console.error(error) } diff --git a/src/lib/token.server.ts b/src/lib/token.server.ts new file mode 100644 index 00000000..72f747d9 --- /dev/null +++ b/src/lib/token.server.ts @@ -0,0 +1,40 @@ +import { decrypt, encrypt, getDerivedKey } from "@proselog/jwt" +import { ENCRYPT_SECRET } from "./env.server" +import { singletonAsync } from "./singleton.server" + +const key = singletonAsync("encryption_key", () => + getDerivedKey(ENCRYPT_SECRET), +) + +export const generateEncryptedToken = async (payload: any) => { + await key.wait + const token = await encrypt(payload, key.value, { + expiresIn: "20m", + }) + return token +} + +export const decryptToken = async (token: string) => { + await key.wait + const payload = await decrypt(token, key.value) + return payload +} + +type LoginTokenPayload = + | { type: "login"; email: string } + | { type: "subscribe"; email: string; siteId: string } + | { + type: "unsubscribe" + siteId: string + userId: string + } + +export const generateLoginToken = async (payload: LoginTokenPayload) => { + const token = await generateEncryptedToken(payload) + return token +} + +export const decryptLoginToken = async (token: string) => { + const payload = await decryptToken(token) + return payload as LoginTokenPayload +} diff --git a/src/lib/umami.server.ts b/src/lib/umami.server.ts new file mode 100644 index 00000000..983eb826 --- /dev/null +++ b/src/lib/umami.server.ts @@ -0,0 +1,50 @@ +import { OUR_DOMAIN } from "./env" +import { UMAMI_ENDPOINT, UMAMI_TOKEN } from "./env.server" +import { singleton } from "./singleton.server" + +class Umami { + constructor() {} + + async request( + url: string, + options: { + method: "GET" | "POST" + query?: Record + data?: Record + }, + ) { + if (options.query) { + url += `?${new URLSearchParams(options.query).toString()}` + } + const body = options.data ? JSON.stringify(options.data) : undefined + return fetch(`https://${UMAMI_ENDPOINT}/api${url}`, { + method: options.method, + body, + headers: { + Authorization: `Bearer ${UMAMI_TOKEN}`, + Accept: "application/json", + }, + }).then((res) => { + if (!res.ok) { + throw new Error(`${res.status} ${res.statusText}`) + } + return res.json() + }) + } + + async createAnalytics(site: { id: string; name: string }) { + const { website_uuid } = await this.request(`/website`, { + method: "POST", + data: { + domain: `${site.id}.${OUR_DOMAIN}`, + name: site.name, + enable_share_url: false, + public: false, + }, + }) + + return website_uuid + } +} + +export const umami = singleton("umami", () => new Umami()) diff --git a/src/markdown/index.ts b/src/markdown/index.ts index 725940f3..a86ce7eb 100644 --- a/src/markdown/index.ts +++ b/src/markdown/index.ts @@ -5,7 +5,7 @@ import rehypeStringify from "rehype-stringify" import remarkGfm from "remark-gfm" import rehypeSanitize, { defaultSchema } from "rehype-sanitize" import rehypeRaw from "rehype-raw" -import { refractor } from "refractor/lib/common" +import { refractor } from "refractor/lib/all" import rehypePrismGenerator from "rehype-prism-plus/generator" import { rehypeImage } from "./rehype-image" import { remarkExcerpt } from "./remark-excerpt" diff --git a/src/models/site.model.ts b/src/models/site.model.ts index 630ba884..c6205e42 100644 --- a/src/models/site.model.ts +++ b/src/models/site.model.ts @@ -8,6 +8,7 @@ import { SiteNavigationItem, SubscribeFormData } from "~/lib/types" import { nanoid } from "nanoid" import { getMembership } from "./membership" import { checkReservedWords } from "~/lib/reserved-words" +import { generateLoginToken } from "~/lib/token.server" export const checkSubdomain = async ({ subdomain, @@ -111,7 +112,7 @@ export async function updateSite( icon?: string | null subdomain?: string navigation?: SiteNavigationItem[] - } + }, ) { const site = await getSite(payload.site) @@ -147,7 +148,7 @@ export async function updateSite( export async function createSite( gate: Gate, - payload: { name: string; subdomain: string } + payload: { name: string; subdomain: string }, ) { const user = gate.getUser(true) await checkSubdomain({ subdomain: payload.subdomain }) @@ -205,27 +206,15 @@ export async function subscribeToSite( email: string url: string } - } + }, ) { const { newUser } = input if (newUser) { // Create the login token instead - const subscribeFormData: SubscribeFormData = { - email: input.email, - siteId: input.siteId, - } - const loginToken = await prismaPrimary.loginToken.create({ - data: { - email: newUser.email, - expiresAt: dayjs().add(10, "minutes").toDate(), - subscribeForm: subscribeFormData, - }, - }) sendLoginEmail({ - token: loginToken.id, - email: loginToken.email, + email: newUser.email, url: newUser.url, - subscribeForm: subscribeFormData, + toSubscribeSiteId: input.siteId, }) return } @@ -272,7 +261,7 @@ export async function subscribeToSite( export async function unsubscribeFromSite( gate: Gate, - input: { siteId: string } + input: { siteId: string }, ) { const user = gate.getUser(true) diff --git a/src/pages/_middleware.ts b/src/pages/_middleware.ts index f0ab2784..79f32e62 100644 --- a/src/pages/_middleware.ts +++ b/src/pages/_middleware.ts @@ -6,6 +6,12 @@ import { getTenant } from "~/lib/tenant.server" const METHODS_TO_NOT_REPLAY = ["GET", "HEAD", "OPTIONS"] +const ALWAYS_REPLAY_ROUTES = [ + "/api/login", + "/api/login-complete", + "/api/logout", +] + export default function middleware(req: NextRequest) { const { pathname } = req.nextUrl @@ -14,7 +20,8 @@ export default function middleware(req: NextRequest) { if ( IS_PROD && !IS_PRIMARY_REGION && - !METHODS_TO_NOT_REPLAY.includes(req.method) + (!METHODS_TO_NOT_REPLAY.includes(req.method) || + ALWAYS_REPLAY_ROUTES.includes(pathname)) ) { console.log("replayed", { PRIMARY_REGION, diff --git a/src/pages/api/login-complete.ts b/src/pages/api/login-complete.ts index 37ccdf36..9c4d3960 100644 --- a/src/pages/api/login-complete.ts +++ b/src/pages/api/login-complete.ts @@ -5,16 +5,22 @@ import { IS_PROD } from "~/lib/constants" import { prismaPrimary } from "~/lib/db.server" import { OUR_DOMAIN } from "~/lib/env" +const pathAndSearch = (pathname: string, search: string) => { + return `${pathname}${search ? `?${search}` : ""}` +} + const handler: NextApiHandler = async (req, res) => { const data = z .object({ id: z.string(), - path: z.string().default("/"), + pathname: z.string().default("/"), + search: z.string().default(""), host: z.string(), }) .parse({ id: req.query.id, - path: req.query.path, + pathname: req.query.pathname, + search: req.query.search, host: req.headers.host, }) @@ -52,13 +58,13 @@ const handler: NextApiHandler = async (req, res) => { type: "auth", domain: data.host, token: accessToken.token, - }) + }), ) - res.redirect(data.path) + res.redirect(pathAndSearch(data.pathname, data.search)) return } - res.redirect(data.path) + res.redirect(pathAndSearch(data.pathname, data.search)) } export default handler diff --git a/src/pages/api/login.ts b/src/pages/api/login.ts index dd17ed72..ec0735a9 100644 --- a/src/pages/api/login.ts +++ b/src/pages/api/login.ts @@ -7,8 +7,10 @@ import { generateCookie } from "~/lib/auth.server" import { OUR_DOMAIN } from "~/lib/env" import { IS_PROD } from "~/lib/constants" import { NextApiHandler } from "next" -import { subscribeToSite } from "~/models/site.model" +import { getSite, subscribeToSite } from "~/models/site.model" import { createGate } from "~/lib/gate.server" +import { decryptLoginToken } from "~/lib/token.server" +import { getSiteLink } from "~/lib/helpers" const handler: NextApiHandler = async (req, res) => { const data = z @@ -16,72 +18,55 @@ const handler: NextApiHandler = async (req, res) => { token: z.string(), next: z.string(), userAgent: z.string(), - subscribe: z - .object({ - siteId: z.string(), - email: z.boolean().optional(), - }) - .optional(), }) .parse({ token: req.query.token, next: req.query.next, userAgent: req.headers["user-agent"], - subscribe: - req.query.subscribe && JSON.parse(req.query.subscribe as string), }) - const loginToken = await prismaPrimary.loginToken.findUnique({ - where: { - id: data.token, - }, - }) - - if (!loginToken) { - throw new Error("invalid token") - } - - if (loginToken.expiresAt < new Date()) { - throw new Error(`token expired`) - } + const payload = await decryptLoginToken(data.token) let user = await prismaPrimary.user.findUnique({ - where: { - email: loginToken.email, - }, + where: + payload.type === "unsubscribe" + ? { + id: payload.userId, + } + : { + email: payload.email, + }, include: { memberships: true, }, }) if (!user) { - user = await prismaPrimary.user.create({ - data: { - email: loginToken.email, - name: loginToken.email.split("@")[0], - username: nanoid(7), - }, - include: { - memberships: true, - }, - }) + if (payload.type === "unsubscribe") { + throw new Error("User not found") + } else { + user = await prismaPrimary.user.create({ + data: { + email: payload.email, + name: payload.email.split("@")[0], + username: nanoid(7), + }, + include: { + memberships: true, + }, + }) + } } - if (data.subscribe) { + if (payload.type === "subscribe") { // Subscribe the user to the site const gate = createGate({ user }) await subscribeToSite(gate, { - siteId: data.subscribe.siteId, - email: data.subscribe.email, + siteId: payload.siteId, + email: true, }) } - await prismaPrimary.loginToken.delete({ - where: { - id: loginToken.id, - }, - }) - const ua = new UAParser(data.userAgent) const publicId = nanoid(32) @@ -103,9 +88,16 @@ const handler: NextApiHandler = async (req, res) => { console.log("login with token", accessToken.token) } - // Custom domain + if (payload.type === "unsubscribe") { + const site = await getSite(payload.siteId) + data.next = `${getSiteLink({ + subdomain: site.subdomain, + })}?subscription` + } + const nextUrl = new URL(data.next) + // Custom domain if (nextUrl.host !== OUR_DOMAIN && !nextUrl.host.endsWith(`.${OUR_DOMAIN}`)) { // Check if the host belong to a site // const existing = await prismaPrimary.domain.findUnique({ @@ -120,7 +112,7 @@ const handler: NextApiHandler = async (req, res) => { } nextUrl.searchParams.set("id", publicId) - nextUrl.searchParams.set("path", nextUrl.pathname) + nextUrl.searchParams.set("pathname", nextUrl.pathname) nextUrl.pathname = "/api/login-complete" console.log("redirecting to", nextUrl.href) @@ -130,7 +122,7 @@ const handler: NextApiHandler = async (req, res) => { type: "auth", domain: OUR_DOMAIN, token: accessToken.token, - }) + }), ) res.redirect(nextUrl.href) diff --git a/src/pages/api/unsubscribe.ts b/src/pages/api/unsubscribe.ts deleted file mode 100644 index 7807bf31..00000000 --- a/src/pages/api/unsubscribe.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { NextApiHandler } from "next" -import Iron from "@hapi/iron" -import { ENCRYPT_SECRET } from "~/lib/env.server" -import { unsubscribeFromSite } from "~/models/site.model" -import { prismaPrimary } from "~/lib/db.server" -import { createGate } from "~/lib/gate.server" - -const handler: NextApiHandler = async (req, res) => { - const token = req.query.token as string - const payload: { - siteId: string - userId: string - } = await Iron.unseal(token, ENCRYPT_SECRET, Iron.defaults) - - const user = await prismaPrimary.user.findUnique({ - where: { - id: payload.userId, - }, - include: { - memberships: true, - }, - }) - if (!user) return res.send("invalid token") - - const gate = createGate({ user }) - - await unsubscribeFromSite(gate, { siteId: payload.siteId }) - - res.send("unsubscribed!") -} - -export default handler diff --git a/src/router/auth.ts b/src/router/auth.ts index 2408899e..0c216cfb 100644 --- a/src/router/auth.ts +++ b/src/router/auth.ts @@ -1,6 +1,4 @@ -import dayjs from "dayjs" import { z } from "zod" -import { prismaPrimary } from "~/lib/db.server" import { sendLoginEmail } from "~/lib/mailgun.server" import { createRouter } from "~/lib/trpc.server" import { getViewer } from "~/lib/viewer" @@ -28,17 +26,9 @@ export const authRouter = createRouter() }), output: z.boolean(), async resolve({ input }) { - const token = await prismaPrimary.loginToken.create({ - data: { - email: input.email, - expiresAt: dayjs().add(10, "minute").toDate(), - }, - }) - await sendLoginEmail({ url: input.url, email: input.email, - token: token.id, }).catch(console.error) return true diff --git a/src/router/site.ts b/src/router/site.ts index 2e3d1402..6d3d6bee 100644 --- a/src/router/site.ts +++ b/src/router/site.ts @@ -80,7 +80,7 @@ export const siteRouter = createRouter() slug: z.string(), excerpt: z.string().nullable(), autoExcerpt: z.string(), - }) + }), ), total: z.number(), hasMore: z.boolean(), @@ -118,7 +118,7 @@ export const siteRouter = createRouter() id: z.string(), name: z.string(), avatar: z.string().nullable(), - }) + }), ) .optional(), }), @@ -143,9 +143,9 @@ export const siteRouter = createRouter() .string() .regex( /^(https?:\/\/|\/)/, - "URL must start with / or http:// or https://" + "URL must start with / or http:// or https://", ), - }) + }), ) .optional(), }), diff --git a/types.d.ts b/types.d.ts index 7f95d91d..7e3ee450 100644 --- a/types.d.ts +++ b/types.d.ts @@ -7,7 +7,8 @@ declare interface CustomEnv { AUTH_COOKIE_NAME: string NEXT_PUBLIC_OUR_DOMAIN: string MAILGUN_APIKEY: string - MAILGUN_DOMAIN: string + MAILGUN_DOMAIN_TRANSANCTION?: string + MAILGUN_DOMAIN_NEWSLETTER?: string FLY_REGION?: string PRIMARY_REGION?: string ENCRYPT_SECRET: string