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 | null | undefined): string { if (!text) return ""; return text .replace(/&/g, String.fromCharCode(38) + "amp;") .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) => `
  • ${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 (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) => `
  • \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 (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( `
    Дата:
    ${formatDate( w.workouted_at, )}
    `, ); } if (w.distantion) { params.push( `
    Расстояние:
    ${formatDistance( w.distantion, )} км
    `, ); } if (w.heart_rate) { params.push( `
    Средний пульс:
    ${Math.floor( w.heart_rate, )} уд./ мин.
    `, ); } if (w.max_heart_rate) { params.push( `
    Максимальный пульс:
    ${Math.floor( w.max_heart_rate, )} уд./ мин.
    `, ); } if (w.speed) { params.push( `
    Средняя скорость:
    ${formatSpeed( w.speed, )} км / ч
    `, ); } if (w.max_speed) { params.push( `
    Максимальная скорость:
    ${formatSpeed( w.max_speed, )} км / ч
    `, ); } if (w.temperature) { params.push( `
    Температура:
    ${w.temperature} °C
    `, ); } if (w.power) { params.push( `
    Средняя мощность:
    ${Math.floor( w.power, )} Вт
    `, ); } if (w.max_power) { params.push( `
    Максимальная мощность:
    ${Math.floor( w.max_power, )} Вт
    `, ); } if (w.cadence) { params.push( `
    Каденс:
    ${w.cadence} об/мин
    `, ); } if (w.duraion_sec) { params.push( `
    Длительность:
    ${formatDuration( w.duraion_sec, )}
    `, ); } if ( w.external_links && w.external_links.values && w.external_links.values.length > 0 ) { const dzenUrl = w.external_links.values[0].value; params.push( `
    Ссылки на описание:
    Дзен
    `, ); } // --- Build photos section --- let photosHtml = ""; if (w.photos && w.photos.length > 0) { const photoItems = w.photos .map( (p: { id: string; url: string }) => `
    ${escapeHtml(p.id)}
    `, ) .join("\n"); photosHtml = `
    ${photoItems}
    `; } const content = `
    Карта (загрузится после инициализации JS)

    ${escapeHtml( w.name, )}

    ${params.join("\n")}
    ${photosHtml}
    Графики (загрузятся после инициализации JS)
    `; 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 = `

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

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

    `; 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 = [ `${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 (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 = `

    404 — Страница не найдена

    Запрашиваемая страница не существует или была перемещена.

    Вернуться на главную

    `; res .status(404) .send(renderTemplate({ meta, content, assetTags: getAssetTagsSafe() })); }); app.listen(PORT, () => { console.log(`SSR server listening on :${PORT}`); });