From c1f34150a0d1528a2a44ac2cdcb6a218f8473507 Mon Sep 17 00:00:00 2001 From: Melissa Autumn Date: Tue, 11 Jun 2024 11:44:35 -0700 Subject: [PATCH] * Remove uniqueness requirement from schedule.slug * Always require namespacing by username for availability * New function to lookup subscriber by schedule slug or signed url --- .../src/appointment/database/repo/schedule.py | 38 +++++++++++++++---- .../appointment/database/repo/subscriber.py | 18 +-------- backend/src/appointment/dependencies/auth.py | 16 ++++++++ ...1613-f1e20604d6e8_add_slug_to_schedules.py | 2 +- backend/src/appointment/routes/schedule.py | 7 ++-- backend/src/appointment/utils.py | 22 +++++++++++ frontend/src/router.js | 2 +- frontend/src/stores/user-store.js | 2 +- frontend/src/views/BookingView.vue | 18 +-------- 9 files changed, 80 insertions(+), 45 deletions(-) diff --git a/backend/src/appointment/database/repo/schedule.py b/backend/src/appointment/database/repo/schedule.py index 345b92efa..9a6716e94 100644 --- a/backend/src/appointment/database/repo/schedule.py +++ b/backend/src/appointment/database/repo/schedule.py @@ -6,6 +6,7 @@ from sqlalchemy.orm import Session from .. import models, schemas, repo +from ... import utils def create(db: Session, schedule: schemas.ScheduleBase): @@ -27,9 +28,13 @@ def get_by_subscriber(db: Session, subscriber_id: int): ) -def get_by_slug(db: Session, slug: str) -> models.Schedule | None: +def get_by_slug(db: Session, slug: str, subscriber_id: int) -> models.Schedule | None: """Get schedule by slug""" - return db.query(models.Schedule).filter(models.Schedule.slug == slug).first() + return (db.query(models.Schedule) + .filter(models.Schedule.slug == slug) + .join(models.Schedule.calendar) + .filter(models.Calendar.owner_id == subscriber_id) + .first()) def get(db: Session, schedule_id: int): @@ -83,18 +88,20 @@ def generate_slug(db: Session, schedule_id: int) -> str|None: if schedule.slug: return schedule.slug + owner_id = schedule.owner.id + # If slug isn't provided, give them the last 8 characters from a uuid4 # Try up-to-3 times to create a unique slug for _ in range(3): slug = uuid.uuid4().hex[-8:] - exists = repo.schedule.get_by_slug(db, slug) + exists = repo.schedule.get_by_slug(db, slug, owner_id) if not exists: schedule.slug = slug break - # Could not create slug due to randomness overlap - if schedule.slug is None: - return None + # Could not create slug due to randomness overlap + if schedule.slug is None: + return None db.add(schedule) db.commit() @@ -102,9 +109,26 @@ def generate_slug(db: Session, schedule_id: int) -> str|None: return schedule.slug -def delete(db: Session, schedule_id: int): +def hard_delete(db: Session, schedule_id: int): schedule = repo.schedule.get(db, schedule_id) db.delete(schedule) db.commit() return True + + +def verify_link(db: Session, url: str) -> models.Subscriber | None: + """Verifies that an url belongs to a subscriber's schedule, and if so return the subscriber. + Otherwise, return none.""" + username, slug, clean_url = utils.retrieve_user_url_data(url) + + subscriber = repo.subscriber.get_by_username(db, username) + if not subscriber: + return None + + schedule = get_by_slug(db, slug, subscriber.id) + + if not schedule: + return None + + return subscriber diff --git a/backend/src/appointment/database/repo/subscriber.py b/backend/src/appointment/database/repo/subscriber.py index 32fe2662e..cf660f2b4 100644 --- a/backend/src/appointment/database/repo/subscriber.py +++ b/backend/src/appointment/database/repo/subscriber.py @@ -8,6 +8,7 @@ from sqlalchemy.orm import Session from .. import models, schemas +from ... import utils from ...controller.auth import sign_url @@ -122,22 +123,7 @@ def verify_link(db: Session, url: str): """Check if a given url is a valid signed subscriber profile link Return subscriber if valid. """ - # Look for a followed by an optional signature that ends the string - pattern = r"[\/]([\w\d\-_\.\@]+)[\/]?([\w\d]*)[\/]?$" - match = re.findall(pattern, url) - - if match is None or len(match) == 0: - return False - - # Flatten - match = match[0] - clean_url = url - - username = match[0] - signature = None - if len(match) > 1: - signature = match[1] - clean_url = clean_url.replace(signature, "") + username, signature, clean_url = utils.retrieve_user_url_data(url) subscriber = get_by_username(db, username) if not subscriber: diff --git a/backend/src/appointment/dependencies/auth.py b/backend/src/appointment/dependencies/auth.py index f3fa13657..1f54cb576 100644 --- a/backend/src/appointment/dependencies/auth.py +++ b/backend/src/appointment/dependencies/auth.py @@ -91,3 +91,19 @@ def get_subscriber_from_signed_url( raise validation.InvalidLinkException return subscriber + + +def get_subscriber_from_schedule_or_signed_url( + url: str = Body(..., embed=True), + db: Session = Depends(get_db), +): + subscriber = repo.subscriber.verify_link(db, url) + print("Signed? ", subscriber) + if not subscriber: + subscriber = repo.schedule.verify_link(db, url) + print("Slug? ", subscriber) + + if not subscriber: + raise validation.InvalidLinkException + + return subscriber diff --git a/backend/src/appointment/migrations/versions/2024_06_04_1613-f1e20604d6e8_add_slug_to_schedules.py b/backend/src/appointment/migrations/versions/2024_06_04_1613-f1e20604d6e8_add_slug_to_schedules.py index 576ec98bd..0626f2e00 100644 --- a/backend/src/appointment/migrations/versions/2024_06_04_1613-f1e20604d6e8_add_slug_to_schedules.py +++ b/backend/src/appointment/migrations/versions/2024_06_04_1613-f1e20604d6e8_add_slug_to_schedules.py @@ -24,7 +24,7 @@ def secret(): def upgrade() -> None: - op.add_column('schedules', sa.Column('slug', StringEncryptedType(sa.DateTime, secret, AesEngine, "pkcs5", length=255), unique=True, index=True)) + op.add_column('schedules', sa.Column('slug', StringEncryptedType(sa.DateTime, secret, AesEngine, "pkcs5", length=255), index=True)) def downgrade() -> None: diff --git a/backend/src/appointment/routes/schedule.py b/backend/src/appointment/routes/schedule.py index 70600be05..c89ed6f8e 100644 --- a/backend/src/appointment/routes/schedule.py +++ b/backend/src/appointment/routes/schedule.py @@ -15,7 +15,8 @@ from ..database.models import Subscriber, CalendarProvider, random_slug, BookingStatus, MeetingLinkProviderType, \ ExternalConnectionType from ..database.schemas import ExternalConnection -from ..dependencies.auth import get_subscriber, get_subscriber_from_signed_url +from ..dependencies.auth import get_subscriber, get_subscriber_from_signed_url, \ + get_subscriber_from_schedule_or_signed_url from ..dependencies.database import get_db, get_redis from ..dependencies.google import get_google_client from datetime import datetime, timedelta, timezone @@ -51,7 +52,7 @@ def create_calendar_schedule( slug = repo.schedule.generate_slug(db, db_schedule.id) if not slug: # A little extra, but things are a little out of place right now.. - repo.schedule.delete(db, db_schedule.id) + repo.schedule.hard_delete(db, db_schedule.id) raise validation.ScheduleCreationException() return db_schedule @@ -118,7 +119,7 @@ def get_signed_url_from_slug( @router.post("/public/availability", response_model=schemas.AppointmentOut) def read_schedule_availabilities( - subscriber: Subscriber = Depends(get_subscriber_from_signed_url), + subscriber: Subscriber = Depends(get_subscriber_from_schedule_or_signed_url), db: Session = Depends(get_db), redis=Depends(get_redis), google_client: GoogleClient = Depends(get_google_client), diff --git a/backend/src/appointment/utils.py b/backend/src/appointment/utils.py index 04e9a609c..52d489d07 100644 --- a/backend/src/appointment/utils.py +++ b/backend/src/appointment/utils.py @@ -1,4 +1,5 @@ import json +import re from functools import cache @@ -41,3 +42,24 @@ def setup_encryption_engine(): engine._update_key(secret()) engine._set_padding_mechanism("pkcs5") return engine + + +def retrieve_user_url_data(url): + """Retrieves username, signature, and main url from ///""" + pattern = r"[\/]([\w\d\-_\.\@!]+)[\/]?([\w\d]*)[\/]?$" + match = re.findall(pattern, url) + + if match is None or len(match) == 0: + return False + + # Flatten + match = match[0] + + clean_url = url + username = match[0] + signature = None + if len(match) > 1: + signature = match[1] + clean_url = clean_url.replace(signature, "") + + return username, signature, clean_url diff --git a/frontend/src/router.js b/frontend/src/router.js index d893b0adf..0f8e8724d 100644 --- a/frontend/src/router.js +++ b/frontend/src/router.js @@ -39,7 +39,7 @@ const routes = [ component: PostLoginView, }, { - path: '/user/:usernameOrSlug/:signature?', + path: '/user/:username/:signatureOrSlug', name: 'availability', component: BookingView, }, diff --git a/frontend/src/stores/user-store.js b/frontend/src/stores/user-store.js index 996f52310..6497f2a59 100644 --- a/frontend/src/stores/user-store.js +++ b/frontend/src/stores/user-store.js @@ -22,7 +22,7 @@ export const useUserStore = defineStore('user', () => { const myLink = computed(() => { const scheduleSlug = data.value?.scheduleSlugs?.length > 0 ? data.value?.scheduleSlugs[0] : null; if (scheduleSlug) { - return `${import.meta.env.VITE_SHORT_BASE_URL}/${scheduleSlug}/`; + return `${import.meta.env.VITE_SHORT_BASE_URL}/${data.value.username}/${scheduleSlug}/`; } return data.value.signedUrl; }); diff --git a/frontend/src/views/BookingView.vue b/frontend/src/views/BookingView.vue index 658bbbf25..9e1c1735e 100644 --- a/frontend/src/views/BookingView.vue +++ b/frontend/src/views/BookingView.vue @@ -148,22 +148,8 @@ const handleError = (data) => { * @returns {Promise} */ const getAppointment = async () => { - let url = null; - // Okay we have a slug, lets lookup the actual signature - if (route.params.usernameOrSlug && !route.params.signature) { - const request = call('schedule/public/url').post({ slug: route.params.usernameOrSlug }); - const { data, error } = await request.json(); - - if (error.value) { - handleError(data?.value); - return null; - } - - url = data?.value?.url; - } - - const signedUrl = url ?? window.location.href.split('#')[0]; - const request = call('schedule/public/availability').post({ url: signedUrl }); + const url = window.location.href.split('#')[0]; + const request = call('schedule/public/availability').post({ url }); const { data, error } = await request.json();