Add server_images logic, env vars & login photo support

[backend] added login_photo_set column to server_settings
[backend] added ENVIRONMENT env variable
[backend] added server_images
[backend] added protections to server_images and user_images logic
[backend] fixed issue on gpx laps processing with no variables initialized
[backend] CORS now in production only accept requests from ENDURAIN_HOST variable
[docs] updated docs with new env variable and new volume
[frontend] added support to new column on server settings store
This commit is contained in:
João Vitória Silva
2025-04-21 12:46:24 +01:00
parent 4529f239c4
commit 8bc19eaf4c
15 changed files with 156 additions and 19 deletions

5
.gitignore vendored
View File

@@ -14,6 +14,11 @@ backend/app/*.pyc
backend/app/logs/*.log
backend/app/*.log
# server image folder images
backend/app/server_images/*.jpeg
backend/app/server_images/*.png
backend/app/server_images/*.jpg
# user image folder images
backend/app/user_images/*.jpeg
backend/app/user_images/*.png

View File

@@ -31,6 +31,30 @@ def upgrade() -> None:
comment="0 - public, 1 - followers, 2 - private",
),
)
# Add login_photo_set column to server_settings table
op.add_column(
"server_settings",
sa.Column(
"login_photo_set",
sa.Boolean(),
nullable=True,
comment="Is login photo set (true - yes, false - no)",
),
)
# Set default value for existing records
op.execute(
"""
UPDATE server_settings
SET login_photo_set = FALSE
"""
)
# Make the column non-nullable now that all rows have a value
op.alter_column(
"server_settings",
"login_photo_set",
existing_type=sa.Boolean(),
nullable=False,
)
# Create activities_laps table
op.create_table(
"activity_laps",
@@ -297,7 +321,10 @@ def upgrade() -> None:
"notes", sa.String(length=250), nullable=True, comment="Workout step notes"
),
sa.Column(
"exercise_category", sa.Integer(), nullable=True, comment="Workout step exercise category"
"exercise_category",
sa.Integer(),
nullable=True,
comment="Workout step exercise category",
),
sa.Column(
"exercise_name", sa.Integer(), nullable=True, comment="Exercise name ID"
@@ -366,11 +393,12 @@ def upgrade() -> None:
nullable=False,
comment="Workout set start date (DATETIME)",
),
sa.Column("category", sa.Integer(), nullable=True, comment="Category name"),
sa.Column(
"category", sa.Integer(), nullable=True, comment="Category name"
),
sa.Column(
"category_subtype", sa.Integer(), nullable=True, comment="Category sub type number"
"category_subtype",
sa.Integer(),
nullable=True,
comment="Category sub type number",
),
sa.ForeignKeyConstraint(["activity_id"], ["activities.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
@@ -391,9 +419,29 @@ def upgrade() -> None:
existing_nullable=False,
)
# Add column tennis_gear_id to users_default_gear table
op.add_column('users_default_gear', sa.Column('tennis_gear_id', sa.Integer(), nullable=True, comment='Gear ID that the default tennis activity type belongs'))
op.create_index(op.f('ix_users_default_gear_tennis_gear_id'), 'users_default_gear', ['tennis_gear_id'], unique=False)
op.create_foreign_key(None, 'users_default_gear', 'gear', ['tennis_gear_id'], ['id'], ondelete='SET NULL')
op.add_column(
"users_default_gear",
sa.Column(
"tennis_gear_id",
sa.Integer(),
nullable=True,
comment="Gear ID that the default tennis activity type belongs",
),
)
op.create_index(
op.f("ix_users_default_gear_tennis_gear_id"),
"users_default_gear",
["tennis_gear_id"],
unique=False,
)
op.create_foreign_key(
None,
"users_default_gear",
"gear",
["tennis_gear_id"],
["id"],
ondelete="SET NULL",
)
# Create migration record
op.execute(
"""
@@ -414,9 +462,11 @@ def downgrade() -> None:
"""
)
# Drop foreign key constraint from users_default_gear table
op.drop_constraint(None, 'users_default_gear', type_='foreignkey')
op.drop_index(op.f('ix_users_default_gear_tennis_gear_id'), table_name='users_default_gear')
op.drop_column('users_default_gear', 'tennis_gear_id')
op.drop_constraint(None, "users_default_gear", type_="foreignkey")
op.drop_index(
op.f("ix_users_default_gear_tennis_gear_id"), table_name="users_default_gear"
)
op.drop_column("users_default_gear", "tennis_gear_id")
# Alter gear column gear_type to remove racquet from comment
op.alter_column(
"gear",

View File

@@ -9,6 +9,7 @@ LICENSE_IDENTIFIER = "AGPL-3.0-or-later"
LICENSE_URL = "https://spdx.org/licenses/AGPL-3.0-or-later.html"
ROOT_PATH = "/api/v1"
FRONTEND_DIR = "/app/frontend/dist"
ENVIRONMENT = os.getenv("ENVIRONMENT")
def check_required_env_vars():
@@ -33,6 +34,7 @@ def check_required_env_vars():
"JAGGER_PORT",
"ENDURAIN_HOST",
"GEOCODES_MAPS_API",
"ENVIRONMENT",
]
for var in required_env_vars:

View File

@@ -34,7 +34,35 @@ def api_not_found():
def user_img_return(
user_img: str,
):
return core_utils.return_user_img_path(user_img)
path = core_utils.return_user_img_path(user_img)
# If the path is None, raise a 404 error
if path is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User image not found",
)
# Return the user image path
return path
@router.get("/server_images/{server_img}", include_in_schema=False)
def server_img_return(
server_img: str,
):
# Get the server image path
path = core_utils.return_server_img_path(server_img)
# If the path is None, raise a 404 error
if path is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Server image not found",
)
# Return the server image path
return path
@router.get("/{path:path}", include_in_schema=False)

View File

@@ -1,8 +1,20 @@
from fastapi.responses import FileResponse
import os
def return_frontend_index(path: str):
return FileResponse("/app/frontend/dist/" + path)
def return_user_img_path(user_img: str):
return FileResponse("/app/backend/user_images/" + user_img)
file_path = "/app/backend/user_images/" + user_img
if not os.path.isfile(file_path):
return None
return FileResponse(file_path)
def return_server_img_path(server_img: str):
file_path = "/app/backend/server_images/" + server_img
if not os.path.isfile(file_path):
return None
return FileResponse(file_path)

View File

@@ -510,6 +510,12 @@ def generate_activity_laps(
lap_hr_waypoints = filter_waypoints(hr_waypoints, start_time, end_time)
lap_cad_waypoints = filter_waypoints(cad_waypoints, start_time, end_time)
lap_vel_waypoints = filter_waypoints(vel_waypoints, start_time, end_time)
ele_gain, ele_loss = None, None
avg_hr, max_hr = None, None
avg_cadence, max_cadence = None, None
avg_speed, max_speed = None, None
avg_power, max_power, np = None, None, None
# Calculate total ascent and descent
if lap_ele_waypoints:

View File

@@ -77,15 +77,21 @@ def create_app() -> FastAPI:
# Add CORS middleware to allow requests from the frontend
origins = [
"http://localhost",
"http://localhost:8080",
"http://localhost:5173",
os.environ.get("ENDURAIN_HOST"),
]
print(core_config.ENVIRONMENT)
print("CORS Origins:", origins)
app.add_middleware(
CORSMiddleware,
allow_origins=origins,
allow_origins=(
origins
if core_config.ENVIRONMENT == "development"
else os.environ.get("ENDURAIN_HOST")
),
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
@@ -96,6 +102,9 @@ def create_app() -> FastAPI:
# Add a route to serve the user images
app.mount("/user_images", StaticFiles(directory="user_images"), name="user_images")
app.mount(
"/server_images", StaticFiles(directory="server_images"), name="server_images"
)
app.mount(
"/", StaticFiles(directory=core_config.FRONTEND_DIR, html=True), name="frontend"
)

View File

View File

@@ -24,5 +24,11 @@ class ServerSettings(Base):
default=False,
comment="Allow show user info on public shareable links (true - yes, false - no)",
)
login_photo_set = Column(
Boolean,
nullable=False,
default=False,
comment="Is login photo set (true - yes, false - no)",
)
__table_args__ = (CheckConstraint("id = 1", name="single_row_check"),)

View File

@@ -6,6 +6,7 @@ class ServerSettings(BaseModel):
units: int
public_shareable_links: bool
public_shareable_links_user_info: bool
login_photo_set: bool
class Config:
orm_mode = True

View File

@@ -58,7 +58,8 @@ ENV UID=1000 \
JAGGER_PORT=4317 \
ENDURAIN_HOST="http://localhost:8080" \
GEOCODES_MAPS_API="changeme" \
BEHIND_PROXY=false
BEHIND_PROXY=false \
ENVIRONMENT="production"
# Set the working directory to /app/frontend
WORKDIR /app/frontend

View File

@@ -43,6 +43,7 @@ Environment variable | Default value | Optional | Notes |
| JAEGER_HOST | jaeger | Yes | N/A |
| JAGGER_PORT | 4317 | Yes | N/A |
| BEHIND_PROXY | false | Yes | Change to true if behind reverse proxy |
| ENVIRONMENT | production | Yes | "production" and "development" allowed. "development" allows connections from localhost:8080 and localhost:5173 at the CORS level |
Table below shows the obligatory environment variables for mariadb container. You should set them based on what was also set for the Endurain container.
@@ -79,6 +80,7 @@ Docker image uses a non-root user, so ensure target folders are not owned by roo
| `<local_path>/endurain/backend/files/bulk_import:/app/backend/files/bulk_import` | Necessary to enable bulk import of activities. Place here your activities files |
| `<local_path>/endurain/backend/files/processed:/app/backend/files/processed` | Necessary for processed original files persistence on container image updates |
| `<local_path>/endurain/backend/user_images:/app/backend/user_images` | Necessary for user image persistence on container image updates |
| `<local_path>/endurain/backend/server_images:/app/backend/server_images` | Necessary for server image persistence on container image updates |
| `<local_path>/endurain/backend/logs:/app/backend/logs` | Log files for the backend |
## Bulk import and file upload
@@ -86,5 +88,11 @@ Docker image uses a non-root user, so ensure target folders are not owned by roo
.fit files are preferred. I noticed that Strava/Garmin Connect process of converting .fit to .gpx introduces additional data to the activity file leading to minor variances in the data, like for example additional meters in distance and elevation gain.
Some notes:
- After the files are processed, the files are moved to the processed folder.
- GEOCODES API has a limit of 1 Request/Second on the free plan, so if you have a large number of files, it might not be possible to import all in the same action.
- After the files are processed, the files are moved to the processed folder
- GEOCODES API has a limit of 1 Request/Second on the free plan, so if you have a large number of files, it might not be possible to import all in the same action
## Image personalization
It is possible (v0.10.0 or higher) to personalize the login image in the login page. To do that, map the server_images directory and:
- Place the image there with the name "login.png"
- A square image is expected. Default one uses 1000px vs 1000px

View File

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 1.1 MiB

View File

@@ -8,6 +8,7 @@ export const useServerSettingsStore = defineStore('serverSettings', {
units: 1,
public_shareable_links: false,
public_shareable_links_user_info: false,
login_photo_set: false,
},
}),
actions: {

View File

@@ -2,7 +2,7 @@
<div class="bg-body-tertiary shadow-sm rounded p-3">
<div class="row justify-content-center align-items-center">
<div class="col d-none d-lg-block">
<img width="auto" height="auto" src="../assets/sports_images/run_triton.png" alt="João at the finish line in Triton 1 Lisbon" class="img-fluid rounded">
<img width="auto" height="auto" :src="loginPhotoUrl" alt="Square login image" class="img-fluid rounded" />
</div>
<div class="col form-signin text-center m-3">
<form @submit.prevent="submitForm">
@@ -45,6 +45,7 @@ import { useI18n } from "vue-i18n";
import { push } from "notivue";
// Importing the stores
import { useAuthStore } from "@/stores/authStore";
import { useServerSettingsStore } from "@/stores/serverSettingsStore";
// Importing the services for the login
import { session } from "@/services/sessionService";
import { profile } from "@/services/profileService";
@@ -60,7 +61,13 @@ export default {
const username = ref("");
const password = ref("");
const authStore = useAuthStore();
const serverSettingsStore = useServerSettingsStore();
const showPassword = ref(false);
const loginPhotoUrl = serverSettingsStore.serverSettings.login_photo_set
? `${import.meta.env.VITE_ENDURAIN_HOST}/server_images/login.png`
: "/src/assets/login.png";
console.log("loginPhotoUrl", loginPhotoUrl);
// Toggle password visibility
const togglePasswordVisibility = () => {
@@ -124,6 +131,7 @@ export default {
togglePasswordVisibility,
submitForm,
t,
loginPhotoUrl,
};
},
};