Skip to content

Commit

Permalink
* Remove uniqueness requirement from schedule.slug
Browse files Browse the repository at this point in the history
* Always require namespacing by username for availability
* New function to lookup subscriber by schedule slug or signed url
  • Loading branch information
MelissaAutumn committed Jun 11, 2024
1 parent 3785df9 commit c1f3415
Show file tree
Hide file tree
Showing 9 changed files with 80 additions and 45 deletions.
38 changes: 31 additions & 7 deletions backend/src/appointment/database/repo/schedule.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

from sqlalchemy.orm import Session
from .. import models, schemas, repo
from ... import utils


def create(db: Session, schedule: schemas.ScheduleBase):
Expand All @@ -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):
Expand Down Expand Up @@ -83,28 +88,47 @@ 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()

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
18 changes: 2 additions & 16 deletions backend/src/appointment/database/repo/subscriber.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

from sqlalchemy.orm import Session
from .. import models, schemas
from ... import utils
from ...controller.auth import sign_url


Expand Down Expand Up @@ -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 <username> 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:
Expand Down
16 changes: 16 additions & 0 deletions backend/src/appointment/dependencies/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
7 changes: 4 additions & 3 deletions backend/src/appointment/routes/schedule.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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),
Expand Down
22 changes: 22 additions & 0 deletions backend/src/appointment/utils.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import json
import re

from functools import cache

Expand Down Expand Up @@ -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 /<username>/<signature>/"""
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
2 changes: 1 addition & 1 deletion frontend/src/router.js
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ const routes = [
component: PostLoginView,
},
{
path: '/user/:usernameOrSlug/:signature?',
path: '/user/:username/:signatureOrSlug',
name: 'availability',
component: BookingView,
},
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/stores/user-store.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
});
Expand Down
18 changes: 2 additions & 16 deletions frontend/src/views/BookingView.vue
Original file line number Diff line number Diff line change
Expand Up @@ -148,22 +148,8 @@ const handleError = (data) => {
* @returns {Promise<Object|null>}
*/
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();
Expand Down

0 comments on commit c1f3415

Please sign in to comment.