Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions apps/web/app/.well-known/security.txt/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { SITE } from '@/lib/site'

/**
* /.well-known/security.txt — RFC 9116.
*
* The disclosure address was already published in prose on /security; this is
* the same fact where a scanner looks for it.
*
* Expires is required by the RFC and must be in the future, so it is computed
* rather than written down: a hard-coded date is a file that silently becomes
* invalid on a day nobody has in their calendar. A year out, recomputed on each
* deploy, means it stays valid as long as the site is maintained — and goes
* stale only once the site itself has been abandoned, which is exactly the
* signal the field is for.
*/
export const dynamic = 'force-dynamic'

export async function GET() {
const expires = new Date()
expires.setUTCFullYear(expires.getUTCFullYear() + 1)

const body = `Contact: mailto:security@profullstack.com
Expires: ${expires.toISOString()}
Preferred-Languages: en
Canonical: ${SITE.url}/.well-known/security.txt
Policy: ${SITE.url}/security
`

return new Response(body, {
headers: {
'content-type': 'text/plain; charset=utf-8',
'cache-control': 'public, max-age=0, s-maxage=86400',
},
})
}
13 changes: 12 additions & 1 deletion apps/web/app/docs/[slug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,12 @@ import { listDocs, readDoc } from '@/lib/docs'

type Params = { params: Promise<{ slug: string }> }

/**
* Docs that are also published at a shorter, promoted URL. The promoted page is
* canonical; this one keeps its place in the docs tree without competing with it.
*/
const PROMOTED: Record<string, string> = { security: '/security' }

/** Pre-rendered at build time: the docs are static files, so the pages are too. */
export async function generateStaticParams() {
const docs = await listDocs()
Expand All @@ -20,7 +26,12 @@ export async function generateMetadata({ params }: Params): Promise<Metadata> {
return {
title: doc.title,
description: meta?.description || `${doc.title} — DiskPush documentation.`,
alternates: { canonical: `/docs/${slug}` },
// A doc with a promoted page of its own is the same bytes at two URLs —
// same markdown, same rendered HTML, same title. Point the canonical at the
// promoted one rather than letting a crawler pick, and rather than dropping
// either URL: /security is linked from every footer, and /docs/security is
// where the docs sidebar goes.
alternates: { canonical: PROMOTED[slug] ?? `/docs/${slug}` },
}
}

Expand Down
58 changes: 58 additions & 0 deletions apps/web/app/llms.txt/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { listDocs } from '@/lib/docs'
import { SITE } from '@/lib/site'

/**
* /llms.txt — the llmstxt.org convention.
*
* Generated rather than written, for the same reason the docs pages are: a
* hand-kept copy of the docs index is a copy that goes stale, and a stale
* llms.txt is worse than none because models quote it confidently.
*
* Deliberately not a copy of the homepage. It answers, in the order a model
* needs them, the questions a marketing page buries: what this is, what it
* costs, who makes it, and which page to read next.
*/
export const dynamic = 'force-static'

export async function GET() {
const docs = await listDocs()

const body = `# ${SITE.name}

> ${SITE.description}

${SITE.name} is free and open source under the MIT licence. There is no account,
no paid tier, and no ${SITE.name} server in any transfer path — the desktop app
and the CLI speak SSH and rsync directly to your own hosts. Linux first, macOS
supported. Maintained by Profullstack, Inc.

## Start here

- [Home](${SITE.url}): What it is, how it works, and the ten most common questions.
- [Download](${SITE.url}/download): Current release for Linux and macOS, plus the one-line installer.
- [Documentation](${SITE.url}/docs): Every guide, rendered from the repository's own docs directory.

## Documentation

${docs.map((doc) => `- [${doc.title}](${SITE.url}/docs/${doc.slug}): ${doc.description}`).join('\n')}

## Project

- [Source](${SITE.repo}): MIT licensed, on GitHub.
- [Releases](${SITE.releases}): Version history and build artifacts.
- [Security model](${SITE.url}/security): Threat model, argument handling, host keys, credentials and destructive-operation guards.
- [Privacy](${SITE.url}/privacy): What the app and the site do and do not collect.

## Contact

- Security reports: security@profullstack.com
- Everything else: GitHub issues at ${SITE.repo}/issues
`

return new Response(body, {
headers: {
'content-type': 'text/plain; charset=utf-8',
'cache-control': 'public, max-age=0, s-maxage=3600',
},
})
}
32 changes: 32 additions & 0 deletions apps/web/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,13 +54,45 @@ export default function HomePage() {
__html: JSON.stringify({
'@context': 'https://schema.org',
'@graph': [
// The maker, as an entity a knowledge graph can resolve. Without
// this the schema described an application that nothing published:
// the only trace of who builds DiskPush was a GitHub URL and the
// domain on the security address.
{
'@type': 'Organization',
'@id': `${SITE.url}/#organization`,
name: 'Profullstack, Inc.',
url: 'https://profullstack.com',
logo: `${SITE.url}/logo.png`,
sameAs: ['https://github.com/profullstack'],
contactPoint: {
'@type': 'ContactPoint',
contactType: 'security',
email: 'security@profullstack.com',
url: `${SITE.url}/security`,
},
},
{
'@type': 'WebSite',
'@id': `${SITE.url}/#website`,
name: SITE.name,
url: SITE.url,
description: SITE.description,
publisher: { '@id': `${SITE.url}/#organization` },
inLanguage: 'en',
},
{
'@type': 'SoftwareApplication',
name: 'DiskPush',
applicationCategory: 'DeveloperApplication',
operatingSystem: 'Linux, macOS',
description: SITE.description,
url: SITE.url,
downloadUrl: `${SITE.url}/download`,
license: 'https://opensource.org/licenses/MIT',
isAccessibleForFree: true,
publisher: { '@id': `${SITE.url}/#organization` },
author: { '@id': `${SITE.url}/#organization` },
offers: { '@type': 'Offer', price: '0', priceCurrency: 'USD' },
},
{
Expand Down
30 changes: 29 additions & 1 deletion apps/web/app/robots.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,37 @@
import type { MetadataRoute } from 'next'
import { SITE } from '@/lib/site'

/**
* The wildcard already allows everything, so these named rules grant no access
* the crawlers did not have. They are here because the policy is worth stating
* rather than inferring: DiskPush is MIT-licensed software whose documentation
* exists to be read, and an answer engine that summarises it accurately is
* doing the thing the docs are for.
*
* Named explicitly so the position is legible to a person reading the file, and
* so that narrowing it later is an edit rather than a decision nobody recorded.
*/
const AI_CRAWLERS = [
'GPTBot',
'OAI-SearchBot',
'ChatGPT-User',
'ClaudeBot',
'Claude-User',
'PerplexityBot',
'Google-Extended',
'Applebot-Extended',
'CCBot',
'meta-externalagent',
'Bytespider',
]

export default function robots(): MetadataRoute.Robots {
return {
rules: [{ userAgent: '*', allow: '/' }],
rules: [
{ userAgent: '*', allow: '/' },
...AI_CRAWLERS.map((userAgent) => ({ userAgent, allow: '/' })),
],
sitemap: `${SITE.url}/sitemap.xml`,
host: SITE.url,
}
}
63 changes: 54 additions & 9 deletions apps/web/app/sitemap.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,66 @@
import { stat } from 'node:fs/promises'
import { join } from 'node:path'
import type { MetadataRoute } from 'next'
import { listDocs } from '@/lib/docs'
import { SITE } from '@/lib/site'

/**
* Every URL used to carry `new Date()`, so all eighteen shared one build
* timestamp. A lastmod that changes on every deploy and is identical across the
* site says nothing about what actually changed, and crawlers discount it —
* which is worse than sending none, because it costs a field and buys nothing.
*
* The real date is on disk. Doc pages are rendered from the repository's own
* `docs/*.md`, so that file's mtime *is* when the page last changed; the rest
* are their own source files. Falls back to the build time only when a stat
* fails, which should not happen but must not break the sitemap if it does.
*/
const ROOT = join(process.cwd(), '..', '..')
const buildTime = new Date()

async function modified(...relative: string[]): Promise<Date> {
const times = await Promise.all(
relative.map(async (path) => {
try {
return (await stat(join(ROOT, path))).mtime
} catch {
return null
}
}),
)
const known = times.filter((time): time is Date => time !== null)
if (known.length === 0) return buildTime
// The newest of the sources a page is built from.
return new Date(Math.max(...known.map((time) => time.getTime())))
}

export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const docs = await listDocs()
const now = new Date()
return [
{ url: SITE.url, lastModified: now, changeFrequency: 'weekly', priority: 1 },
{ url: `${SITE.url}/download`, lastModified: now, changeFrequency: 'weekly', priority: 0.9 },
{ url: `${SITE.url}/docs`, lastModified: now, changeFrequency: 'weekly', priority: 0.8 },
{ url: `${SITE.url}/security`, lastModified: now, changeFrequency: 'monthly', priority: 0.5 },
{ url: `${SITE.url}/privacy`, lastModified: now, changeFrequency: 'yearly', priority: 0.3 },
...docs.map((doc) => ({
const web = 'apps/web'

const [home, download, docsIndex, security, privacy] = await Promise.all([
modified(`${web}/app/page.tsx`, `${web}/app/layout.tsx`),
modified(`${web}/app/download/page.tsx`),
modified(`${web}/app/docs/page.tsx`),
modified(`${web}/app/security/page.tsx`),
modified(`${web}/app/privacy/page.tsx`),
])

const docEntries = await Promise.all(
docs.map(async (doc) => ({
url: `${SITE.url}/docs/${doc.slug}`,
lastModified: now,
lastModified: await modified(`docs/${doc.slug}.md`),
changeFrequency: 'monthly' as const,
priority: 0.7,
})),
)

return [
{ url: SITE.url, lastModified: home, changeFrequency: 'weekly', priority: 1 },
{ url: `${SITE.url}/download`, lastModified: download, changeFrequency: 'weekly', priority: 0.9 },
{ url: `${SITE.url}/docs`, lastModified: docsIndex, changeFrequency: 'weekly', priority: 0.8 },
{ url: `${SITE.url}/security`, lastModified: security, changeFrequency: 'monthly', priority: 0.5 },
{ url: `${SITE.url}/privacy`, lastModified: privacy, changeFrequency: 'yearly', priority: 0.3 },
...docEntries,
]
}
39 changes: 39 additions & 0 deletions apps/web/next.config.mjs
Original file line number Diff line number Diff line change
@@ -1,4 +1,40 @@
/** @type {import('next').NextConfig} */

// Response headers the site was serving none of.
//
// The CSP is deliberately not `default-src 'self'` alone: Next inlines its
// hydration payload in a <script>, and the app's styles arrive inline too, so a
// policy without 'unsafe-inline' in those two places blanks the page. Scripts
// are otherwise same-origin only, which is the part that actually limits an
// injection, and `object-src 'none'` plus `frame-ancestors 'none'` close the
// two classic bypasses.
const csp = [
"default-src 'self'",
"script-src 'self' 'unsafe-inline'",
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data:",
"font-src 'self' data:",
// The download page reads release metadata from the GitHub API.
"connect-src 'self' https://api.github.com",
"object-src 'none'",
"base-uri 'self'",
"form-action 'self'",
"frame-ancestors 'none'",
'upgrade-insecure-requests',
].join('; ')

const securityHeaders = [
// Two years, subdomains included. No preload: the list's own operators now
// discourage it, and it is a one-way door.
{ key: 'Strict-Transport-Security', value: 'max-age=63072000; includeSubDomains' },
{ key: 'Content-Security-Policy', value: csp },
{ key: 'X-Content-Type-Options', value: 'nosniff' },
// frame-ancestors above is the real control; this is the legacy fallback.
{ key: 'X-Frame-Options', value: 'DENY' },
{ key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
{ key: 'Permissions-Policy', value: 'camera=(), microphone=(), geolocation=()' },
]

const nextConfig = {
reactStrictMode: true,
// The docs pages read Markdown from the repository at build time, so the
Expand All @@ -9,6 +45,9 @@ const nextConfig = {
// The installer is served from the repository's own copy.
'/install.sh': ['../../scripts/install.sh'],
},
async headers() {
return [{ source: '/:path*', headers: securityHeaders }]
},
}

export default nextConfig
Loading