import express, { Request, Response } from "express";
import { getPublicWorkouts, getPublicWorkout, WorkoutItem } 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 distInt(m: number): string {
return Math.round(m / 1000).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 файлы и получите детальную аналитику.
Последние тренировки
Возможности
- Нормализованная мощность (NP) и FTP
- Пульсовые зоны и скорость
- Каденс и дистанция
- Профиль высоты
`;
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: WorkoutItem) => {
const meta: string[] = [];
if (w.workouted_at) {
meta.push(
` Дата: ${formatDate(w.workouted_at)}`,
);
}
if (w.photos && w.photos.length > 0) {
meta.push(` Фото: ${w.photos.length}`);
}
if (w.speed) {
meta.push(
` Скорость: ${formatSpeed(
w.speed,
)} км/ч `,
);
}
if (w.heart_rate) {
meta.push(
` Пульс: ${w.heart_rate} уд /мин `,
);
}
if (w.distantion) {
meta.push(
` Расстояние: ${distInt(
w.distantion,
)} км `,
);
}
if (w.duraion_sec) {
meta.push(
` Продолжительность: ${formatDuration(
w.duraion_sec,
)}`,
);
}
if (w.cadence) {
meta.push(` Каденс: ${w.cadence} об/мин `);
}
if (w.power) {
meta.push(
` Мощность: ${Math.round(w.power)} Вт `,
);
}
if (w.external_links && w.external_links.values.length > 0) {
meta.push(
` Ссылки: Дзен`,
);
}
const imageUrl = w.attachment?.url;
const image = imageUrl
? `\n `
: "";
return `
${image}
`;
})
.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 }) =>
` `,
)
.join("\n");
photosHtml = `
`;
}
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}`);
});