Skip to content

Commit 5308dbe

Browse files
u
1 parent 6da3c94 commit 5308dbe

35 files changed

Lines changed: 528 additions & 179 deletions

File tree

‎src/app/(legal)/layout.tsx‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import type { Viewport } from "next"
2+
import { getSettings } from "@/server/settings"
3+
import { SiteChrome } from "@/components/marketing/site-chrome"
4+
import "@/components/marketing/marketing.css"
5+
6+
export const viewport: Viewport = { themeColor: "#FAF9F5" }
7+
8+
/**
9+
* Imprint, privacy policy and terms. Always public (the sign-in page links
10+
* here), with the full site chrome when the marketing site is on and a
11+
* minimal header otherwise.
12+
*/
13+
export default async function LegalLayout({ children }: { children: React.ReactNode }) {
14+
const general = await getSettings("general")
15+
return <SiteChrome variant={general.marketingSite === false ? "minimal" : "full"}>{children}</SiteChrome>
16+
}
Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -26,22 +26,31 @@ export function generateStaticParams() {
2626
export async function generateMetadata({ params }: PageProps<"/legal/[slug]">): Promise<Metadata> {
2727
const { slug } = await params
2828
if (!isLegalSlug(slug)) return {}
29-
return { title: pages[slug].title, alternates: { canonical: `/legal/${slug}` } }
29+
const legal = await getSettings("legal")
30+
const published = Boolean(legal[pages[slug].field].trim())
31+
return {
32+
title: pages[slug].title,
33+
alternates: { canonical: `/legal/${slug}` },
34+
// Placeholder pages shouldn't end up in search results.
35+
...(published ? {} : { robots: { index: false } }),
36+
}
3037
}
3138

3239
/** Operator-provided Markdown → sanitized HTML. */
3340
function renderMarkdown(md: string): string {
3441
const html = marked.parse(md, { async: false, gfm: true, breaks: false })
3542
return sanitizeHtml(html, {
36-
allowedTags: sanitizeHtml.defaults.allowedTags.concat(["h1", "h2", "img"]),
43+
allowedTags: sanitizeHtml.defaults.allowedTags.concat(["h2", "img"]),
3744
allowedAttributes: {
38-
a: ["href", "title", "name", "target", "rel"],
45+
a: ["href", "title", "target", "rel"],
3946
img: ["src", "alt", "title", "width", "height"],
4047
th: ["align"],
4148
td: ["align"],
4249
},
4350
allowedSchemes: ["http", "https", "mailto", "tel"],
4451
transformTags: {
52+
// The page already has an <h1>; demote Markdown top-level headings.
53+
h1: "h2",
4554
a: (tagName, attribs) => {
4655
const external = /^https?:\/\//.test(attribs.href ?? "")
4756
return {
@@ -94,10 +103,6 @@ export default async function LegalPage({ params }: PageProps<"/legal/[slug]">)
94103
<FileText className="size-4 text-muted-foreground" aria-hidden />
95104
</span>
96105
<p className="mt-5 text-[15px] font-medium">The operator of this instance hasn&apos;t published this page yet.</p>
97-
<p className="mt-1.5 max-w-sm text-[13.5px] leading-relaxed text-muted-foreground">
98-
Administrators can add it in Admin → Settings → Legal. It&apos;s written in Markdown and appears here
99-
right away.
100-
</p>
101106
</div>
102107
)}
103108
</div>

‎src/app/(marketing)/layout.tsx‎

Lines changed: 6 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,42 +1,19 @@
1+
import type { Viewport } from "next"
12
import { redirect } from "next/navigation"
23
import { getSettings } from "@/server/settings"
3-
import { getCurrentUser } from "@/server/auth/session"
4-
import { AnnouncementBar } from "@/components/marketing/announcement-bar"
5-
import { FloatingCta } from "@/components/marketing/floating-cta"
6-
import { getMarketingNav } from "@/components/marketing/nav"
7-
import { SiteFooter } from "@/components/marketing/site-footer"
8-
import { SiteHeader } from "@/components/marketing/site-header"
9-
import { formatStars, getGitHubStars } from "@/components/marketing/lib/github"
4+
import { SiteChrome } from "@/components/marketing/site-chrome"
105
import { APP_ENTRY } from "@/content/marketing/site"
116
import "@/components/marketing/marketing.css"
127

8+
export const viewport: Viewport = { themeColor: "#FAF9F5" }
9+
1310
/**
1411
* Public marketing site. Instances can switch it off in Admin → General
1512
* (`marketingSite`), in which case every marketing URL forwards to the app.
13+
* Legal pages live in the (legal) group so they stay reachable either way.
1614
*/
1715
export default async function MarketingLayout({ children }: { children: React.ReactNode }) {
1816
const general = await getSettings("general")
1917
if (general.marketingSite === false) redirect(APP_ENTRY)
20-
21-
const [user, stars] = await Promise.all([getCurrentUser().catch(() => null), getGitHubStars()])
22-
const starLabel = formatStars(stars)
23-
const nav = getMarketingNav()
24-
25-
return (
26-
<div className="mk-light flex min-h-dvh flex-col overflow-x-clip">
27-
<a
28-
href="#main"
29-
className="sr-only z-50 rounded-[4px] bg-primary px-3 py-2 text-sm text-primary-foreground focus:not-sr-only focus:fixed focus:top-3 focus:left-3"
30-
>
31-
Skip to content
32-
</a>
33-
<AnnouncementBar stars={starLabel} />
34-
<SiteHeader groups={nav.groups} pricing={nav.pricing} signedIn={Boolean(user)} stars={starLabel} />
35-
<main id="main" className="flex-1">
36-
{children}
37-
</main>
38-
<SiteFooter stars={starLabel} />
39-
<FloatingCta signedIn={Boolean(user)} stars={starLabel} />
40-
</div>
41-
)
18+
return <SiteChrome variant="full">{children}</SiteChrome>
4219
}

‎src/app/(marketing)/page.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -324,7 +324,7 @@ function Automate() {
324324
className="md:col-span-2 lg:col-span-4"
325325
layout="row"
326326
title="Rules & automation."
327-
quiet="When, if, then: label, assign, move or notify automatically."
327+
quiet="When, if, then: label, assign, snooze or close automatically."
328328
visual="rules"
329329
href="/features/rules-automation"
330330
/>

‎src/app/(marketing)/pricing/page.tsx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { CtaSection } from "@/components/marketing/cta-section"
44
import { FaqSection } from "@/components/marketing/faq"
55
import { clientPricing, getCloudPricing, type CloudPricing } from "@/components/marketing/lib/pricing"
66
import { PageHero } from "@/components/marketing/page-hero"
7-
import { Bool, PlanCards, referencePrice } from "@/components/marketing/pricing-parts"
7+
import { Bool, PlanCards } from "@/components/marketing/pricing-parts"
88
import { JsonLd, MonoLabel, PixelMark, Section, SectionHeading } from "@/components/marketing/primitives"
99
import { competitors, PRICES_AS_OF } from "@/content/marketing/competitors"
1010
import type { Faq } from "@/content/marketing/types"
@@ -23,7 +23,7 @@ export default async function PricingPage() {
2323
const rivals = ["missive", "front", "hiver", "help-scout"]
2424
.map((s) => competitors.find((c) => c.slug === s))
2525
.filter((c) => c !== undefined)
26-
.map((c) => ({ name: c.name, plan: c.referencePlan, price: referencePrice(c) }))
26+
.map((c) => ({ name: c.name, plans: c.plans, referencePlan: c.referencePlan }))
2727

2828
return (
2929
<>

‎src/app/(marketing)/security/page.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -147,7 +147,7 @@ export default async function SecurityPage() {
147147
<ul className="space-y-3 self-center">
148148
{[
149149
"Mail stays in the original mailbox; Dispatch keeps a synced copy in your own Postgres",
150-
"No telemetry: a self-hosted instance doesn't phone home",
150+
"No telemetry: Dispatch never reports usage data anywhere",
151151
"The AI assistant is off by default and only uses the provider and API key you configure",
152152
"Stripe is contacted only when an operator turns billing on",
153153
"Open source under AGPL-3.0, so every claim on this page can be verified in the code",

‎src/app/api/w/[slug]/conversations/[id]/route.ts‎

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,15 +14,16 @@ export const GET = route<P>(async (req, { params }) => {
1414
return json(thread)
1515
})
1616

17-
/** PATCH /api/w/[slug]/conversations/[id] body: ConversationPatch → ConversationListItem */
17+
/**
18+
* PATCH /api/w/[slug]/conversations/[id] body: ConversationPatch
19+
* → { item: ConversationListItem | null } (null when the change removed your access, e.g. unassigning yourself)
20+
*/
1821
export const PATCH = route<P>(async (req, { params }) => {
1922
const { slug, id } = await params
2023
const { ctx, scope } = await inboxContext(req, slug, { write: true })
2124
const patch = await parseJson(req, conversationPatchSchema)
2225
await applyConversationPatch(ctx, scope, [assertUuid(id, "Conversation")], patch)
23-
const item = await getListItem(ctx, scope, id)
24-
if (!item) throw new ApiError(404, "Conversation not found", "not_found")
25-
return json(item)
26+
return json({ item: await getListItem(ctx, scope, id) })
2627
})
2728

2829
/** DELETE /api/w/[slug]/conversations/[id] → permanently delete (must be in trash) */

‎src/app/api/w/[slug]/events/route.ts‎

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type { NextRequest } from "next/server"
2-
import { and, eq } from "drizzle-orm"
2+
import { and, eq, gt, isNull, or } from "drizzle-orm"
33
import { errorResponse, requireApiOrg } from "@/server/api"
44
import { SESSION_COOKIE, validateSessionToken } from "@/server/auth/session"
55
import { subscribe } from "@/server/realtime"
@@ -56,7 +56,17 @@ export async function GET(req: NextRequest, { params }: { params: Promise<{ slug
5656
const heartbeat = setInterval(() => send(`: ping ${Date.now()}\n\n`), HEARTBEAT_MS)
5757
const recheck = setInterval(async () => {
5858
try {
59-
const session = ctx.via === "session" ? await validateSessionToken(token) : { user: ctx.user }
59+
const session =
60+
ctx.via === "session"
61+
? await validateSessionToken(token)
62+
: await db.query.apiKeys.findFirst({
63+
where: and(
64+
eq(schema.apiKeys.id, ctx.apiKeyId!),
65+
isNull(schema.apiKeys.revokedAt),
66+
or(isNull(schema.apiKeys.expiresAt), gt(schema.apiKeys.expiresAt, new Date()))
67+
),
68+
columns: { id: true },
69+
})
6070
const membership = session
6171
? await db.query.memberships.findFirst({
6272
where: and(eq(schema.memberships.orgId, orgId), eq(schema.memberships.userId, me), eq(schema.memberships.status, "active")),

‎src/app/api/w/[slug]/presence/route.ts‎

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -25,14 +25,12 @@ export const POST = route<{ slug: string }>(async (req, { params }) => {
2525
const orgId = ctx.org.id
2626
const me = ctx.user.id
2727

28-
if (input.left && input.left !== input.conversationId) {
28+
// Leaving: only if my presence still points at that conversation (requests can arrive out of order).
29+
if (input.left && input.left !== input.conversationId && getViewers(orgId, input.left).some((v) => v.userId === me)) {
2930
touchPresence(orgId, me, "", false)
3031
await publish({ orgId, type: "presence", conversationId: input.left, actorId: me, data: { viewers: getViewers(orgId, input.left).map((v) => ({ userId: v.userId, composing: normalize(v.composing) })) } })
3132
}
32-
if (!input.conversationId) {
33-
if (!input.left) touchPresence(orgId, me, "", false)
34-
return json({ viewers: [] })
35-
}
33+
if (!input.conversationId) return json({ viewers: [] })
3634

3735
const scope = await loadScope(ctx)
3836
const row = await loadOneAccessible(ctx, scope, input.conversationId)

‎src/app/api/w/[slug]/uploads/route.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { ApiError, json, route } from "@/server/api"
22
import { inboxContext } from "@/server/conversations/context"
33
import { storeUpload } from "@/server/conversations/files"
4+
import { getSettings } from "@/server/settings"
45

56
/**
67
* POST /api/w/[slug]/uploads (multipart/form-data, field "file")
@@ -9,6 +10,12 @@ import { storeUpload } from "@/server/conversations/files"
910
*/
1011
export const POST = route<{ slug: string }>(async (req, { params }) => {
1112
const { ctx } = await inboxContext(req, (await params).slug, { write: true })
13+
// Reject oversized bodies before buffering them (exact per-file check happens in storeUpload).
14+
const storage = await getSettings("storage")
15+
const length = Number(req.headers.get("content-length") || 0)
16+
if (length > storage.maxAttachmentMb * 1024 * 1024 + 64 * 1024) {
17+
throw new ApiError(413, `Files can be at most ${storage.maxAttachmentMb} MB`, "file_too_large")
18+
}
1219
let form: FormData
1320
try {
1421
form = await req.formData()

0 commit comments

Comments
 (0)