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 ''; } } function escapeHtml(text: string): string { return text.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) => `
  • ${escapeHtml(w.name)} — ${formatDate(w.workouted_at)}, ${formatDistance(w.distantion)} км
  • `, ) .join("\n"); } catch { workoutsHtml = "
  • Загрузка…
  • "; } const content = `

    Cycle Rider — анализ велотренировок

    Платформа для анализа велотренировок: мощность, пульс, скорость, каденс. Загрузите FIT/GPX файлы и получите детальную аналитику.

    Последние тренировки

    Возможности

    `; 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 { 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) => `
  • \n ${escapeHtml(w.name)}\n — ${formatDate(w.workouted_at)}, ${formatDistance(w.distantion)} км, ${formatSpeed(w.speed)} км/ч, ${w.heart_rate} уд/мин\n
  • `, ) .join("\n"); const content = `

    Публичные тренировки

    Лента велотренировок от сообщества.

    `; 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 { 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.workout; const content = `

    ${escapeHtml(w.name)}

    ${escapeHtml(w.description)}

    `; const desc = 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) { // 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 = `

    Конструктор маршрутов

    Создайте свой велосипедный маршрут на карте. Добавляйте точки, рассчитывайте дистанцию и экспортируйте в GPX.

    `; const meta: SeoMeta = { title: "Конструктор веломаршрутов — Cycle Rider", description: "Создайте велосипедный маршрут на карте, экспортируйте в GPX. Cycle Rider.", canonicalUrl: `${BASE_URL}/routes`, }; res.send(renderTemplate({ meta, content, assetTags: getAssetTagsSafe() })); } catch { 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 = [ `${BASE_URL}/daily1.0`, `${BASE_URL}/exploredaily0.9`, `${BASE_URL}/routesweekly0.5`, ]; const workoutUrls = workouts.map((w) => { const date = new Date(w.workouted_at).toISOString().split("T")[0]; return `${BASE_URL}/public/workouts/${w.id}${date}monthly0.7`; }); const xml = `\n\n ${[ ...staticUrls, ...workoutUrls, ].join("\n ")}\n`; res.set("Content-Type", "application/xml; charset=utf-8"); res.send(xml); } catch { 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" }); }); app.listen(PORT, () => { console.log(`SSR server listening on :${PORT}`); });