This commit is contained in:
Bastian Wagner
2026-06-16 12:15:29 +02:00
commit 38141c0358
80 changed files with 23444 additions and 0 deletions

View File

@@ -0,0 +1,49 @@
import { StravaActivityEntity } from '../database/entities';
import { StravaActivityPayload } from './strava.types';
export const mapStravaActivity = (
stravaAthleteId: string,
payload: StravaActivityPayload,
existing?: StravaActivityEntity | null,
): StravaActivityEntity => {
const activity = existing ?? new StravaActivityEntity();
activity.stravaAthleteId = stravaAthleteId;
activity.stravaActivityId = String(payload.id);
activity.name = payload.name ?? `Activity ${payload.id}`;
activity.sportType = payload.sport_type ?? payload.type ?? null;
activity.distance = numberOrNull(payload.distance);
activity.movingTime = numberOrNull(payload.moving_time);
activity.elapsedTime = numberOrNull(payload.elapsed_time);
activity.totalElevationGain = numberOrNull(payload.total_elevation_gain);
activity.startDate = dateOrNull(payload.start_date);
activity.startDateLocal = dateOrNull(payload.start_date_local);
activity.timezone = payload.timezone ?? null;
activity.utcOffset = numberOrNull(payload.utc_offset);
activity.averageSpeed = numberOrNull(payload.average_speed);
activity.maxSpeed = numberOrNull(payload.max_speed);
activity.averageHeartrate = numberOrNull(payload.average_heartrate);
activity.maxHeartrate = numberOrNull(payload.max_heartrate);
activity.averageWatts = numberOrNull(payload.average_watts);
activity.maxWatts = numberOrNull(payload.max_watts);
activity.weightedAverageWatts = numberOrNull(payload.weighted_average_watts);
activity.averageCadence = numberOrNull(payload.average_cadence);
activity.calories = numberOrNull(payload.calories);
activity.gearId = payload.gear_id ?? null;
activity.trainer = Boolean(payload.trainer);
activity.commute = Boolean(payload.commute);
activity.manual = Boolean(payload.manual);
activity.private = Boolean(payload.private);
activity.visibility = payload.visibility ?? null;
activity.mapId = payload.map?.id ?? null;
activity.summaryPolyline = payload.map?.summary_polyline ?? null;
activity.resourceState = numberOrNull(payload.resource_state);
activity.rawPayload = payload;
return activity;
};
const numberOrNull = (value: unknown): number | null =>
typeof value === 'number' && Number.isFinite(value) ? value : null;
const dateOrNull = (value: unknown): Date | null =>
typeof value === 'string' ? new Date(value) : null;

View File

@@ -0,0 +1,60 @@
import { Controller, Get, Query, Redirect } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { StravaAuthService } from './strava-auth.service';
@Controller('auth/strava')
export class StravaAuthController {
constructor(
private readonly stravaAuthService: StravaAuthService,
private readonly configService: ConfigService,
) {}
@Get('connect')
@Redirect()
connect(): { url: string } {
return { url: this.stravaAuthService.buildAuthorizationUrl() };
}
@Get('callback')
@Redirect()
async callback(
@Query('code') code?: string,
@Query('scope') scope?: string,
@Query('error') error?: string,
): Promise<{ url: string }> {
if (error || !code) {
return {
url: `${this.clientUrl()}?strava=error`,
};
}
await this.stravaAuthService.handleCallback(code, scope);
return {
url: `${this.clientUrl()}?strava=connected`,
};
}
@Get('status')
status(): Promise<{
connected: boolean;
athlete: {
id: string;
stravaAthleteId: string;
username: string | null;
firstName: string | null;
lastName: string | null;
profile: string | null;
updatedAt: Date;
} | null;
}> {
return this.stravaAuthService.getStatus();
}
private clientUrl(): string {
return (
this.configService.get<string>('CLIENT_URL') ??
this.configService.get<string>('CORS_ORIGIN')?.split(',')[0] ??
'http://localhost:8080'
);
}
}

View File

@@ -0,0 +1,112 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { StravaAthleteEntity } from '../database/entities';
import { StravaClientService } from './strava-client.service';
import { StravaTokenService } from './strava-token.service';
import { StravaAthletePayload } from './strava.types';
@Injectable()
export class StravaAuthService {
constructor(
@InjectRepository(StravaAthleteEntity)
private readonly athleteRepository: Repository<StravaAthleteEntity>,
private readonly stravaClientService: StravaClientService,
private readonly stravaTokenService: StravaTokenService,
) {}
buildAuthorizationUrl(): string {
return this.stravaClientService.buildAuthorizeUrl(
Buffer.from(
JSON.stringify({ issuedAt: new Date().toISOString() }),
'utf8',
).toString('base64url'),
);
}
async handleCallback(
code: string,
scope?: string,
): Promise<{
stravaAthleteId: string;
scope: string | null;
}> {
const tokenPayload = await this.stravaClientService.exchangeCode(code);
if (!tokenPayload.athlete) {
throw new Error('Strava OAuth response did not include athlete payload');
}
const athlete = await this.saveAthlete(tokenPayload.athlete);
await this.stravaTokenService.saveTokens({
stravaAthleteId: athlete.id,
accessToken: tokenPayload.access_token,
refreshToken: tokenPayload.refresh_token,
expiresAt: new Date(tokenPayload.expires_at * 1000),
scope: scope ?? null,
tokenType: tokenPayload.token_type ?? null,
});
return {
stravaAthleteId: athlete.id,
scope: scope ?? null,
};
}
async getStatus(): Promise<{
connected: boolean;
athlete: {
id: string;
stravaAthleteId: string;
username: string | null;
firstName: string | null;
lastName: string | null;
profile: string | null;
updatedAt: Date;
} | null;
}> {
const athlete = await this.athleteRepository.findOne({
where: { accountKey: 'primary' },
});
return {
connected: Boolean(athlete),
athlete: athlete
? {
id: athlete.id,
stravaAthleteId: athlete.stravaAthleteId,
username: athlete.username,
firstName: athlete.firstName,
lastName: athlete.lastName,
profile: athlete.profile,
updatedAt: athlete.updatedAt,
}
: null,
};
}
private async saveAthlete(
payload: StravaAthletePayload,
): Promise<StravaAthleteEntity> {
const stravaAthleteId = String(payload.id);
const existing = await this.athleteRepository.findOne({
where: [{ accountKey: 'primary' }, { stravaAthleteId }],
});
const athlete = existing ?? this.athleteRepository.create();
athlete.accountKey = 'primary';
athlete.stravaAthleteId = stravaAthleteId;
athlete.username = payload.username ?? null;
athlete.firstName = payload.firstname ?? null;
athlete.lastName = payload.lastname ?? null;
athlete.city = payload.city ?? null;
athlete.state = payload.state ?? null;
athlete.country = payload.country ?? null;
athlete.sex = payload.sex ?? null;
athlete.profileMedium = payload.profile_medium ?? null;
athlete.profile = payload.profile ?? null;
athlete.rawPayload = payload as unknown as Record<string, unknown>;
return this.athleteRepository.save(athlete);
}
}

View File

@@ -0,0 +1,165 @@
import { HttpService } from '@nestjs/axios';
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { AxiosError, AxiosRequestConfig } from 'axios';
import { firstValueFrom } from 'rxjs';
import { StravaRateLimitError } from './strava-rate-limit.error';
import {
StravaActivityPayload,
StravaStreamPayload,
StravaTokenPayload,
} from './strava.types';
@Injectable()
export class StravaClientService {
private readonly clientId: string;
private readonly clientSecret: string;
private readonly redirectUri: string;
constructor(
private readonly httpService: HttpService,
configService: ConfigService,
) {
this.clientId = this.required(configService, 'STRAVA_CLIENT_ID');
this.clientSecret = this.required(configService, 'STRAVA_CLIENT_SECRET');
this.redirectUri = this.required(configService, 'STRAVA_REDIRECT_URI');
}
buildAuthorizeUrl(state: string): string {
const params = new URLSearchParams({
client_id: this.clientId,
redirect_uri: this.redirectUri,
response_type: 'code',
approval_prompt: 'auto',
scope: 'read,activity:read_all',
state,
});
return `https://www.strava.com/oauth/authorize?${params.toString()}`;
}
async exchangeCode(code: string): Promise<StravaTokenPayload> {
return this.postToken({
client_id: this.clientId,
client_secret: this.clientSecret,
code,
grant_type: 'authorization_code',
});
}
async refreshToken(refreshToken: string): Promise<StravaTokenPayload> {
return this.postToken({
client_id: this.clientId,
client_secret: this.clientSecret,
grant_type: 'refresh_token',
refresh_token: refreshToken,
});
}
async listActivities(
accessToken: string,
page: number,
perPage = 100,
): Promise<StravaActivityPayload[]> {
return this.request<StravaActivityPayload[]>({
method: 'GET',
url: 'https://www.strava.com/api/v3/athlete/activities',
headers: this.authHeaders(accessToken),
params: { page, per_page: perPage },
});
}
async getActivity(
accessToken: string,
stravaActivityId: string,
): Promise<StravaActivityPayload> {
return this.request<StravaActivityPayload>({
method: 'GET',
url: `https://www.strava.com/api/v3/activities/${stravaActivityId}`,
headers: this.authHeaders(accessToken),
});
}
async getActivityStreams(
accessToken: string,
stravaActivityId: string,
): Promise<StravaStreamPayload[]> {
return this.request<StravaStreamPayload[]>({
method: 'GET',
url: `https://www.strava.com/api/v3/activities/${stravaActivityId}/streams`,
headers: this.authHeaders(accessToken),
params: {
keys: 'time,distance,latlng,altitude,velocity_smooth,heartrate,cadence,watts,temp,moving,grade_smooth',
key_by_type: false,
},
});
}
private async postToken(
body: Record<string, string>,
): Promise<StravaTokenPayload> {
return this.request<StravaTokenPayload>({
method: 'POST',
url: 'https://www.strava.com/oauth/token',
data: body,
});
}
private async request<T>(config: AxiosRequestConfig): Promise<T> {
try {
const response = await firstValueFrom(
this.httpService.request<T>(config),
);
return response.data;
} catch (error) {
if (this.isAxiosError(error) && error.response?.status === 429) {
throw new StravaRateLimitError(
'Strava API rate limit exceeded',
this.parseRetryAfter(error),
);
}
throw error;
}
}
private parseRetryAfter(error: AxiosError): Date | null {
const headers = error.response?.headers as
| Record<string, unknown>
| undefined;
const value = this.headerValue(headers?.['retry-after']);
const seconds = Number(value);
return Number.isFinite(seconds)
? new Date(Date.now() + seconds * 1000)
: null;
}
private headerValue(value: unknown): string | number | null {
if (Array.isArray(value)) {
const values = value as unknown[];
return this.headerValue(values[0]);
}
return typeof value === 'string' || typeof value === 'number'
? value
: null;
}
private authHeaders(accessToken: string): Record<string, string> {
return { Authorization: `Bearer ${accessToken}` };
}
private required(configService: ConfigService, key: string): string {
const value = configService.get<string>(key);
if (!value) {
throw new Error(`Missing required environment variable: ${key}`);
}
return value;
}
private isAxiosError(error: unknown): error is AxiosError {
return (
typeof error === 'object' && error !== null && 'isAxiosError' in error
);
}
}

View File

@@ -0,0 +1,9 @@
export class StravaRateLimitError extends Error {
constructor(
message: string,
readonly retryAfter: Date | null,
) {
super(message);
this.name = 'StravaRateLimitError';
}
}

View File

@@ -0,0 +1,60 @@
import { StravaStreamNormalizerService } from './strava-stream-normalizer.service';
describe('StravaStreamNormalizerService', () => {
const service = new StravaStreamNormalizerService();
it('normalizes known Strava streams into point rows', () => {
const points = service.normalize('activity-1', [
{ type: 'time', data: [0, 10] },
{ type: 'distance', data: [0, 50] },
{
type: 'latlng',
data: [
[48.1, 16.1],
[48.2, 16.2],
],
},
{ type: 'heartrate', data: [140, 145] },
{ type: 'moving', data: [true, false] },
]);
expect(points).toEqual([
expect.objectContaining({
activityId: 'activity-1',
pointIndex: 0,
timeSeconds: 0,
distance: 0,
latitude: 48.1,
longitude: 16.1,
heartRate: 140,
moving: true,
}),
expect.objectContaining({
pointIndex: 1,
timeSeconds: 10,
distance: 50,
latitude: 48.2,
longitude: 16.2,
heartRate: 145,
moving: false,
}),
]);
});
it('fills missing stream values with null', () => {
const points = service.normalize('activity-1', [
{ type: 'time', data: [0, 10] },
{ type: 'watts', data: [250] },
]);
expect(points).toHaveLength(2);
expect(points[1]).toEqual(
expect.objectContaining({
timeSeconds: 10,
watts: null,
latitude: null,
longitude: null,
}),
);
});
});

View File

@@ -0,0 +1,61 @@
import { Injectable } from '@nestjs/common';
import { StravaActivityStreamPointEntity } from '../database/entities';
import { StravaStreamPayload } from './strava.types';
type StreamDataByType = Record<string, unknown[]>;
@Injectable()
export class StravaStreamNormalizerService {
normalize(
activityId: string,
streams: StravaStreamPayload[],
): Partial<StravaActivityStreamPointEntity>[] {
const streamData = this.byType(streams);
const pointCount = Math.max(
0,
...Object.values(streamData).map((values) => values.length),
);
return Array.from({ length: pointCount }, (_, pointIndex) => {
const latlng = this.tuple(streamData.latlng?.[pointIndex]);
return {
activityId,
pointIndex,
timeSeconds: this.numberOrNull(streamData.time?.[pointIndex]),
distance: this.numberOrNull(streamData.distance?.[pointIndex]),
latitude: this.numberOrNull(latlng?.[0]),
longitude: this.numberOrNull(latlng?.[1]),
altitude: this.numberOrNull(streamData.altitude?.[pointIndex]),
velocitySmooth: this.numberOrNull(
streamData.velocity_smooth?.[pointIndex],
),
heartRate: this.numberOrNull(streamData.heartrate?.[pointIndex]),
cadence: this.numberOrNull(streamData.cadence?.[pointIndex]),
watts: this.numberOrNull(streamData.watts?.[pointIndex]),
temperature: this.numberOrNull(streamData.temp?.[pointIndex]),
moving: this.booleanOrNull(streamData.moving?.[pointIndex]),
gradeSmooth: this.numberOrNull(streamData.grade_smooth?.[pointIndex]),
};
});
}
private byType(streams: StravaStreamPayload[]): StreamDataByType {
return streams.reduce<StreamDataByType>((result, stream) => {
result[stream.type] = Array.isArray(stream.data) ? stream.data : [];
return result;
}, {});
}
private tuple(value: unknown): unknown[] | null {
return Array.isArray(value) ? value : null;
}
private numberOrNull(value: unknown): number | null {
return typeof value === 'number' && Number.isFinite(value) ? value : null;
}
private booleanOrNull(value: unknown): boolean | null {
return typeof value === 'boolean' ? value : null;
}
}

View File

@@ -0,0 +1,18 @@
import { Controller, Get, Param, Post } from '@nestjs/common';
import { StravaSyncJobEntity } from '../database/entities';
import { StravaSyncService } from './strava-sync.service';
@Controller('strava/sync')
export class StravaSyncController {
constructor(private readonly stravaSyncService: StravaSyncService) {}
@Post()
start(): Promise<StravaSyncJobEntity> {
return this.stravaSyncService.startSync();
}
@Get('jobs/:id')
getJob(@Param('id') id: string): Promise<StravaSyncJobEntity> {
return this.stravaSyncService.getJob(id);
}
}

View File

@@ -0,0 +1,218 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity';
import {
StravaActivityEntity,
StravaActivityStreamPointEntity,
StravaAthleteEntity,
StravaSyncJobEntity,
StravaSyncJobItemEntity,
} from '../database/entities';
import { mapStravaActivity } from './strava-activity.mapper';
import { StravaClientService } from './strava-client.service';
import { StravaRateLimitError } from './strava-rate-limit.error';
import { StravaStreamNormalizerService } from './strava-stream-normalizer.service';
import { StravaTokenService } from './strava-token.service';
import { StravaActivityPayload } from './strava.types';
@Injectable()
export class StravaSyncService {
constructor(
@InjectRepository(StravaAthleteEntity)
private readonly athleteRepository: Repository<StravaAthleteEntity>,
@InjectRepository(StravaActivityEntity)
private readonly activityRepository: Repository<StravaActivityEntity>,
@InjectRepository(StravaActivityStreamPointEntity)
private readonly streamPointRepository: Repository<StravaActivityStreamPointEntity>,
@InjectRepository(StravaSyncJobEntity)
private readonly jobRepository: Repository<StravaSyncJobEntity>,
@InjectRepository(StravaSyncJobItemEntity)
private readonly jobItemRepository: Repository<StravaSyncJobItemEntity>,
private readonly stravaTokenService: StravaTokenService,
private readonly stravaClientService: StravaClientService,
private readonly streamNormalizer: StravaStreamNormalizerService,
) {}
async startSync(): Promise<StravaSyncJobEntity> {
const athlete = await this.resolveAthlete();
const job = await this.jobRepository.save(
this.jobRepository.create({
stravaAthleteId: athlete.id,
status: 'queued',
}),
);
void this.runJob(job.id);
return job;
}
async getJob(jobId: string): Promise<StravaSyncJobEntity> {
const job = await this.jobRepository.findOne({
where: { id: jobId },
relations: { items: true },
});
if (!job) {
throw new NotFoundException('Sync job not found');
}
return job;
}
async runJob(jobId: string): Promise<void> {
const job = await this.getJob(jobId);
job.status = 'running';
job.startedAt = new Date();
await this.jobRepository.save(job);
try {
const accessToken = await this.stravaTokenService.getValidAccessToken(
job.stravaAthleteId,
);
let page = 1;
const perPage = 100;
let summaries: StravaActivityPayload[] = [];
do {
summaries = await this.stravaClientService.listActivities(
accessToken,
page,
perPage,
);
for (const summary of summaries) {
await this.importActivity(job, accessToken, summary);
}
page += 1;
} while (summaries.length === perPage);
job.status = 'completed';
job.finishedAt = new Date();
await this.jobRepository.save(job);
} catch (error) {
await this.failJob(job, error);
}
}
private async importActivity(
job: StravaSyncJobEntity,
accessToken: string,
summary: StravaActivityPayload,
): Promise<void> {
const stravaActivityId = String(summary.id);
const item = await this.getOrCreateJobItem(job.id, stravaActivityId);
try {
const existingSummary = await this.activityRepository.findOne({
where: {
stravaAthleteId: job.stravaAthleteId,
stravaActivityId,
},
});
let activity = await this.activityRepository.save(
mapStravaActivity(job.stravaAthleteId, summary, existingSummary),
);
job.activityCount += 1;
const detail = await this.stravaClientService.getActivity(
accessToken,
stravaActivityId,
);
activity = await this.activityRepository.save(
mapStravaActivity(job.stravaAthleteId, detail, activity),
);
job.detailCount += 1;
const streams = await this.stravaClientService.getActivityStreams(
accessToken,
stravaActivityId,
);
await this.streamPointRepository.delete({ activityId: activity.id });
const points = this.streamNormalizer.normalize(activity.id, streams);
await this.insertStreamPoints(points);
job.streamPointCount += points.length;
item.status = 'completed';
item.errorMessage = null;
await this.jobItemRepository.save(item);
await this.jobRepository.save(job);
} catch (error) {
if (error instanceof StravaRateLimitError) {
throw error;
}
item.status = 'failed';
item.errorMessage = this.errorMessage(error);
await this.jobItemRepository.save(item);
await this.jobRepository.save(job);
}
}
private async insertStreamPoints(
points: Partial<StravaActivityStreamPointEntity>[],
): Promise<void> {
const chunkSize = 1000;
for (let index = 0; index < points.length; index += chunkSize) {
await this.streamPointRepository.insert(
points.slice(
index,
index + chunkSize,
) as QueryDeepPartialEntity<StravaActivityStreamPointEntity>[],
);
}
}
private async getOrCreateJobItem(
jobId: string,
stravaActivityId: string,
): Promise<StravaSyncJobItemEntity> {
const existing = await this.jobItemRepository.findOne({
where: { jobId, stravaActivityId },
});
if (existing) {
existing.status = 'pending';
existing.errorMessage = null;
return this.jobItemRepository.save(existing);
}
return this.jobItemRepository.save(
this.jobItemRepository.create({
jobId,
stravaActivityId,
status: 'pending',
}),
);
}
private async resolveAthlete(): Promise<StravaAthleteEntity> {
const athlete = await this.athleteRepository.findOne({
where: { accountKey: 'primary' },
});
if (!athlete) {
throw new NotFoundException('No Strava athlete connected');
}
return athlete;
}
private async failJob(
job: StravaSyncJobEntity,
error: unknown,
): Promise<void> {
job.status =
error instanceof StravaRateLimitError ? 'rate_limited' : 'failed';
job.errorMessage = this.errorMessage(error);
job.retryAfter =
error instanceof StravaRateLimitError ? error.retryAfter : null;
job.finishedAt = new Date();
await this.jobRepository.save(job);
}
private errorMessage(error: unknown): string {
return error instanceof Error ? error.message : 'Unknown sync error';
}
}

View File

@@ -0,0 +1,73 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { StravaTokenEntity } from '../database/entities';
import { StravaClientService } from './strava-client.service';
import { TokenCryptoService } from './token-crypto.service';
@Injectable()
export class StravaTokenService {
constructor(
@InjectRepository(StravaTokenEntity)
private readonly tokenRepository: Repository<StravaTokenEntity>,
private readonly tokenCryptoService: TokenCryptoService,
private readonly stravaClientService: StravaClientService,
) {}
async saveTokens(input: {
stravaAthleteId: string;
accessToken: string;
refreshToken: string;
expiresAt: Date;
scope: string | null;
tokenType: string | null;
}): Promise<StravaTokenEntity> {
const existing = await this.tokenRepository.findOne({
where: { stravaAthleteId: input.stravaAthleteId },
});
const token = existing ?? this.tokenRepository.create();
token.stravaAthleteId = input.stravaAthleteId;
token.accessTokenCiphertext = this.tokenCryptoService.encrypt(
input.accessToken,
);
token.refreshTokenCiphertext = this.tokenCryptoService.encrypt(
input.refreshToken,
);
token.expiresAt = input.expiresAt;
token.scope = input.scope;
token.tokenType = input.tokenType ?? 'Bearer';
return this.tokenRepository.save(token);
}
async getValidAccessToken(stravaAthleteId: string): Promise<string> {
const token = await this.tokenRepository.findOne({
where: { stravaAthleteId },
});
if (!token) {
throw new NotFoundException('No Strava token found for athlete');
}
const refreshBufferMs = 60_000;
if (token.expiresAt.getTime() > Date.now() + refreshBufferMs) {
return this.tokenCryptoService.decrypt(token.accessTokenCiphertext);
}
const refreshed = await this.stravaClientService.refreshToken(
this.tokenCryptoService.decrypt(token.refreshTokenCiphertext),
);
token.accessTokenCiphertext = this.tokenCryptoService.encrypt(
refreshed.access_token,
);
token.refreshTokenCiphertext = this.tokenCryptoService.encrypt(
refreshed.refresh_token,
);
token.expiresAt = new Date(refreshed.expires_at * 1000);
token.tokenType = refreshed.token_type ?? token.tokenType;
await this.tokenRepository.save(token);
return refreshed.access_token;
}
}

View File

@@ -0,0 +1,44 @@
import { HttpModule } from '@nestjs/axios';
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import {
StravaActivityEntity,
StravaActivityStreamPointEntity,
StravaAthleteEntity,
StravaSyncJobEntity,
StravaSyncJobItemEntity,
StravaTokenEntity,
} from '../database/entities';
import { StravaAuthController } from './strava-auth.controller';
import { StravaAuthService } from './strava-auth.service';
import { StravaClientService } from './strava-client.service';
import { StravaStreamNormalizerService } from './strava-stream-normalizer.service';
import { StravaSyncController } from './strava-sync.controller';
import { StravaSyncService } from './strava-sync.service';
import { StravaTokenService } from './strava-token.service';
import { TokenCryptoService } from './token-crypto.service';
@Module({
imports: [
HttpModule,
TypeOrmModule.forFeature([
StravaAthleteEntity,
StravaTokenEntity,
StravaActivityEntity,
StravaActivityStreamPointEntity,
StravaSyncJobEntity,
StravaSyncJobItemEntity,
]),
],
controllers: [StravaAuthController, StravaSyncController],
providers: [
StravaAuthService,
StravaClientService,
StravaTokenService,
TokenCryptoService,
StravaStreamNormalizerService,
StravaSyncService,
],
exports: [StravaStreamNormalizerService],
})
export class StravaModule {}

View File

@@ -0,0 +1,70 @@
export interface StravaAthletePayload {
id: number | string;
username?: string | null;
firstname?: string | null;
lastname?: string | null;
city?: string | null;
state?: string | null;
country?: string | null;
sex?: string | null;
profile_medium?: string | null;
profile?: string | null;
}
export interface StravaTokenPayload {
token_type?: string;
expires_at: number;
expires_in?: number;
refresh_token: string;
access_token: string;
athlete?: StravaAthletePayload;
}
export interface StravaActivityPayload {
id: number | string;
name?: string;
sport_type?: string;
type?: string;
distance?: number;
moving_time?: number;
elapsed_time?: number;
total_elevation_gain?: number;
start_date?: string;
start_date_local?: string;
timezone?: string;
utc_offset?: number;
average_speed?: number;
max_speed?: number;
average_heartrate?: number;
max_heartrate?: number;
average_watts?: number;
max_watts?: number;
weighted_average_watts?: number;
average_cadence?: number;
calories?: number;
gear_id?: string | null;
trainer?: boolean;
commute?: boolean;
manual?: boolean;
private?: boolean;
visibility?: string | null;
resource_state?: number;
map?: {
id?: string | null;
summary_polyline?: string | null;
} | null;
[key: string]: unknown;
}
export interface StravaStreamPayload {
type: string;
data: unknown[];
series_type?: string;
original_size?: number;
resolution?: string;
}
export interface StravaRateLimit {
limit?: string;
usage?: string;
}

View File

@@ -0,0 +1,58 @@
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import {
createCipheriv,
createDecipheriv,
createHash,
randomBytes,
} from 'crypto';
@Injectable()
export class TokenCryptoService {
private readonly key: Buffer;
constructor(configService: ConfigService) {
const configuredKey = configService.get<string>('APP_ENCRYPTION_KEY');
if (!configuredKey) {
throw new Error(
'Missing required environment variable: APP_ENCRYPTION_KEY',
);
}
this.key = createHash('sha256').update(configuredKey).digest();
}
encrypt(value: string): string {
const iv = randomBytes(12);
const cipher = createCipheriv('aes-256-gcm', this.key, iv);
const encrypted = Buffer.concat([
cipher.update(value, 'utf8'),
cipher.final(),
]);
const authTag = cipher.getAuthTag();
return [
iv.toString('base64url'),
authTag.toString('base64url'),
encrypted.toString('base64url'),
].join('.');
}
decrypt(value: string): string {
const [ivValue, authTagValue, encryptedValue] = value.split('.');
if (!ivValue || !authTagValue || !encryptedValue) {
throw new Error('Invalid encrypted token format');
}
const decipher = createDecipheriv(
'aes-256-gcm',
this.key,
Buffer.from(ivValue, 'base64url'),
);
decipher.setAuthTag(Buffer.from(authTagValue, 'base64url'));
return Buffer.concat([
decipher.update(Buffer.from(encryptedValue, 'base64url')),
decipher.final(),
]).toString('utf8');
}
}