strava-frontend/server/index.ts

445 lines
15 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, String.fromCharCode(38) + "amp;")
.replace(/</g, String.fromCharCode(38) + "lt;")
.replace(/>/g, String.fromCharCode(38) + "gt;")
.replace(/"/g, String.fromCharCode(38) + "quot;");
}
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;
}
// --- Build params section ---
const params: string[] = [];
if (w.workouted_at) {
params.push(
` <div class="workout-item-params"><div class="workout-item-params-name">Дата:</div><div class="workout-item-params-value">${formatDate(
w.workouted_at,
)}</div></div>`,
);
}
if (w.distantion) {
params.push(
` <div class="workout-item-params"><div class="workout-item-params-name">Расстояние:</div><div class="workout-item-params-value">${formatDistance(
w.distantion,
)} км</div></div>`,
);
}
if (w.heart_rate) {
params.push(
` <div class="workout-item-params"><div class="workout-item-params-name">Средний пульс:</div><div class="workout-item-params-value">${Math.floor(
w.heart_rate,
)} уд./ мин.</div></div>`,
);
}
if (w.max_heart_rate) {
params.push(
` <div class="workout-item-params"><div class="workout-item-params-name">Максимальный пульс:</div><div class="workout-item-params-value">${Math.floor(
w.max_heart_rate,
)} уд./ мин.</div></div>`,
);
}
if (w.speed) {
params.push(
` <div class="workout-item-params"><div class="workout-item-params-name">Средняя скорость:</div><div class="workout-item-params-value">${formatSpeed(
w.speed,
)} км / ч</div></div>`,
);
}
if (w.max_speed) {
params.push(
` <div class="workout-item-params"><div class="workout-item-params-name">Максимальная скорость:</div><div class="workout-item-params-value">${formatSpeed(
w.max_speed,
)} км / ч</div></div>`,
);
}
if (w.temperature) {
params.push(
` <div class="workout-item-params"><div class="workout-item-params-name">Температура:</div><div class="workout-item-params-value">${w.temperature} °C</div></div>`,
);
}
if (w.power) {
params.push(
` <div class="workout-item-params"><div class="workout-item-params-name">Средняя мощность:</div><div class="workout-item-params-value">${Math.floor(
w.power,
)} Вт</div></div>`,
);
}
if (w.max_power) {
params.push(
` <div class="workout-item-params"><div class="workout-item-params-name">Максимальная мощность:</div><div class="workout-item-params-value">${Math.floor(
w.max_power,
)} Вт</div></div>`,
);
}
if (w.cadence) {
params.push(
` <div class="workout-item-params"><div class="workout-item-params-name">Каденс:</div><div class="workout-item-params-value">${w.cadence} об/мин</div></div>`,
);
}
if (w.duraion_sec) {
params.push(
` <div class="workout-item-params"><div class="workout-item-params-name">Длительность:</div><div class="workout-item-params-value">${formatDuration(
w.duraion_sec,
)}</div></div>`,
);
}
if (
w.external_links &&
w.external_links.values &&
w.external_links.values.length > 0
) {
const dzenUrl = w.external_links.values[0].value;
params.push(
` <div class="workout-item-params"><div class="workout-item-params-name">Ссылки на описание:</div><div><a href="${escapeHtml(
dzenUrl,
)}" target="_blank">Дзен</a></div></div>`,
);
}
// --- Build photos section ---
let photosHtml = "";
if (w.photos && w.photos.length > 0) {
const photoItems = w.photos
.map(
(p: { id: string; url: string }) =>
` <div class="workout-photo"><img src="${escapeHtml(
p.url,
)}" alt="${escapeHtml(p.id)}" /></div>`,
)
.join("\n");
photosHtml = `
<div id="workout-photos">
<div class="workout-photos-grid">
${photoItems}
</div>
</div>`;
}
const content = `
<div id="workout-container">
<div id="workout-map" style="height:350px;background:#e9ecef;display:flex;align-items:center;justify-content:center;color:#999;font-family:sans-serif;">Карта (загрузится после инициализации JS)</div>
<div id="workout-short-data">
<div class="workout-item-editable-title"><h3>${escapeHtml(
w.name,
)}</h3></div>
${params.join("\n")}
</div>
</div>${photosHtml}
<div id="workout-charts" style="margin-top:20px;padding:20px;background:#f8f9fa;border-radius:6px;color:#999;text-align:center;font-family:sans-serif;">Графики (загрузятся после инициализации JS)</div>
<noscript><p>Для полноценного просмотра тренировки (карта, графики) включите JavaScript.</p></noscript>`;
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}`,
ogImage: w.attachment?.url,
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}`);
});