diff --git a/.gitignore b/.gitignore index 43bb89720..bb20a1fbf 100644 --- a/.gitignore +++ b/.gitignore @@ -8,5 +8,42 @@ backend/*.pyc backend/logs/*.log backend/*.log +# user image folder images +backend/user_images/*.jpeg +backend/user_images/*.png +backend/user_images/*.jpg + # Frontend -frontend/img/users_img/*.* \ No newline at end of file +frontend/img/users_img/*.* +# Logs +frontend/.gitignore +frontend/logs +frontend/*.log +frontend/npm-debug.log* +frontend/yarn-debug.log* +frontend/yarn-error.log* +frontend/pnpm-debug.log* +frontend/lerna-debug.log* + +frontend/node_modules +frontend/.DS_Store +frontend/dist +frontend/dist-ssr +frontend/coverage +frontend/*.local +frontend/README.md + +frontend/cypress/videos/ +frontend/cypress/screenshots/ + +# Editor directories and files +frontend/.vscode/* +frontend/!.vscode/extensions.json +frontend/.idea +frontend/*.suo +frontend/*.ntvs* +frontend/*.njsproj +frontend/*.sln +frontend/*.sw? + +frontend/*.tsbuildinfo \ No newline at end of file diff --git a/Dockerfile_backend b/Dockerfile_backend index 1659ea097..a62f20001 100644 --- a/Dockerfile_backend +++ b/Dockerfile_backend @@ -1,7 +1,7 @@ FROM python:3.11 # Links Docker image with repository -LABEL org.opencontainers.image.source https://github.com/joaovitoriasilva/gearguardian +LABEL org.opencontainers.image.source https://github.com/joaovitoriasilva/endurain # Set the working directory WORKDIR /app @@ -17,26 +17,33 @@ RUN pip install --no-cache-dir --upgrade -r requirements.txt \ && apt-get clean \ && rm -rf /var/lib/apt/lists/* -# Make port 80 available to the world outside this container -EXPOSE 80 - # Copy the directory backend contents to /app COPY backend /app -# Define environment variable -ENV DB_HOST="" +# Make port 80 available to the world outside this container +EXPOSE 80 + +# Define environment variables +ENV DB_HOST="mariadb" ENV DB_PORT=3306 -ENV DB_USER="" -ENV DB_PASSWORD="" -ENV DB_DATABASE="" -ENV SECRET_KEY="" +ENV DB_USER="endurain" +ENV DB_PASSWORD="changeme" +ENV DB_DATABASE="endurain" +ENV SECRET_KEY="changeme" ENV ALGORITHM="HS256" ENV ACCESS_TOKEN_EXPIRE_MINUTES=30 -ENV STRAVA_CLIENT_ID="" -ENV STRAVA_CLIENT_SECRET="" -ENV STRAVA_AUTH_CODE="" -ENV JAEGER_HOST="" +ENV STRAVA_CLIENT_ID="changeme" +ENV STRAVA_CLIENT_SECRET="changeme" +ENV STRAVA_AUTH_CODE="changeme" +ENV JAEGER_ENABLED="true" +ENV JAEGER_HOST="jaeger" +ENV JAEGER_PROTOCOL="http" +ENV JAGGER_PORT=4317 ENV STRAVA_DAYS_ACTIVITIES_ONLINK=30 +ENV FRONTEND_PROTOCOL="http" +ENV FRONTEND_HOST="frontend" +ENV FRONTEND_PORT=8080 +ENV GEOCODES_MAPS_API="changeme" # Run main.py when the container launches -CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "80"] +CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "80"] \ No newline at end of file diff --git a/Dockerfile_frontend b/Dockerfile_frontend index 0e2024998..a23a967fe 100644 --- a/Dockerfile_frontend +++ b/Dockerfile_frontend @@ -1,38 +1,34 @@ -# Use an official PHP runtime as a parent image -FROM php:8.3-apache +# Use an official node runtime as a parent image +FROM node:20-alpine as build-stage # Links Docker image with repository -LABEL org.opencontainers.image.source https://github.com/joaovitoriasilva/gearguardian +LABEL org.opencontainers.image.source https://github.com/joaovitoriasilva/endurain -# Set the working directory to /var/www/html -WORKDIR /var/www/html +# Set the working directory to /app +WORKDIR /app -# Copy the current directory contents into the container at /var/www/html -COPY frontend/ /var/www/html +# Copy package.json and package-lock.json +COPY frontend/package*.json ./ +RUN npm install -# Copy custom php.ini -COPY custom_php.ini /usr/local/etc/php/php.ini +# Copy the current directory contents into the container at /app +COPY frontend ./ -# Install any dependencies your application needs -RUN apt-get update +# Build the app +RUN npm run build -# Change ownership of the directory to www-data:www-data -RUN chown -R www-data:www-data /var/www/html +# Use nginx to serve the built app +FROM nginx:alpine as production-stage -# Change permissions of the directory to 755 -RUN chmod -R 755 /var/www/html +COPY --from=build-stage /app/dist /usr/share/nginx/html +COPY nginx-custom.conf /etc/nginx/conf.d/default.conf + +COPY frontend_env.sh /docker-entrypoint.d/frontend_env.sh +RUN chmod +x /docker-entrypoint.d/frontend_env.sh -# Expose port 80 to the outside world EXPOSE 80 -# Define environment variables -ENV APACHE_DOCUMENT_ROOT /var/www/html +ENV MY_APP_BACKEND_PROTOCOL=http +ENV MY_APP_BACKEND_HOST=localhost:98 -# Enable Apache modules -RUN a2enmod rewrite - -# Update the default virtual host to use the environment variable -RUN sed -ri -e 's!/var/www/html!${APACHE_DOCUMENT_ROOT}!g' /etc/apache2/sites-available/*.conf - -# Start Apache -CMD ["apache2-foreground"] +CMD ["nginx", "-g", "daemon off;"] diff --git a/README.md b/README.md index 3bbddc73e..3e34f3a82 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,10 @@
+
# Endurain
+
@@ -11,7 +13,7 @@
> [!WARNING]
> This project is currently in **Alpha** state. You can try it out at your own risk, but be aware that things might break and **DATA LOSS** may occur.
-Endurain is a self-hosted fitness tracking service that operates much like Strava but allows users to have complete control over their data and the hosting environment. The application's frontend is built using a combination of PHP, HTML, basic JavaScript, and Bootstrap CSS. On the backend, it leverages Python FastAPI, Alembic, SQLAlchemy, stravalib and gpxpy for seamless integration with Strava and .gpx file import. The MariaDB database engine is employed to efficiently store and manage user data, while Jaeger is used for basic observability.
+Endurain is a self-hosted fitness tracking service that operates much like Strava but allows users to have complete control over their data and the hosting environment. The application's frontend is built using Vue.js and Bootstrap CSS. On the backend, it leverages Python FastAPI, Alembic, SQLAlchemy, stravalib and gpxpy for seamless integration with Strava and .gpx file import. The MariaDB database engine is employed to efficiently store and manage user data, while Jaeger is used for basic observability.
To deploy Endurain, Docker images are available, and a comprehensive example can be found in the "docker-compose.yml" file provided. Configuration is facilitated through environment variables, ensuring flexibility and ease of customization.
@@ -55,76 +57,77 @@ More screenshots: https://imgur.com/a/lDR0sBf
# Frontend
Table bellow shows supported environemnt variables. Variables marked with optional "No" should be set to avoid errors.
-Environemnt variable | Default value | Optional
---- | --- | ---
-BACKEND_PROTOCOL* | http | Yes
-BACKEND_HOST** | backend | Yes
-
-*BACKEND_PROTOCOL needs to be https if you want to enable Strava integration
-**BACKEND_HOST needs to be set and be Internet faced/resolved if you want to enable Strava integration. Strava callback relies on this.
+Environemnt variable | Default value | Optional | Notes
+--- | --- | --- | ---
+MY_APP_BACKEND_PROTOCOL | http | Yes | Needs to be https if you want to enable Strava integration. You may need to update this variable based on docker image spin up
+MY_APP_BACKEND_HOST | localhost:98 | Yes | Needs to be set and be Internet faced/resolved if you want to enable Strava integration. Strava callback relies on this. You may need to update this variable based on docker image spin up
Frontend dependencies:
- - php:8.3-apache
+ - vue@3.4.24
+ - vue-router@4.3.2
+ - vue-i18n@9.13.1
+ - vite@5.2.10
+ - pinia@2.1.7
+ - crypto-js@4.2.0
+ - chart.js@4.4.2
- User avatars create using DiceBear (https://www.dicebear.com) avataaars style.
- - Bootstrap CSS v5.3.2
- - leaflet v1.7.1
- - fontawesome icons free version
+ - Bootstrap CSS v5.3.3
+ - leaflet v1.9.4
+ - fontawesome icons free version@6.5.2 and vue-fontawesome@3.0.6
- Logo created using Canvas
---
# Backend
Table bellow shows supported environemnt variables. Variables marked with optional "No" should be set to avoid errors.
-Environemnt variable | Default value | Optional
---- | --- | ---
-DB_HOST | mariadb | Yes
-DB_PORT | 3306 | Yes
-DB_USER | gearguardian | Yes
-DB_PASSWORD | changeme | `No`
-DB_DATABASE | gearguardian | Yes
-SECRET_KEY | changeme | `No`
-ALGORITHM | HS256 | Yes
-ACCESS_TOKEN_EXPIRE_MINUTES | 30 | Yes
-STRAVA_CLIENT_ID | changeme | `No`
-STRAVA_CLIENT_SECRET | changeme | `No`
-STRAVA_AUTH_CODE | changeme | `No`
-JAEGER_ENABLED | true | Yes
-JAEGER_PROTOCOL | http | Yes
-JAEGER_HOST | jaeger | Yes
-JAGGER_PORT | 4317 | Yes
-STRAVA_DAYS_ACTIVITIES_ONLINK | 30 | Yes
-FRONTEND_HOST* | frontend | Yes
-GEOCODES_MAPS_API** | changeme | `No`
-
-*FRONTEND_HOST needs to be set if you want to enable Strava integration
-**Geocode maps offers a free plan consisting of 1 Request/Second. Registration necessary.
+Environemnt variable | Default value | Optional | Notes
+--- | --- | --- | ---
+DB_HOST | mariadb | Yes | N/A
+DB_PORT | 3306 | Yes | N/A
+DB_USER | gearguardian | Yes | N/A
+DB_PASSWORD | changeme | `No` | N/A
+DB_DATABASE | gearguardian | Yes | N/A
+SECRET_KEY | changeme | `No` | N/A
+ALGORITHM | HS256 | Yes | N/A
+ACCESS_TOKEN_EXPIRE_MINUTES | 30 | Yes | N/A
+STRAVA_CLIENT_ID | changeme | `No` | N/A
+STRAVA_CLIENT_SECRET | changeme | `No` | N/A
+STRAVA_AUTH_CODE | changeme | `No` | N/A
+JAEGER_ENABLED | true | Yes | N/A
+JAEGER_PROTOCOL | http | Yes | N/A
+JAEGER_HOST | jaeger | Yes | N/A
+JAGGER_PORT | 4317 | Yes | N/A
+STRAVA_DAYS_ACTIVITIES_ONLINK | 30 | Yes | N/A
+FRONTEND_PROTOCOL | http | Yes | Needs to be set if you want to enable Strava integration. You may need to update this variable based on docker image spin up
+FRONTEND_HOST | frontend | Yes | Needs to be set if you want to enable Strava integration. You may need to update this variable based on docker image spin up
+FRONTEND_PORT | frontend | Yes | Needs to be set if you want to enable Strava integration. You may need to update this variable based on docker image spin up
+GEOCODES_MAPS_API | changeme | `No` | Geocode maps offers a free plan consisting of 1 Request/Second. Registration necessary.
Table bellow shows the obligatory environemnt variables for mariadb container. You should set them based on what was also set for backend container.
-Environemnt variable | Default value | Optional
---- | --- | ---
-MYSQL_ROOT_PASSWORD | changeme | `No`
-MYSQL_DATABASE | gearguardian | `No`
-MYSQL_USER | gearguardian | `No`
-MYSQL_PASSWORD | changeme | `No`
+Environemnt variable | Default value | Optional | Notes
+--- | --- | --- | ---
+MYSQL_ROOT_PASSWORD | changeme | `No` | N/A
+MYSQL_DATABASE | gearguardian | `No` | N/A
+MYSQL_USER | gearguardian | `No` | N/A
+MYSQL_PASSWORD | changeme | `No` | N/A
Python backend dependencies used:
- - python:3.11
- - fastapi==0.108.0
- - pydantic==1.10.9
- - uvicorn==0.25.0
- - python-dotenv==1.0.0
- - sqlalchemy==2.0.25
- - mysqlclient==2.2.1
+ - fastapi==0.111.0
+ - pydantic==1.10.15
+ - uvicorn==0.29.0
+ - python-dotenv==1.0.1
+ - sqlalchemy==2.0.30
+ - mysqlclient==2.2.4
- python-jose[cryptography]==3.3.0
- passlib[bcrypt]==1.7.4
- apscheduler==3.10.4
- - requests==2.31.0
- - stravalib==1.5
+ - requests==2.32.2
+ - stravalib==1.7
- opentelemetry-sdk==1.22.0
- opentelemetry-instrumentation-fastapi==0.43b0
- opentelemetry.exporter.otlp==1.22.0
- - python-multipart==0.0.6
+ - python-multipart==0.0.9
- gpxpy==1.6.2
- alembic==1.13.1
diff --git a/backend/.env b/backend/.env
deleted file mode 100644
index c2aaa5136..000000000
--- a/backend/.env
+++ /dev/null
@@ -1,19 +0,0 @@
-# .env
-DB_HOST=mariadb
-DB_PORT=3306
-DB_USER=endurain
-DB_PASSWORD=changeme
-DB_DATABASE=endurain
-SECRET_KEY=changeme # openssl rand -hex 32
-ALGORITHM=HS256
-ACCESS_TOKEN_EXPIRE_MINUTES=30
-STRAVA_CLIENT_ID=changeme
-STRAVA_CLIENT_SECRET=changeme
-STRAVA_AUTH_CODE=changeme
-JAEGER_ENABLED=true
-JAEGER_PROTOCOL=http
-JAEGER_HOST=jaeger
-JAGGER_PORT=4317
-STRAVA_DAYS_ACTIVITIES_ONLINK=30
-FRONTEND_HOST=frontend
-GEOCODES_MAPS_API=changeme
\ No newline at end of file
diff --git a/backend/alembic/versions/0ab200a7f196_remove_access_tokens_table.py b/backend/alembic/versions/0ab200a7f196_remove_access_tokens_table.py
new file mode 100644
index 000000000..12845a3c7
--- /dev/null
+++ b/backend/alembic/versions/0ab200a7f196_remove_access_tokens_table.py
@@ -0,0 +1,59 @@
+"""Remove access_tokens table
+
+Revision ID: 0ab200a7f196
+Revises: 5fd61bc55e09
+Create Date: 2024-05-24 13:39:50.917676
+
+"""
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+from sqlalchemy.dialects import mysql
+
+# revision identifiers, used by Alembic.
+revision: str = '0ab200a7f196'
+down_revision: Union[str, None] = '5fd61bc55e09'
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ # Drop the foreign key constraint first
+ op.drop_constraint('access_tokens_ibfk_1', 'access_tokens', type_='foreignkey')
+ # Then drop the index
+ op.drop_index('ix_access_tokens_user_id', table_name='access_tokens')
+ op.drop_table('access_tokens')
+ op.alter_column('users_integrations', 'user_id',
+ existing_type=mysql.INTEGER(display_width=11),
+ comment='User ID that the integration belongs',
+ existing_comment='User ID that the token belongs',
+ existing_nullable=False)
+ # ### end Alembic commands ###
+
+
+def downgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.alter_column('users_integrations', 'user_id',
+ existing_type=mysql.INTEGER(display_width=11),
+ comment='User ID that the token belongs',
+ existing_comment='User ID that the integration belongs',
+ existing_nullable=False)
+ op.create_table('access_tokens',
+ sa.Column('id', mysql.INTEGER(display_width=11), autoincrement=True, nullable=False),
+ sa.Column('token', mysql.VARCHAR(length=256), nullable=False, comment='User token'),
+ sa.Column('user_id', mysql.INTEGER(display_width=11), autoincrement=False, nullable=False, comment='User ID that the token belongs'),
+ sa.Column('created_at', mysql.DATETIME(), nullable=False, comment='Token creation date (date)'),
+ sa.Column('expires_at', mysql.DATETIME(), nullable=False, comment='Token expiration date (date)'),
+ sa.ForeignKeyConstraint(['user_id'], ['users.id'], name='access_tokens_ibfk_1', ondelete='CASCADE'),
+ sa.PrimaryKeyConstraint('id'),
+ mysql_collate='utf8mb4_general_ci',
+ mysql_default_charset='utf8mb4',
+ mysql_engine='InnoDB'
+ )
+ # Recreate the index first
+ op.create_index('ix_access_tokens_user_id', 'access_tokens', ['user_id'])
+ # Then recreate the foreign key constraint
+ op.create_foreign_key('access_tokens_ibfk_1', 'access_tokens', 'users', ['user_id'], ['id'])
+ # ### end Alembic commands ###
diff --git a/backend/alembic/versions/5fd61bc55e09_add_cascade_delete_to_followers_foreign_.py b/backend/alembic/versions/5fd61bc55e09_add_cascade_delete_to_followers_foreign_.py
new file mode 100644
index 000000000..e52e39879
--- /dev/null
+++ b/backend/alembic/versions/5fd61bc55e09_add_cascade_delete_to_followers_foreign_.py
@@ -0,0 +1,36 @@
+"""Add cascade delete to followers foreign keys
+
+Revision ID: 5fd61bc55e09
+Revises: 1bce2bd27873
+Create Date: 2024-05-21 20:59:54.984566
+
+"""
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision: str = '5fd61bc55e09'
+down_revision: Union[str, None] = '1bce2bd27873'
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.drop_constraint('followers_ibfk_2', 'followers', type_='foreignkey')
+ op.drop_constraint('followers_ibfk_1', 'followers', type_='foreignkey')
+ op.create_foreign_key(None, 'followers', 'users', ['following_id'], ['id'], ondelete='CASCADE')
+ op.create_foreign_key(None, 'followers', 'users', ['follower_id'], ['id'], ondelete='CASCADE')
+ # ### end Alembic commands ###
+
+
+def downgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.drop_constraint(None, 'followers', type_='foreignkey')
+ op.drop_constraint(None, 'followers', type_='foreignkey')
+ op.create_foreign_key('followers_ibfk_1', 'followers', 'users', ['follower_id'], ['id'])
+ op.create_foreign_key('followers_ibfk_2', 'followers', 'users', ['following_id'], ['id'])
+ # ### end Alembic commands ###
diff --git a/backend/constants.py b/backend/constants.py
index 8c8b6e064..a790f5114 100644
--- a/backend/constants.py
+++ b/backend/constants.py
@@ -1,7 +1,7 @@
import os
# Constant related to version
-API_VERSION="v0.1.5"
+API_VERSION="v0.2.0"
# JWT Token constants
JWT_ALGORITHM = os.environ.get("ALGORITHM")
diff --git a/backend/crud/crud_access_tokens.py b/backend/crud/crud_access_tokens.py
deleted file mode 100644
index 2bf11bb82..000000000
--- a/backend/crud/crud_access_tokens.py
+++ /dev/null
@@ -1,129 +0,0 @@
-import logging
-
-import models
-
-from datetime import datetime
-
-from fastapi import HTTPException, status
-from sqlalchemy.orm import Session
-
-# Define a loggger created on main.py
-logger = logging.getLogger("myLogger")
-
-
-def get_acess_tokens_by_user_id(user_id: int, db: Session):
- try:
- access_tokens = (
- db.query(models.AccessToken)
- .filter(models.AccessToken.user_id == user_id)
- .all()
- )
- if access_tokens is None:
- # If the user was not found, return a 404 Not Found error
- return None
-
- return access_tokens
- except Exception as err:
- # Log the exception
- logger.error(f"Error in get_acess_tokens_by_user_id: {err}", exc_info=True)
- # Raise an HTTPException with a 500 Internal Server Error status code
- raise HTTPException(
- status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
- detail="Internal Server Error",
- ) from err
-
-
-def create_access_token(token, db: Session):
- try:
- # Create a new access token in the database
- db_access_token = models.AccessToken(
- token=token.token,
- user_id=token.user_id,
- created_at=datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%S"),
- expires_at=token.expires_at,
- )
-
- # Add the access token to the database and commit the transaction
- db.add(db_access_token)
- db.commit()
- db.refresh(db_access_token)
-
- # return the access token
- return db_access_token
- except Exception as err:
- # Handle database-related exceptions
- db.rollback() # Rollback the transaction to maintain database consistency
-
- # Log the exception
- logger.error(f"Error in create_access_token: {err}", exc_info=True)
-
- # Raise an HTTPException with a 500 Internal Server Error status code
- raise HTTPException(
- status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
- detail="Internal Server Error",
- ) from err
-
-
-def delete_access_token(token: str, db: Session):
- try:
- # Delete the access token from the database
- db_access_token = (
- db.query(models.AccessToken)
- .filter(models.AccessToken.token == token)
- .delete()
- )
-
- # Commit the transaction to the database
- if db_access_token:
- db.delete(db_access_token)
- db.commit()
- logger.info(f"{db_access_token} access tokens deleted from the database")
- return db_access_token
- else:
- # If the access token was not found, return a 404 Not Found error
- raise HTTPException(
- status_code=status.HTTP_404_NOT_FOUND,
- detail="Access token not found",
- )
- except Exception as err:
- # Handle database-related exceptions
- db.rollback() # Rollback the transaction to maintain database consistency
-
- # Log the exception
- logger.error(f"Error in delete_access_token: {err}", exc_info=True)
-
- # Raise an HTTPException with a 500 Internal Server Error status code
- raise HTTPException(
- status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
- detail="Internal Server Error",
- ) from err
-
-
-def delete_access_tokens(expiration_time: str, db: Session):
- try:
- # Delete the access tokens from the database
- db_access_tokens = (
- db.query(models.AccessToken)
- .filter(models.AccessToken.created_at < expiration_time)
- .delete()
- )
-
- # Commit the transaction to the database
- if db_access_tokens:
- db.commit()
- return db_access_tokens
- else:
- # If no access tokens were found, return 0
- return 0
- except Exception as err:
- # Handle database-related exceptions
- db.rollback() # Rollback the transaction to maintain database consistency
-
- # Log the exception
- logger.error(f"Error in delete_access_tokens: {err}", exc_info=True)
-
- # Raise an HTTPException with a 500 Internal Server Error status code
- raise HTTPException(
- status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
- detail="Internal Server Error",
- ) from err
diff --git a/backend/crud/crud_activities.py b/backend/crud/crud_activities.py
index aee6962b0..6bb27fea9 100644
--- a/backend/crud/crud_activities.py
+++ b/backend/crud/crud_activities.py
@@ -5,6 +5,7 @@ from fastapi import HTTPException, status
from datetime import datetime
from sqlalchemy import func, desc
from sqlalchemy.orm import Session, joinedload
+from urllib.parse import unquote
import models
from schemas import schema_activities
@@ -404,6 +405,46 @@ def get_activity_by_strava_id_from_user_id(
) from err
+def get_activities_if_contains_name(
+ name: str, user_id: int, db: Session
+):
+ try:
+ # Define a search term
+ partial_name = unquote(name).replace("+", " ")
+
+ # Get the activities from the database
+ activities = (
+ db.query(models.Activity)
+ .filter(
+ models.Activity.user_id == user_id,
+ models.Activity.name.like(f"%{partial_name}%"),
+ )
+ .order_by(desc(models.Activity.start_time))
+ .all()
+ )
+
+ # Check if there are activities if not return None
+ if not activities:
+ return None
+
+ # Iterate and format the dates
+ for activity in activities:
+ activity.start_time = activity.start_time.strftime("%Y-%m-%d %H:%M:%S")
+ activity.end_time = activity.end_time.strftime("%Y-%m-%d %H:%M:%S")
+ activity.created_at = activity.created_at.strftime("%Y-%m-%d %H:%M:%S")
+
+ # Return the activities
+ return activities
+ except Exception as err:
+ # Log the exception
+ logger.error(f"Error in get_activities_if_contains_name: {err}", exc_info=True)
+ # Raise an HTTPException with a 500 Internal Server Error status code
+ raise HTTPException(
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
+ detail="Internal Server Error",
+ ) from err
+
+
def create_activity(activity: schema_activities.Activity, db: Session):
try:
# Create a new activity
diff --git a/backend/crud/crud_users.py b/backend/crud/crud_users.py
index 9bd55d57a..5ca6a8f57 100644
--- a/backend/crud/crud_users.py
+++ b/backend/crud/crud_users.py
@@ -1,3 +1,5 @@
+import os
+import glob
import logging
from fastapi import HTTPException, status
@@ -11,6 +13,25 @@ import models
# Define a loggger created on main.py
logger = logging.getLogger("myLogger")
+def delete_user_photo_filesystem(user_id: int):
+ # Define the pattern to match files with the specified name regardless of the extension
+ folder = "user_images"
+ file = f"{user_id}.*"
+
+ print(os.path.join(folder, file))
+
+ # Find all files matching the pattern
+ files_to_delete = glob.glob(os.path.join(folder, file))
+
+ print(f"Files to delete: {files_to_delete}")
+
+ # Remove each file found
+ for file_path in files_to_delete:
+ print(f"Deleting: {file_path}")
+ if os.path.exists(file_path):
+ os.remove(file_path)
+ print(f"Deleted: {file_path}")
+
def format_user_birthdate(user):
user.birthdate = user.birthdate.strftime("%Y-%m-%d") if user.birthdate else None
@@ -334,6 +355,10 @@ def edit_user(user: schema_users.User, db: Session):
# Commit the transaction
db.commit()
+
+ if db_user.photo_path is None:
+ # Delete the user photo in the filesystem
+ delete_user_photo_filesystem(db_user.id)
except IntegrityError as integrity_error:
# Rollback the transaction
db.rollback()
@@ -379,6 +404,33 @@ def edit_user_password(user_id: int, password: str, db: Session):
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Internal Server Error",
) from err
+
+
+def edit_user_photo_path(user_id: int, photo_path: str, db: Session):
+ try:
+ # Get the user from the database
+ db_user = db.query(models.User).filter(models.User.id == user_id).first()
+
+ # Update the user
+ db_user.photo_path = photo_path
+
+ # Commit the transaction
+ db.commit()
+
+ # Return the photo path
+ return photo_path
+ except Exception as err:
+ # Rollback the transaction
+ db.rollback()
+
+ # Log the exception
+ logger.error(f"Error in edit_user_photo_path: {err}", exc_info=True)
+
+ # Raise an HTTPException with a 500 Internal Server Error status code
+ raise HTTPException(
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
+ detail="Internal Server Error",
+ ) from err
def delete_user_photo(user_id: int, db: Session):
@@ -392,6 +444,9 @@ def delete_user_photo(user_id: int, db: Session):
# Commit the transaction
db.commit()
+
+ # Delete the user photo in the filesystem
+ delete_user_photo_filesystem(user_id)
except Exception as err:
# Rollback the transaction
db.rollback()
@@ -420,6 +475,9 @@ def delete_user(user_id: int, db: Session):
# Commit the transaction
db.commit()
+
+ # Delete the user photo in the filesystem
+ delete_user_photo_filesystem(user_id)
except Exception as err:
# Rollback the transaction
db.rollback()
diff --git a/backend/main.py b/backend/main.py
index f6f5bae01..b00b8ad8c 100644
--- a/backend/main.py
+++ b/backend/main.py
@@ -2,6 +2,8 @@ import logging
import os
from fastapi import FastAPI
+from fastapi.middleware.cors import CORSMiddleware
+from fastapi.staticfiles import StaticFiles
from alembic.config import Config
from alembic import command
@@ -37,15 +39,13 @@ def startup_event():
# Run Alembic migrations to ensure the database is up to date
alembic_cfg = Config("alembic.ini")
# Disable the logger configuration in Alembic to avoid conflicts with FastAPI
- alembic_cfg.attributes['configure_logger'] = False
+ alembic_cfg.attributes["configure_logger"] = False
command.upgrade(alembic_cfg, "head")
# Create a scheduler to run background jobs
scheduler.start()
- # Job to remove expired tokens every 5 minutes
- logger.info("Added scheduler job to remove expired tokens every 5 minutes")
- scheduler.add_job(remove_expired_tokens_job, "interval", minutes=5)
+ # Add scheduler jobs to refresh Strava tokens and retrieve last day activities
logger.info("Added scheduler job to refresh Strava user tokens every 60 minutes")
scheduler.add_job(refresh_strava_tokens_job, "interval", minutes=60)
logger.info(
@@ -64,17 +64,6 @@ def shutdown_event():
scheduler.shutdown()
-def remove_expired_tokens_job():
- # Create a new database session
- db = SessionLocal()
- try:
- # Remove expired tokens from the database
- schema_access_tokens.remove_expired_tokens(db=db)
- finally:
- # Ensure the session is closed after use
- db.close()
-
-
def refresh_strava_tokens_job():
# Create a new database session
db = SessionLocal()
@@ -121,7 +110,9 @@ required_env_vars = [
"JAEGER_HOST",
"JAGGER_PORT",
"STRAVA_DAYS_ACTIVITIES_ONLINK",
+ "FRONTEND_PROTOCOL",
"FRONTEND_HOST",
+ "FRONTEND_PORT",
"GEOCODES_MAPS_API",
]
@@ -147,6 +138,29 @@ app = FastAPI(
},
)
+# Add a route to serve the user images
+app.mount("/user_images", StaticFiles(directory="user_images"), name="user_images")
+
+# Add CORS middleware to allow requests from the frontend
+origins = [
+ "http://localhost",
+ "http://localhost:8080",
+ "http://localhost:5173",
+ os.environ.get("FRONTEND_PROTOCOL")
+ + "://"
+ + os.environ.get("FRONTEND_HOST")
+ + ":"
+ + os.environ.get("FRONTEND_PORT"),
+]
+
+app.add_middleware(
+ CORSMiddleware,
+ allow_origins=origins,
+ allow_credentials=True,
+ allow_methods=["*"],
+ allow_headers=["*"],
+)
+
# Router files
app.include_router(router_session.router)
app.include_router(router_users.router)
diff --git a/backend/models.py b/backend/models.py
index 8a50696c7..5e17c461e 100644
--- a/backend/models.py
+++ b/backend/models.py
@@ -20,14 +20,14 @@ class Follower(Base):
follower_id = Column(
Integer,
- ForeignKey("users.id"),
+ ForeignKey("users.id", ondelete="CASCADE"),
primary_key=True,
index=True,
comment="ID of the follower user",
)
following_id = Column(
Integer,
- ForeignKey("users.id"),
+ ForeignKey("users.id", ondelete="CASCADE"),
primary_key=True,
index=True,
comment="ID of the following user",
@@ -103,12 +103,6 @@ class User(Base):
back_populates="user",
cascade="all, delete-orphan",
)
- # Define a relationship to AccessToken model
- access_tokens = relationship(
- "AccessToken",
- back_populates="user",
- cascade="all, delete-orphan",
- )
# Define a relationship to Gear model
gear = relationship(
"Gear",
@@ -147,7 +141,7 @@ class UserIntegrations(Base):
ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
index=True,
- comment="User ID that the token belongs",
+ comment="User ID that the integration belongs",
)
strava_state = Column(String(length=45), default=None, nullable=True)
strava_token = Column(String(length=250), default=None, nullable=True)
@@ -164,28 +158,6 @@ class UserIntegrations(Base):
user = relationship("User", back_populates="users_integrations")
-# Data model for access_tokens table using SQLAlchemy's ORM
-class AccessToken(Base):
- __tablename__ = "access_tokens"
-
- id = Column(Integer, primary_key=True)
- token = Column(String(length=256), nullable=False, comment="User token")
- user_id = Column(
- Integer,
- ForeignKey("users.id", ondelete="CASCADE"),
- nullable=False,
- index=True,
- comment="User ID that the token belongs",
- )
- created_at = Column(DateTime, nullable=False, comment="Token creation date (date)")
- expires_at = Column(
- DateTime, nullable=False, comment="Token expiration date (date)"
- )
-
- # Define a relationship to the User model
- user = relationship("User", back_populates="access_tokens")
-
-
# Data model for gear table using SQLAlchemy's ORM
class Gear(Base):
__tablename__ = "gear"
diff --git a/backend/routers/router_activities.py b/backend/routers/router_activities.py
index f2d2a9de0..c2cf0ef52 100644
--- a/backend/routers/router_activities.py
+++ b/backend/routers/router_activities.py
@@ -332,17 +332,35 @@ async def read_activities_activity_from_id(
return crud_activities.get_activity_by_id_from_user_id_or_has_visibility(activity_id, token_user_id, db)
+@router.get(
+ "/activities/name/contains/{name}",
+ response_model=list[schema_activities.Activity] | None,
+ tags=["activities"],
+)
+async def read_activities_contain_name(
+ name: str,
+ token_user_id: Annotated[
+ Callable,
+ Depends(dependencies_session.validate_token_and_get_authenticated_user_id),
+ ],
+ db: Session = Depends(dependencies_database.get_db),
+):
+ # Get the activities from the database by name
+ return crud_activities.get_activities_if_contains_name(name, token_user_id, db)
+
+
@router.post(
- "/activities/{user_id}/create/upload",
+ "/activities/create/upload",
status_code=201,
response_model=int,
tags=["activities"],
)
async def create_activity_with_uploaded_file(
- user_id: int,
- validate_user_id: Annotated[Callable, Depends(dependencies_users.validate_user_id)],
+ token_user_id: Annotated[
+ Callable,
+ Depends(dependencies_session.validate_token_and_get_authenticated_user_id),
+ ],
file: UploadFile,
- validate_token: Annotated[Callable, Depends(dependencies_session.validate_token)],
db: Session = Depends(dependencies_database.get_db),
):
try:
@@ -360,10 +378,10 @@ async def create_activity_with_uploaded_file(
# Choose the appropriate parser based on file extension
if file_extension.lower() == ".gpx":
# Parse the GPX file
- parsed_info = gpx_processor.parse_gpx_file(file.filename, user_id)
+ parsed_info = gpx_processor.parse_gpx_file(file.filename, token_user_id)
elif file_extension.lower() == ".fit":
# Parse the FIT file
- parsed_info = fit_processor.parse_fit_file(file.filename, user_id)
+ parsed_info = fit_processor.parse_fit_file(file.filename, token_user_id)
else:
# file extension not supported raise an HTTPException with a 406 Not Acceptable status code
raise HTTPException(
diff --git a/backend/routers/router_strava.py b/backend/routers/router_strava.py
index 826d1bd51..27577b220 100644
--- a/backend/routers/router_strava.py
+++ b/backend/routers/router_strava.py
@@ -80,7 +80,7 @@ async def strava_link(
redirect_url = (
"https://"
+ os.environ.get("FRONTEND_HOST")
- + "/settings/settings.php?integrationsSettings=1&stravaLinked=1"
+ + "/settings?stravaLinked=1"
)
# Return a RedirectResponse to the redirect URL
diff --git a/backend/routers/router_users.py b/backend/routers/router_users.py
index 8d3dd52f2..d6c43e633 100644
--- a/backend/routers/router_users.py
+++ b/backend/routers/router_users.py
@@ -1,11 +1,14 @@
+import os
import logging
from typing import Annotated, Callable
-from fastapi import APIRouter, Depends
+from fastapi import APIRouter, Depends, HTTPException, status, UploadFile
from fastapi.security import OAuth2PasswordBearer
from sqlalchemy.orm import Session
+import shutil
+
from schemas import schema_users
from crud import crud_user_integrations, crud_users
from dependencies import (
@@ -158,6 +161,52 @@ async def create_user(
return created_user.id
+@router.post(
+ "/users/{user_id}/upload/image",
+ status_code=201,
+ response_model=str | None,
+ tags=["users"],
+)
+async def upload_user_image(
+ user_id: int,
+ token_user_id: Annotated[
+ Callable,
+ Depends(dependencies_session.validate_token_and_get_authenticated_user_id),
+ ],
+ file: UploadFile,
+ db: Session = Depends(dependencies_database.get_db),
+):
+ try:
+ upload_dir = "user_images"
+ os.makedirs(upload_dir, exist_ok=True)
+
+ # Get file extension
+ _, file_extension = os.path.splitext(file.filename)
+ filename = f"{user_id}{file_extension}"
+
+ file_path_to_save = os.path.join(upload_dir, filename)
+
+ with open(file_path_to_save, "wb") as buffer:
+ shutil.copyfileobj(file.file, buffer)
+
+ return crud_users.edit_user_photo_path(user_id, file_path_to_save, db)
+ except Exception as err:
+ # Log the exception
+ logger.error(
+ f"Error in upload_user_image: {err}", exc_info=True
+ )
+
+ # Remove the file after processing
+ if os.path.exists(file_path_to_save):
+ os.remove(file_path_to_save)
+
+ # Raise an HTTPException with a 500 Internal Server Error status code
+ raise HTTPException(
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
+ detail="Internal Server Error",
+ ) from err
+
+
@router.put("/users/edit", tags=["users"])
async def edit_user(
user_attributtes: schema_users.User,
diff --git a/backend/schemas/schema_access_tokens.py b/backend/schemas/schema_access_tokens.py
index aade3905c..703632f9f 100644
--- a/backend/schemas/schema_access_tokens.py
+++ b/backend/schemas/schema_access_tokens.py
@@ -7,7 +7,6 @@ from fastapi.security import OAuth2PasswordBearer
from jose import JWTError, jwt
from sqlalchemy.orm import Session
-from crud import crud_access_tokens
from constants import (
JWT_EXPIRATION_IN_MINUTES,
JWT_ALGORITHM,
@@ -69,9 +68,10 @@ def validate_token_expiration(db: Session, token: str = Depends(oauth2_scheme)):
or datetime.utcfromtimestamp(expiration_timestamp) < datetime.utcnow()
):
logger.warning(
- "Token expired | Will force remove_expired_tokens to run | Returning 401 response"
+ "Token expired | Returning 401 response"
)
- remove_expired_tokens(db)
+
+ # Raise an HTTPException with a 401 Unauthorized status code
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Token no longer valid",
@@ -80,10 +80,9 @@ def validate_token_expiration(db: Session, token: str = Depends(oauth2_scheme)):
except Exception:
# Log the error and raise the exception
logger.info(
- "Token expired during validation | Will force remove_expired_tokens to run | Returning 401 response"
+ "Token expired during validation | Returning 401 response"
)
- # Remove expired tokens from the database
- remove_expired_tokens(db)
+
# Raise an HTTPException with a 401 Unauthorized status code
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
@@ -159,29 +158,5 @@ def create_access_token(
# Encode the data and return the token
encoded_jwt = jwt.encode(to_encode, JWT_SECRET_KEY, algorithm=JWT_ALGORITHM)
- # Save the token in the database
- db_access_token = crud_access_tokens.create_access_token(
- CreateToken(
- token=encoded_jwt,
- user_id=data.get("id"),
- expires_at=expire.strftime("%Y-%m-%dT%H:%M:%S"),
- ),
- db,
- )
- if db_access_token:
- # Return the token
- return encoded_jwt
- else:
- # If the token could not be saved in the database return None
- return None
-
-
-def remove_expired_tokens(db: Session):
- # Calculate the expiration time
- expiration_time = datetime.utcnow() - timedelta(minutes=JWT_EXPIRATION_IN_MINUTES)
-
- # Delete the expired tokens from the database
- rows_deleted = crud_access_tokens.delete_access_tokens(expiration_time, db)
-
- # Log the number of tokens deleted
- logger.info(f"{rows_deleted} access tokens deleted from the database")
+ # Return the token
+ return encoded_jwt
diff --git a/backend/user_images/__init__.py b/backend/user_images/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/backend_otel/otel-collector-config.yaml b/backend_otel/otel-collector-config.yaml
deleted file mode 100644
index 863f970d6..000000000
--- a/backend_otel/otel-collector-config.yaml
+++ /dev/null
@@ -1,18 +0,0 @@
-receivers:
- otlp:
- protocols:
- http:
-
-processors:
- batch:
-
-exporters:
- jaeger:
- endpoint: "jaeger:14268" # Use the service name defined in your Jaeger service
-
-service:
- pipelines:
- traces:
- receivers: [otlp]
- processors: [batch]
- exporters: [jaeger]
\ No newline at end of file
diff --git a/crowdin.yml b/crowdin.yml
new file mode 100644
index 000000000..e79638a12
--- /dev/null
+++ b/crowdin.yml
@@ -0,0 +1,7 @@
+files:
+ - source: /frontend_vue/src/i18n/en/*.json
+ translation: /frontend_vue/src/i18n/%two_letters_code%/%file_name%.%file_extension%
+ - source: /frontend_vue/src/i18n/en/components/*.json
+ translation: /frontend_vue/src/i18n/%two_letters_code%/components/%file_name%.%file_extension%
+ - source: /frontend_vue/src/i18n/en/gears/*.json
+ translation: /frontend_vue/src/i18n/%two_letters_code%/gears/%file_name%.%file_extension%
diff --git a/docker-compose.yml b/docker-compose.yml
index 9639e93be..9f07b0b7a 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -4,16 +4,14 @@ services:
frontend:
container_name: frontend
image: ghcr.io/joaovitoriasilva/endurain/frontend:latest
- environment:
- - BACKEND_PROTOCOL=https # http or https, default is http
- - BACKEND_HOST=backend # api host, default is backend
+ #environment:
+ #- MY_APP_BACKEND_PROTOCOL=http # http or https, default is http
+ #- MY_APP_BACKEND_HOST=localhost:98 # api host or local ip (example: 192.168.1.10:98), default is localhost:98
# Configure volume if you want to edit the code locally by clomming the repo
#volumes:
- # -
-
-
-
-
- ';
- } else {
- if ($activity["activity_type"] == 4 || $activity["activity_type"] == 5 || $activity["activity_type"] == 6 || $activity["activity_type"] == 7 || $activity["activity_type"] == 8) {
- echo '';
- } else {
- if ($activity["activity_type"] == 9) {
- echo '';
- }
- }
- } ?>
-
-
-
-
-
-
- -
-- -
-( :
-