improving login/subscription flow

This commit is contained in:
EGOIST 2022-05-27 02:15:58 +08:00
parent bb6d3471ea
commit e1d49e34d9
26 changed files with 353 additions and 257 deletions

View File

@ -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=

View File

@ -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"]

View File

@ -7,5 +7,6 @@ module.exports = {
},
experimental: {
scrollRestoration: true,
outputStandalone: true,
},
}

View File

@ -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",

View File

@ -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==}

View File

@ -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");

View File

@ -91,6 +91,7 @@ model Site {
navigation Json?
deletedAt DateTime?
memberships Membership[]
analyticsId String? @unique
@@map("sites")
}

View File

@ -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 (
<Dialog
<Modal
title={`Continue with Email`}
open={loginModalOpened}
onClose={() => setLoginModalOpened(false)}
className="fixed z-10 inset-0 overflow-y-auto"
setOpen={setLoginModalOpened}
>
<div className="flex items-center justify-center min-h-screen">
<Dialog.Overlay className="fixed inset-0 bg-white opacity-75" />
<div className="relative bg-white rounded-lg overflow-hidden max-w-sm shadow-modal w-full mx-auto">
<Dialog.Title className="bg-zinc-200 h-12 flex items-center px-3 text-sm text-zinc-600 justify-center">
Continue with Email
</Dialog.Title>
<div className="p-8">
{data && (
<div className="mb-5">
We just emailed you with a link to log in, please check your
inbox and spam folder in case you can{`'`}t find it.
</div>
)}
{error && <div className="mb-5 text-red-500">{error.message}</div>}
<form className="space-y-5" onSubmit={handleSubmit}>
<div>
<label
className="block mb-1 font-medium text-zinc-600"
htmlFor="email"
>
Email
</label>
<input
id="email"
type="email"
required
className="input is-block"
{...form.register("email")}
/>
</div>
<div>
<Button
type="submit"
isBlock
isLoading={requestLoginLinkStatus === "loading"}
>
Continue
</Button>
</div>
</form>
<div className="p-5">
{data && (
<div className="mb-5">
We just emailed you with a link to log in, please check your inbox
and spam folder in case you can{`'`}t find it.
</div>
</div>
)}
{error && <div className="mb-5 text-red-500">{error.message}</div>}
<form className="space-y-5" onSubmit={handleSubmit}>
<div>
<label
className="block mb-1 font-medium text-zinc-600"
htmlFor="email"
>
Email
</label>
<input
id="email"
type="email"
required
className="input is-block"
{...form.register("email")}
/>
</div>
<div>
<Button
type="submit"
isBlock
isLoading={requestLoginLinkStatus === "loading"}
>
Continue
</Button>
</div>
<div className="text-xs text-zinc-400">
By clicking Continue, you agree to our{" "}
<UniLink
href={`https://${DOCS_DOMAIN}/terms.html`}
className="underline"
>
Terms of Service
</UniLink>{" "}
and{" "}
<UniLink
href={`https://${DOCS_DOMAIN}/privacy.html`}
className="underline"
>
Privacy Policy
</UniLink>
.
</div>
</form>
</div>
</Dialog>
</Modal>
)
}

View File

@ -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 (
<Modal title="Become a subscriber" open={open} setOpen={setOpen}>
<Modal
title={subscription ? `Manage your subscription` : `Become a subscriber`}
open={open}
setOpen={(open) => {
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 ? (
<div className="p-5 space-y-3">
<p>

View File

@ -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 (

View File

@ -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 && <span>{link.icon}</span>}
@ -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: <DashboardIcon />,
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}
</UniLink>

View File

@ -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<SiteLayoutProps> = ({
title,
ogDescription,
}) => {
const router = useRouter()
const setSubscribeModalOpened = useStore(
(store) => store.setSubscribeModalOpened,
)
useEffect(() => {
if ("subscription" in router.query) {
setSubscribeModalOpened(true)
}
}, [setSubscribeModalOpened, router.query])
return (
<>
<SEOHead

View File

@ -1,9 +1,16 @@
export const R2_API_TOKEN = process.env.R2_API_TOKEN
export const MAILGUN_APIKEY = process.env.MAILGUN_APIKEY
export const MAILGUN_DOMAIN = process.env.MAILGUN_DOMAIN
export const ENCRYPT_SECRET = process.env.ENCRYPT_SECRET
export const MAILGUN_DOMAIN_TRANSANCTION =
process.env.MAILGUN_DOMAIN_TRANSANCTION
export const MAILGUN_DOMAIN_NEWSLETTER = process.env.MAILGUN_DOMAIN_NEWSLETTER
export const ENCRYPT_SECRET = process.env.BUILD_STEP
? // This won't be used in actual runtime
"random_string_to_make_next_build_happy"
: process.env.ENCRYPT_SECRET
export const AUTH_COOKIE_NAME = process.env.AUTH_COOKIE_NAME
export const PRIMARY_REGION = process.env.PRIMARY_REGION
export const FLY_REGION = process.env.FLY_REGION || "local"
export const IS_PRIMARY_REGION = FLY_REGION === PRIMARY_REGION
export const UMAMI_TOKEN = process.env.UMAMI_TOKEN
export const UMAMI_ENDPOINT = process.env.UMAMI_ENDPOINT

View File

@ -2,6 +2,7 @@ import { IS_PROD } from "./constants"
export const APP_NAME = process.env.NEXT_PUBLIC_APP_NAME
export const OUR_DOMAIN = process.env.NEXT_PUBLIC_OUR_DOMAIN
export const DOCS_DOMAIN = `docs.${OUR_DOMAIN}`
export const SITE_URL = `${IS_PROD ? "https" : "http"}://${OUR_DOMAIN}`
export const R2_URL = process.env.NEXT_PUBLIC_R2_URL
export const APP_DESCRIPTION = process.env.APP_DESCRIPTION

View File

@ -2,9 +2,9 @@ import Mailgun from "mailgun.js"
import FormData from "form-data"
import { singleton } from "./singleton.server"
import {
ENCRYPT_SECRET,
MAILGUN_APIKEY,
MAILGUN_DOMAIN,
MAILGUN_DOMAIN_NEWSLETTER,
MAILGUN_DOMAIN_TRANSANCTION,
} from "~/lib/env.server"
import type { MailgunMessageData } from "mailgun.js/interfaces/Messages"
import { IS_PROD } from "./constants"
@ -12,11 +12,17 @@ import { APP_NAME, OUR_DOMAIN, SITE_URL } from "./env"
import { SubscribeFormData } from "./types"
import { getSite } from "~/models/site.model"
import { type Site } from "~/lib/db.server"
import Iron from "@hapi/iron"
import mjml from "mjml"
import { getSiteLink } from "./helpers"
import { generateLoginToken } from "./token.server"
const enableMailgun = Boolean(MAILGUN_APIKEY && MAILGUN_DOMAIN)
enum MAIL_STREAM {
NEWSLETTER = "newsletter",
TRANSANCTION = "transaction",
}
const enableMailgun = Boolean(
MAILGUN_APIKEY && MAILGUN_DOMAIN_TRANSANCTION && MAILGUN_DOMAIN_NEWSLETTER,
)
const getClient = () =>
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: {
<p>Your Proselog Team</p>
`
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 = `
<p>Please confirm your subscription to ${site.name} by clicking the link below:</p>
<p>Please confirm your subscription to "${site.name}" by clicking the link below:</p>
<a href="${loginLink}">confirm</a>
<p>This link will expire in 10 minutes.</p>`
<p>This link will expire in 20 minutes.</p>`
}
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: {
<p style="margin-top:3em;">
<a
href="${`${SITE_URL}/api/unsubscribe?token=%recipient.unsubscribeToken%`}"
href="${`${SITE_URL}/api/login?next=/ignore&token=%recipient.unsubscribeToken%`}"
style="text-decoration:none;">
Unsubscribe
</a>
@ -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)
}

40
src/lib/token.server.ts Normal file
View File

@ -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
}

50
src/lib/umami.server.ts Normal file
View File

@ -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<string, any>
data?: Record<string, any>
},
) {
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())

View File

@ -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"

View File

@ -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)

View File

@ -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,

View File

@ -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

View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -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(),
}),

3
types.d.ts vendored
View File

@ -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