strava-frontend/server/index.ts

335 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import express, { Request, Response } from "express";
import { getPublicWorkouts, getPublicWorkout } from "./api";
import { renderTemplate, getAssetTags, SeoMeta } from "./template";
const app = express();
const PORT = process.env.SSR_PORT || 3001;
const BASE_URL = process.env.SSR_BASE_URL || "https://cycle-rider.ru";
app.disable("x-powered-by");
// --- Helpers ---
function getAssetTagsSafe(): string {
try {
return getAssetTags();
} catch {
return '<script type="module" src="/src/main.ts"></script>';
}
}
function escapeHtml(text: string | null | undefined): string {
if (!text) return "";
return text.replace(/</g, "<").replace(/>/g, ">");
}
function formatDate(iso: string): string {
const d = new Date(iso);
const dd = String(d.getDate()).padStart(2, "0");
const mm = String(d.getMonth() + 1).padStart(2, "0");
const yyyy = d.getFullYear();
return `${dd}.${mm}.${yyyy}`;
}
function formatDuration(sec: number): string {
const h = Math.floor(sec / 3600);
const m = Math.floor((sec % 3600) / 60);
return `${h} ч. ${m} мин.`;
}
function formatDistance(meters: number): string {
return (meters / 1000).toFixed(1);
}
function formatSpeed(ms: number): string {
return Math.round(ms * 3.6).toString();
}
function getToken(req: Request): string | null {
const cookieHeader = req.headers.cookie;
if (!cookieHeader) return null;
const match = cookieHeader.match(/(?:^|;\s*)token=([^;]*)/);
return match ? decodeURIComponent(match[1]) : null;
}
// --- Routes ---
// GET / — Landing page (SEO)
app.get("/", async (_req: Request, res: Response) => {
const token = getToken(_req);
if (token) {
res.redirect(302, "/explore");
return;
}
try {
let workoutsHtml: string;
try {
const workouts = await getPublicWorkouts();
const top5 = workouts.slice(0, 5);
workoutsHtml = top5
.map(
(w) =>
` <li><a href="/public/workouts/${w.id}">${escapeHtml(
w.name,
)}</a> — ${formatDate(w.workouted_at)}, ${formatDistance(
w.distantion,
)} км</li>`,
)
.join("\n");
} catch {
workoutsHtml = " <li>Загрузка…</li>";
}
const content = `
<h1>Cycle Rider — анализ велотренировок</h1>
<p>Платформа для анализа велотренировок: мощность, пульс, скорость, каденс. Загрузите FIT/GPX файлы и получите детальную аналитику.</p>
<h2>Последние тренировки</h2>
<ul>
${workoutsHtml}
</ul>
<h2>Возможности</h2>
<ul>
<li>Нормализованная мощность (NP) и FTP</li>
<li>Пульсовые зоны и скорость</li>
<li>Каденс и дистанция</li>
<li>Профиль высоты</li>
</ul>`;
const meta: SeoMeta = {
title: "Cycle Rider — Анализ велотренировок: мощность, пульс, скорость",
description:
"Анализируй велотренировки: мощность (FTP, NP), пульс, скорость, каденс, зоны мощности. Загрузка FIT/GPX. Cycle Rider.",
canonicalUrl: `${BASE_URL}/`,
jsonLd: {
"@context": "https://schema.org",
"@type": "WebApplication",
name: "Cycle Rider",
description: "Платформа для анализа велотренировок",
url: `${BASE_URL}/`,
applicationCategory: "HealthApplication",
operatingSystem: "Web",
offers: {
"@type": "Offer",
price: "0",
},
},
};
res.send(renderTemplate({ meta, content, assetTags: getAssetTagsSafe() }));
} catch (err) {
console.error("SSR error:", err);
res.status(500).send("Internal Server Error");
}
});
// GET /explore — Public feed
app.get("/explore", async (_req: Request, res: Response) => {
try {
const workouts = await getPublicWorkouts();
const list = workouts
.map(
(w) =>
` <li>\n <a href="/public/workouts/${w.id}">${escapeHtml(
w.name,
)}</a>\n — ${formatDate(w.workouted_at)}, ${formatDistance(
w.distantion,
)} км, ${formatSpeed(w.speed)} км/ч, ${
w.heart_rate
} уд/мин\n </li>`,
)
.join("\n");
const content = `
<h1>Публичные тренировки</h1>
<p>Лента велотренировок от сообщества.</p>
<ul>
${list}
</ul>`;
const meta: SeoMeta = {
title: "Публичные велотренировки — Cycle Rider",
description:
"Лента публичных велотренировок: скорость, пульс, мощность, расстояние. Cycle Rider.",
canonicalUrl: `${BASE_URL}/explore`,
jsonLd: {
"@context": "https://schema.org",
"@type": "WebPage",
name: "Публичные велотренировки",
url: `${BASE_URL}/explore`,
description:
"Лента публичных велотренировок: скорость, пульс, мощность, расстояние.",
},
};
res.send(renderTemplate({ meta, content, assetTags: getAssetTagsSafe() }));
} catch (err) {
console.error("SSR error:", err);
res.status(500).send("Internal Server Error");
}
});
// GET /public/workouts/:id — Workout detail
app.get("/public/workouts/:id", async (req: Request, res: Response) => {
try {
const detail = await getPublicWorkout(req.params.id);
const w = (detail as any).workout || detail;
if (!w || !w.name) {
console.error(
"SSR: unexpected API response shape:",
JSON.stringify(detail).slice(0, 500),
);
res.status(500).send("Unexpected API response");
return;
}
const content = `
<h1>${escapeHtml(w.name)}</h1>
<p>${escapeHtml(w.description)}</p>
<ul class="workout-metrics">
<li>Дата: ${formatDate(w.workouted_at)}</li>
<li>Расстояние: ${formatDistance(w.distantion)} км</li>
<li>Средняя скорость: ${formatSpeed(w.speed)} км/ч</li>
<li>Макс. скорость: ${formatSpeed(w.max_speed)} км/ч</li>
<li>Средний пульс: ${w.heart_rate} уд/мин</li>
<li>Макс. пульс: ${w.max_heart_rate} уд/мин</li>
<li>Средняя мощность: ${w.power} Вт</li>
<li>Макс. мощность: ${w.max_power} Вт</li>
<li>Каденс: ${w.cadence} об/мин</li>
<li>Длительность: ${formatDuration(w.duraion_sec)}</li>
</ul>`;
const desc = w.description
? w.description.slice(0, 150) + (w.description.length > 150 ? "…" : "")
: "";
const metrics = `${formatDistance(w.distantion)} км, ${formatSpeed(
w.speed,
)} км/ч, пульс ${w.heart_rate}, мощность ${w.power} Вт`;
const meta: SeoMeta = {
title: `${w.name} — Cycle Rider`,
description: `${desc} ${metrics}`,
canonicalUrl: `${BASE_URL}/public/workouts/${w.id}`,
jsonLd: {
"@context": "https://schema.org",
"@type": "SportsActivity",
name: w.name,
description: desc,
url: `${BASE_URL}/public/workouts/${w.id}`,
startDate: w.workouted_at,
sport: "Cycling",
},
};
res.send(renderTemplate({ meta, content, assetTags: getAssetTagsSafe() }));
} catch (err: unknown) {
console.error("SSR error:", err);
// Check for 404 from API
if (
err &&
typeof err === "object" &&
"response" in err &&
(err as { response?: { status?: number } }).response?.status === 404
) {
res.status(404).send("Not found");
return;
}
res.status(500).send("Internal Server Error");
}
});
// GET /routes — Routes page (minimal)
app.get("/routes", async (_req: Request, res: Response) => {
try {
const content = `
<h1>Конструктор маршрутов</h1>
<p>Создайте свой велосипедный маршрут на карте. Добавляйте точки, рассчитывайте дистанцию и экспортируйте в GPX.</p>`;
const meta: SeoMeta = {
title: "Конструктор веломаршрутов — Cycle Rider",
description:
"Создайте велосипедный маршрут на карте, экспортируйте в GPX. Cycle Rider.",
canonicalUrl: `${BASE_URL}/routes`,
};
res.send(renderTemplate({ meta, content, assetTags: getAssetTagsSafe() }));
} catch (err) {
console.error("SSR error:", err);
res.status(500).send("Internal Server Error");
}
});
// GET /sitemap.xml
app.get("/sitemap.xml", async (_req: Request, res: Response) => {
try {
const workouts = await getPublicWorkouts();
const staticUrls = [
`<url><loc>${BASE_URL}/</loc><changefreq>daily</changefreq><priority>1.0</priority></url>`,
`<url><loc>${BASE_URL}/explore</loc><changefreq>daily</changefreq><priority>0.9</priority></url>`,
`<url><loc>${BASE_URL}/routes</loc><changefreq>weekly</changefreq><priority>0.5</priority></url>`,
];
const workoutUrls = workouts.map((w) => {
const date = new Date(w.workouted_at).toISOString().split("T")[0];
return `<url><loc>${BASE_URL}/public/workouts/${w.id}</loc><lastmod>${date}</lastmod><changefreq>monthly</changefreq><priority>0.7</priority></url>`;
});
const xml = `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n ${[
...staticUrls,
...workoutUrls,
].join("\n ")}\n</urlset>`;
res.set("Content-Type", "application/xml; charset=utf-8");
res.send(xml);
} catch (err) {
console.error("SSR error:", err);
res.status(500).send("Internal Server Error");
}
});
// GET /robots.txt
app.get("/robots.txt", (_req: Request, res: Response) => {
const text = `User-agent: *
Allow: /
Disallow: /auth/
Disallow: /workouts/
Disallow: /preferences/
Disallow: /admin/
Sitemap: ${BASE_URL}/sitemap.xml
`;
res.set("Content-Type", "text/plain; charset=utf-8");
res.send(text);
});
// Health check
app.get("/health", (_req: Request, res: Response) => {
res.json({ status: "ok" });
});
// Catch-all 404 — SEO
app.use((req: Request, res: Response) => {
if (req.method !== "GET") {
res.status(404).send("Not Found");
return;
}
const meta: SeoMeta = {
title: "404 — Страница не найдена — Cycle Rider",
description:
"Страница не найдена. Вернитесь на главную страницу Cycle Rider.",
canonicalUrl: `${BASE_URL}/404`,
};
const content = `
<h1>404 — Страница не найдена</h1>
<p>Запрашиваемая страница не существует или была перемещена.</p>
<p><a href="/">Вернуться на главную</a></p>`;
res
.status(404)
.send(renderTemplate({ meta, content, assetTags: getAssetTagsSafe() }));
});
app.listen(PORT, () => {
console.log(`SSR server listening on :${PORT}`);
});