Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 8 additions & 10 deletions SparkyFitnessServer/SparkyFitnessServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,6 @@ import swaggerSpecs from './config/swagger.js';
import { createCorsOriginChecker } from './utils/corsHelper.js';
import authModule from './auth.js';
import { toNodeHandler } from 'better-auth/node';
import exerciseRepository from './models/exerciseRepository.js';
import freeExerciseDBService from './integrations/freeexercisedb/FreeExerciseDBService.js';
import { downloadImage } from './utils/imageDownloader.js';
import authRoutes from './routes/authRoutes.js';
Expand Down Expand Up @@ -258,19 +257,18 @@ app.get(
if (fs.existsSync(localImagePath)) {
return res.sendFile(localImagePath);
}
// If not found, attempt to re-download
// If not found, attempt to re-download. Resolve image paths from the
// upstream free-exercise-db record (the canonical source) rather than a
// per-user DB copy — different users may have locally diverged the images
// array, and this route is unauthenticated so we have no user context.
try {
const exercise = await exerciseRepository.getExerciseBySourceAndSourceId(
'free-exercise-db',
exerciseId
);
const exercise = await freeExerciseDBService.getExerciseById(exerciseId);
if (!exercise) {
return res.status(404).send('Exercise not found.');
}
// @ts-expect-error TS7006
const originalRelativeImagePath = exercise.images.find((img) =>
img.endsWith(imageFileName)
);
const originalRelativeImagePath = (
(exercise as { images?: string[] }).images ?? []
).find((img) => path.basename(img) === imageFileName);
if (!originalRelativeImagePath) {
return res.status(404).send('Image not found for this exercise.');
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
-- Scope the exercises (source, source_id) uniqueness to each user so that
-- multiple users can import the same external-source exercise (e.g. the same
-- Free-Exercise-DB entry) into their own per-user copies.

DROP INDEX IF EXISTS idx_exercises_source_source_id_unique;

CREATE UNIQUE INDEX IF NOT EXISTS idx_exercises_user_source_source_id_unique
ON exercises (user_id, source, source_id)
WHERE source IS NOT NULL AND source_id IS NOT NULL;
4 changes: 2 additions & 2 deletions SparkyFitnessServer/db/rls_policies.sql
Original file line number Diff line number Diff line change
Expand Up @@ -371,7 +371,7 @@ SELECT create_library_policy('foods', 'shared_with_public', ARRAY['can_view_food
SELECT create_library_policy('meals', 'is_public', ARRAY['can_view_food_library', 'can_manage_diary']);
SELECT create_library_policy('meal_plan_templates', 'false', ARRAY['can_view_food_library']);
SELECT create_library_policy('workout_plan_templates', 'false', ARRAY['can_view_exercise_library']);
SELECT create_library_policy('workout_presets', 'false', ARRAY['can_view_exercise_library']);
SELECT create_library_policy('workout_presets', 'is_public', ARRAY['can_view_exercise_library']);


-- Custom policies for special cases
Expand Down Expand Up @@ -498,4 +498,4 @@ WITH CHECK (
-- Sleep Science tables
SELECT create_owner_policy('sleep_need_calculations');
SELECT create_owner_policy('daily_sleep_need');
SELECT create_owner_policy('day_classification_cache');
SELECT create_owner_policy('day_classification_cache');
3 changes: 2 additions & 1 deletion SparkyFitnessServer/integrations/polar/polarDataProcessor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,8 @@ async function processPolarExercises(
const exerciseSourceId = `polar-workout-${exerciseId}`;
let exerciseDef = await exerciseRepository.getExerciseBySourceAndSourceId(
'Polar',
exerciseSourceId
exerciseSourceId,
userId
);
if (!exerciseDef) {
// Search by name if source not found
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -959,7 +959,8 @@ async function processWithingsWorkouts(
const exerciseSourceId = `withings-workout-${workoutCategory}`;
let exercise = await exerciseRepository.getExerciseBySourceAndSourceId(
'Withings',
exerciseSourceId
exerciseSourceId,
userId
); // Corrected variable name
if (!exercise) {
// If not found by source and sourceId, try to find by name (for user-created exercises)
Expand Down
14 changes: 10 additions & 4 deletions SparkyFitnessServer/models/exercise.ts
Original file line number Diff line number Diff line change
Expand Up @@ -606,17 +606,23 @@ async function getTopExercises(userId: any, limit: any) {
client.release();
}
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
async function getExerciseBySourceAndSourceId(source: any, sourceId: any) {
async function getExerciseBySourceAndSourceId(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
source: any,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
sourceId: any,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
userId: any
) {
const client = await getSystemClient();
try {
const result = await client.query(
`SELECT id, source, source_id, name, force, level, mechanic, equipment,
primary_muscles, secondary_muscles, instructions, category, images,
calories_per_hour, description, user_id, is_custom, shared_with_public,
created_at, updated_at
FROM exercises WHERE source = $1 AND source_id = $2`,
[source, sourceId]
FROM exercises WHERE source = $1 AND source_id = $2 AND user_id = $3`,
[source, sourceId, userId]
);
const exercise = result.rows[0];
if (exercise && exercise.images) {
Expand Down
8 changes: 6 additions & 2 deletions SparkyFitnessServer/services/exerciseService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -192,7 +192,8 @@ async function prepareExerciseEntryForCreate(
entryData: any
) {
const resolvedExerciseId = await resolveExerciseIdToUuid(
entryData.exercise_id
entryData.exercise_id,
authenticatedUserId
);
const exercise = await exerciseDb.getExerciseById(
resolvedExerciseId,
Expand Down Expand Up @@ -1298,7 +1299,10 @@ async function getExerciseHistory(
limit: any
) {
try {
const resolvedExerciseId = await resolveExerciseIdToUuid(exerciseId);
const resolvedExerciseId = await resolveExerciseIdToUuid(
exerciseId,
authenticatedUserId
);
// getExerciseHistory is implemented in the exerciseEntry module
const history = await exerciseEntryDb.getExerciseHistory(
authenticatedUserId,
Expand Down
4 changes: 2 additions & 2 deletions SparkyFitnessServer/services/workoutPresetService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { resolveExerciseIdToUuid } from '../utils/uuidUtils.js';
async function createWorkoutPreset(userId: any, presetData: any) {
// Validate and resolve exercise_ids
for (const ex of presetData.exercises) {
ex.exercise_id = await resolveExerciseIdToUuid(ex.exercise_id); // Resolve to UUID
ex.exercise_id = await resolveExerciseIdToUuid(ex.exercise_id, userId); // Resolve to UUID
const exercise = await exerciseRepository.getExerciseById(
ex.exercise_id,
userId
Expand Down Expand Up @@ -71,7 +71,7 @@ async function updateWorkoutPreset(
// Validate and resolve exercise_ids if exercises are being updated
if (updateData.exercises) {
for (const ex of updateData.exercises) {
ex.exercise_id = await resolveExerciseIdToUuid(ex.exercise_id); // Resolve to UUID
ex.exercise_id = await resolveExerciseIdToUuid(ex.exercise_id, userId); // Resolve to UUID
const exercise = await exerciseRepository.getExerciseById(
ex.exercise_id,
userId
Expand Down
127 changes: 127 additions & 0 deletions SparkyFitnessServer/tests/exerciseSourceScoping.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import { vi, afterEach, beforeEach, describe, expect, it } from 'vitest';
import { v4 as uuidv4 } from 'uuid';
import exerciseDb from '../models/exercise.js';
import { getSystemClient } from '../db/poolManager.js';
import { resolveExerciseIdToUuid } from '../utils/uuidUtils.js';
import exerciseRepository from '../models/exerciseRepository.js';

vi.mock('../db/poolManager', () => ({
getClient: vi.fn(),
getSystemClient: vi.fn(),
}));

vi.mock('../config/logging', () => ({
log: vi.fn(),
}));

vi.mock('../models/exerciseRepository', () => ({
default: {
getExerciseBySourceAndSourceId: vi.fn(),
},
}));

describe('exercise source/source_id scoping', () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let mockClient: any;

beforeEach(() => {
mockClient = { query: vi.fn(), release: vi.fn() };
// @ts-expect-error mock typing
getSystemClient.mockResolvedValue(mockClient);
});

afterEach(() => {
vi.clearAllMocks();
});

describe('exerciseDb.getExerciseBySourceAndSourceId', () => {
it('filters on user_id when userId is provided', async () => {
const userId = uuidv4();
const rowId = uuidv4();
mockClient.query.mockResolvedValueOnce({
rows: [
{
id: rowId,
source: 'free-exercise-db',
source_id: 'Barbell_Bench_Press',
user_id: userId,
images: null,
},
],
});

const result = await exerciseDb.getExerciseBySourceAndSourceId(
'free-exercise-db',
'Barbell_Bench_Press',
userId
);

expect(result.id).toBe(rowId);
expect(mockClient.query).toHaveBeenCalledTimes(1);
const [sql, params] = mockClient.query.mock.calls[0];
expect(sql).toContain('AND user_id = $3');
expect(params).toEqual([
'free-exercise-db',
'Barbell_Bench_Press',
userId,
]);
});

it('returns undefined when the requested user has no copy even if another user does', async () => {
const userId = uuidv4();
mockClient.query.mockResolvedValueOnce({ rows: [] });

const result = await exerciseDb.getExerciseBySourceAndSourceId(
'free-exercise-db',
'Barbell_Bench_Press',
userId
);

expect(result).toBeUndefined();
const [sql, params] = mockClient.query.mock.calls[0];
expect(sql).toContain('AND user_id = $3');
expect(params[2]).toBe(userId);
});
});

describe('resolveExerciseIdToUuid', () => {
it('returns the input unchanged when it is already a UUID', async () => {
const existingUuid = uuidv4();

const result = await resolveExerciseIdToUuid(existingUuid, uuidv4());

expect(result).toBe(existingUuid);
expect(
exerciseRepository.getExerciseBySourceAndSourceId
).not.toHaveBeenCalled();
});

it('forwards the userId to getExerciseBySourceAndSourceId so each user resolves to their own copy', async () => {
const userId = uuidv4();
const resolved = uuidv4();
exerciseRepository.getExerciseBySourceAndSourceId.mockResolvedValueOnce({
id: resolved,
});

const result = await resolveExerciseIdToUuid(
'Barbell_Bench_Press',
userId
);

expect(result).toBe(resolved);
expect(
exerciseRepository.getExerciseBySourceAndSourceId
).toHaveBeenCalledWith('free-exercise-db', 'Barbell_Bench_Press', userId);
});

it('throws when no exercise is found for the caller', async () => {
exerciseRepository.getExerciseBySourceAndSourceId.mockResolvedValueOnce(
undefined
);

await expect(
resolveExerciseIdToUuid('Barbell_Bench_Press', uuidv4())
).rejects.toThrow(/not found or is not a valid UUID/);
});
});
});
16 changes: 11 additions & 5 deletions SparkyFitnessServer/utils/uuidUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,22 @@ const isValidUuid = (uuid: any) => {
return uuidRegex.test(uuid);
};
// Helper function to resolve exercise ID to a UUID
// eslint-disable-next-line @typescript-eslint/no-explicit-any
async function resolveExerciseIdToUuid(exerciseId: any) {
async function resolveExerciseIdToUuid(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
exerciseId: any,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
userId: any
) {
if (isValidUuid(exerciseId)) {
return exerciseId;
}
// If not a UUID, assume it's an integer ID from a source like FreeExerciseDB
// We need to find the corresponding exercise in our DB that has this source_id
// If not a UUID, assume it's an ID from a source like FreeExerciseDB.
// Each user may have their own per-user copy of that source exercise, so
// callers pass userId to select the row owned by the current user.
const exercise = await exerciseRepository.getExerciseBySourceAndSourceId(
'free-exercise-db',
exerciseId
exerciseId,
userId
);
if (exercise) {
return exercise.id;
Expand Down
7 changes: 3 additions & 4 deletions db_schema_backup.sql
Original file line number Diff line number Diff line change
Expand Up @@ -3604,10 +3604,10 @@ CREATE INDEX idx_exercises_source ON public.exercises USING btree (source);


--
-- Name: idx_exercises_source_source_id_unique; Type: INDEX; Schema: public; Owner: -
-- Name: idx_exercises_user_source_source_id_unique; Type: INDEX; Schema: public; Owner: -
--

CREATE UNIQUE INDEX idx_exercises_source_source_id_unique ON public.exercises USING btree (source, source_id) WHERE ((source IS NOT NULL) AND (source_id IS NOT NULL));
CREATE UNIQUE INDEX idx_exercises_user_source_source_id_unique ON public.exercises USING btree (user_id, source, source_id) WHERE ((source IS NOT NULL) AND (source_id IS NOT NULL));


--
Expand Down Expand Up @@ -5583,7 +5583,7 @@ CREATE POLICY select_policy ON public.workout_plan_templates FOR SELECT USING (p
-- Name: workout_presets select_policy; Type: POLICY; Schema: public; Owner: -
--

CREATE POLICY select_policy ON public.workout_presets FOR SELECT USING (public.has_library_access_with_public(user_id, false, ARRAY['can_view_exercise_library'::text]));
CREATE POLICY select_policy ON public.workout_presets FOR SELECT USING (public.has_library_access_with_public(user_id, is_public, ARRAY['can_view_exercise_library'::text]));


--
Expand Down Expand Up @@ -7262,4 +7262,3 @@ ALTER DEFAULT PRIVILEGES FOR ROLE sparky IN SCHEMA public GRANT SELECT,INSERT,DE
--

\unrestrict G4DirQ0zC90iSaOGue74XukJ95cc6Vq4wGjrNcK3lM0nnBjsZQlAdgl73ISL7bj

Loading