Skip to content

Commit

Permalink
Short URLs (#465)
Browse files Browse the repository at this point in the history
* Refactor models to reduce visual noise
* Add slug to schedule model and migration
* Fill in any existing schedule with a null slug
* Add functionality to retrieve schedule availability by slug or signed url
* Frontend adjustments to make the "My Link" textbox bigger
* Add plenty of tests for auth dependencies
* 🔨 Fix confirmation mail

---------

Co-authored-by: Andreas Müller <[email protected]>
  • Loading branch information
MelissaAutumn and devmount authored Jun 12, 2024
1 parent f77581b commit 0ccc949
Show file tree
Hide file tree
Showing 22 changed files with 435 additions and 117 deletions.
105 changes: 55 additions & 50 deletions backend/src/appointment/database/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import os
import uuid
import zoneinfo
from functools import cached_property

from sqlalchemy import Column, ForeignKey, Integer, String, DateTime, Enum, Boolean, JSON, Date, Time
from sqlalchemy_utils import StringEncryptedType, ChoiceType, UUIDType
Expand Down Expand Up @@ -81,6 +82,11 @@ class InviteStatus(enum.Enum):
revoked = 2 # The code is no longer valid and cannot be used for sign up anymore


def encrypted_type(column_type, length: int = 255, **kwargs) -> StringEncryptedType:
"""Helper to reduce visual noise when creating model columns"""
return StringEncryptedType(column_type, secret, AesEngine, 'pkcs5', length=length, **kwargs)


@as_declarative()
class Base:
"""Base model, contains anything we want to be on every model."""
Expand Down Expand Up @@ -115,27 +121,23 @@ class Subscriber(HasSoftDelete, Base):
__tablename__ = 'subscribers'

id = Column(Integer, primary_key=True, index=True)
username = Column(StringEncryptedType(String, secret, AesEngine, 'pkcs5', length=255), unique=True, index=True)
username = Column(encrypted_type(String), unique=True, index=True)
# Encrypted (here) and hashed (by the associated hashing functions in routes/auth)
password = Column(StringEncryptedType(String, secret, AesEngine, 'pkcs5', length=255), index=False)
password = Column(encrypted_type(String), index=False)

# Use subscriber.preferred_email for any email, or other user-facing presence.
email = Column(StringEncryptedType(String, secret, AesEngine, 'pkcs5', length=255), unique=True, index=True)
secondary_email = Column(
StringEncryptedType(String, secret, AesEngine, 'pkcs5', length=255), nullable=True, index=True
)
email = Column(encrypted_type(String), unique=True, index=True)
secondary_email = Column(encrypted_type(String), nullable=True, index=True)

name = Column(StringEncryptedType(String, secret, AesEngine, 'pkcs5', length=255), index=True)
name = Column(encrypted_type(String), index=True)
level = Column(Enum(SubscriberLevel), default=SubscriberLevel.basic, index=True)
timezone = Column(StringEncryptedType(String, secret, AesEngine, 'pkcs5', length=255), index=True)
avatar_url = Column(StringEncryptedType(String, secret, AesEngine, 'pkcs5', length=2048), index=False)
timezone = Column(encrypted_type(String), index=True)
avatar_url = Column(encrypted_type(String, length=2048), index=False)

short_link_hash = Column(StringEncryptedType(String, secret, AesEngine, 'pkcs5', length=255), index=False)
short_link_hash = Column(encrypted_type(String), index=False)

# Only accept the times greater than the one specified in the `iat` claim of the jwt token
minimum_valid_iat_time = Column(
'minimum_valid_iat_time', StringEncryptedType(DateTime, secret, AesEngine, 'pkcs5', length=255)
)
minimum_valid_iat_time = Column('minimum_valid_iat_time', encrypted_type(DateTime))

calendars = relationship('Calendar', cascade='all,delete', back_populates='owner')
slots = relationship('Slot', cascade='all,delete', back_populates='subscriber')
Expand All @@ -158,11 +160,11 @@ class Calendar(Base):
id = Column(Integer, primary_key=True, index=True)
owner_id = Column(Integer, ForeignKey('subscribers.id'))
provider = Column(Enum(CalendarProvider), default=CalendarProvider.caldav)
title = Column(StringEncryptedType(String, secret, AesEngine, 'pkcs5', length=255), index=True)
color = Column(StringEncryptedType(String, secret, AesEngine, 'pkcs5', length=32), index=True)
url = Column(StringEncryptedType(String, secret, AesEngine, 'pkcs5', length=2048), index=False)
user = Column(StringEncryptedType(String, secret, AesEngine, 'pkcs5', length=255), index=True)
password = Column(StringEncryptedType(String, secret, AesEngine, 'pkcs5', length=255))
title = Column(encrypted_type(String), index=True)
color = Column(encrypted_type(String, length=32), index=True)
url = Column(encrypted_type(String, length=2048), index=False)
user = Column(encrypted_type(String), index=True)
password = Column(encrypted_type(String))
connected = Column(Boolean, index=True, default=False)
connected_at = Column(DateTime)

Expand All @@ -180,23 +182,21 @@ class Appointment(Base):
uuid = Column(UUIDType(native=False), default=uuid.uuid4(), index=True)
calendar_id = Column(Integer, ForeignKey('calendars.id'))
duration = Column(Integer)
title = Column(StringEncryptedType(String, secret, AesEngine, 'pkcs5', length=255))
title = Column(encrypted_type(String))
location_type = Column(Enum(LocationType), default=LocationType.inperson)
location_suggestions = Column(String(255))
location_selected = Column(Integer)
location_name = Column(StringEncryptedType(String, secret, AesEngine, 'pkcs5', length=255))
location_url = Column(StringEncryptedType(String, secret, AesEngine, 'pkcs5', length=2048))
location_phone = Column(StringEncryptedType(String, secret, AesEngine, 'pkcs5', length=255))
details = Column(StringEncryptedType(String, secret, AesEngine, 'pkcs5', length=255))
slug = Column(StringEncryptedType(String, secret, AesEngine, 'pkcs5', length=255), unique=True, index=True)
location_name = Column(encrypted_type(String))
location_url = Column(encrypted_type(String, length=2048))
location_phone = Column(encrypted_type(String))
details = Column(encrypted_type(String))
slug = Column(encrypted_type(String), unique=True, index=True)
keep_open = Column(Boolean)
status: AppointmentStatus = Column(Enum(AppointmentStatus), default=AppointmentStatus.draft)

# What (if any) meeting link will we generate once the meeting is booked
meeting_link_provider = Column(
StringEncryptedType(ChoiceType(MeetingLinkProviderType), secret, AesEngine, 'pkcs5', length=255),
default=MeetingLinkProviderType.none,
index=False,
encrypted_type(ChoiceType(MeetingLinkProviderType)), default=MeetingLinkProviderType.none, index=False
)

calendar: Mapped[Calendar] = relationship('Calendar', back_populates='appointments')
Expand All @@ -207,8 +207,8 @@ class Attendee(Base):
__tablename__ = 'attendees'

id = Column(Integer, primary_key=True, index=True)
email = Column(StringEncryptedType(String, secret, AesEngine, 'pkcs5', length=255), index=True)
name = Column(StringEncryptedType(String, secret, AesEngine, 'pkcs5', length=255), index=True)
email = Column(encrypted_type(String), index=True)
name = Column(encrypted_type(String), index=True)
timezone = Column(String(255), index=True)

slots: Mapped[list['Slot']] = relationship('Slot', cascade='all,delete', back_populates='attendee')
Expand All @@ -227,12 +227,12 @@ class Slot(Base):
duration = Column(Integer)

# provider specific id we can use to query against their service
meeting_link_id = Column(StringEncryptedType(String, secret, AesEngine, 'pkcs5', length=1024), index=False)
meeting_link_id = Column(encrypted_type(String, length=1024), index=False)
# meeting link override for a appointment or schedule's location url
meeting_link_url = Column(StringEncryptedType(String, secret, AesEngine, 'pkcs5', length=2048))
meeting_link_url = Column(encrypted_type(String, length=2048))

# columns for availability bookings
booking_tkn = Column(StringEncryptedType(String, secret, AesEngine, 'pkcs5', length=512), index=False)
booking_tkn = Column(encrypted_type(String, length=512), index=False)
booking_expires_at = Column(DateTime)
booking_status = Column(Enum(BookingStatus), default=BookingStatus.none)

Expand All @@ -249,24 +249,23 @@ class Schedule(Base):
id: int = Column(Integer, primary_key=True, index=True)
calendar_id: int = Column(Integer, ForeignKey('calendars.id'))
active: bool = Column(Boolean, index=True, default=True)
name: str = Column(StringEncryptedType(String, secret, AesEngine, 'pkcs5', length=255), index=True)
name: str = Column(encrypted_type(String), index=True)
slug: str = Column(encrypted_type(String), index=True, unique=True)
location_type: LocationType = Column(Enum(LocationType), default=LocationType.inperson)
location_url: str = Column(StringEncryptedType(String, secret, AesEngine, 'pkcs5', length=2048))
details: str = Column(StringEncryptedType(String, secret, AesEngine, 'pkcs5', length=255))
start_date: datetime.date = Column(StringEncryptedType(Date, secret, AesEngine, 'pkcs5', length=255), index=True)
end_date: datetime.date = Column(StringEncryptedType(Date, secret, AesEngine, 'pkcs5', length=255), index=True)
start_time: datetime.time = Column(StringEncryptedType(Time, secret, AesEngine, 'pkcs5', length=255), index=True)
end_time: datetime.time = Column(StringEncryptedType(Time, secret, AesEngine, 'pkcs5', length=255), index=True)
location_url: str = Column(encrypted_type(String, length=2048))
details: str = Column(encrypted_type(String))
start_date: datetime.date = Column(encrypted_type(Date), index=True)
end_date: datetime.date = Column(encrypted_type(Date), index=True)
start_time: datetime.time = Column(encrypted_type(Time), index=True)
end_time: datetime.time = Column(encrypted_type(Time), index=True)
earliest_booking: int = Column(Integer, default=1440) # in minutes, defaults to 24 hours
farthest_booking: int = Column(Integer, default=20160) # in minutes, defaults to 2 weeks
weekdays: str | dict = Column(JSON, default='[1,2,3,4,5]') # list of ISO weekdays, Mo-Su => 1-7
slot_duration: int = Column(Integer, default=30) # defaults to 30 minutes

# What (if any) meeting link will we generate once the meeting is booked
meeting_link_provider: MeetingLinkProviderType = Column(
StringEncryptedType(ChoiceType(MeetingLinkProviderType), secret, AesEngine, 'pkcs5', length=255),
default=MeetingLinkProviderType.none,
index=False,
encrypted_type(ChoiceType(MeetingLinkProviderType)), default=MeetingLinkProviderType.none, index=False
)

calendar: Mapped[Calendar] = relationship('Calendar', back_populates='schedules')
Expand All @@ -291,6 +290,12 @@ def end_time_local(self) -> datetime.time:
)
return time_of_save.astimezone(zoneinfo.ZoneInfo(self.calendar.owner.timezone)).time()

@cached_property
def owner(self):
if not self.calendar:
return None
return self.calendar.owner


class Availability(Base):
"""This table will be used as soon as the application provides custom availability
Expand All @@ -301,11 +306,11 @@ class Availability(Base):

id = Column(Integer, primary_key=True, index=True)
schedule_id = Column(Integer, ForeignKey('schedules.id'))
day_of_week = Column(StringEncryptedType(String, secret, AesEngine, 'pkcs5', length=255), index=True)
start_time = Column(StringEncryptedType(String, secret, AesEngine, 'pkcs5', length=255), index=True)
end_time = Column(StringEncryptedType(String, secret, AesEngine, 'pkcs5', length=255), index=True)
day_of_week = Column(encrypted_type(String), index=True)
start_time = Column(encrypted_type(String), index=True)
end_time = Column(encrypted_type(String), index=True)
# Can't book if it's less than X minutes before start time:
min_time_before_meeting = Column(StringEncryptedType(String, secret, AesEngine, 'pkcs5', length=255), index=True)
min_time_before_meeting = Column(encrypted_type(String), index=True)
slot_duration = Column(Integer) # Size of the Slot that can be booked.

schedule: Mapped[Schedule] = relationship('Schedule', back_populates='availabilities')
Expand All @@ -318,10 +323,10 @@ class ExternalConnections(Base):

id = Column(Integer, primary_key=True, index=True)
owner_id = Column(Integer, ForeignKey('subscribers.id'))
name = Column(StringEncryptedType(String, secret, AesEngine, 'pkcs5', length=255), index=False)
name = Column(encrypted_type(String), index=False)
type = Column(Enum(ExternalConnectionType), index=True)
type_id = Column(StringEncryptedType(String, secret, AesEngine, 'pkcs5', length=255), index=True)
token = Column(StringEncryptedType(String, secret, AesEngine, 'pkcs5', length=2048), index=False)
type_id = Column(encrypted_type(String), index=True)
token = Column(encrypted_type(String, length=2048), index=False)
owner: Mapped[Subscriber] = relationship('Subscriber', back_populates='external_connections')


Expand All @@ -332,7 +337,7 @@ class Invite(Base):

id = Column(Integer, primary_key=True, index=True)
subscriber_id = Column(Integer, ForeignKey('subscribers.id'))
code = Column(StringEncryptedType(String, secret, AesEngine, 'pkcs5', length=255), index=False)
code = Column(encrypted_type(String), index=False)
status = Column(Enum(InviteStatus), index=True)

subscriber: Mapped['Subscriber'] = relationship('Subscriber', back_populates='invite', single_parent=True)
Expand Down
62 changes: 62 additions & 0 deletions backend/src/appointment/database/repo/schedule.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,11 @@
Repository providing CRUD functions for schedule database models.
"""
import uuid

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


def create(db: Session, schedule: schemas.ScheduleBase):
Expand All @@ -26,6 +28,15 @@ def get_by_subscriber(db: Session, subscriber_id: int):
)


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)
.join(models.Schedule.calendar)
.filter(models.Calendar.owner_id == subscriber_id)
.first())


def get(db: Session, schedule_id: int):
"""retrieve schedule by id"""
if schedule_id:
Expand Down Expand Up @@ -69,3 +80,54 @@ def has_slot(db: Session, schedule_id: int, slot_id: int):
"""check if slot belongs to schedule"""
db_slot = repo.slot.get(db, slot_id)
return db_slot and db_slot.schedule_id == schedule_id


def generate_slug(db: Session, schedule_id: int) -> str|None:
schedule = repo.schedule.get(db, schedule_id)

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, owner_id)
if not exists:
schedule.slug = slug
break

# Could not create slug due to randomness overlap
if schedule.slug is None:
return None

db.add(schedule)
db.commit()

return schedule.slug


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
22 changes: 4 additions & 18 deletions backend/src/appointment/database/repo/subscriber.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
"""Module: repo.subscriber
Repository providing CRUD functions for subscriber database models.
Repository providing CRUD functions for subscriber database models.
"""

import re
import datetime

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


Expand Down Expand Up @@ -122,28 +123,13 @@ 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:
return False

clean_url_with_short_link = clean_url + f'{subscriber.short_link_hash}'
clean_url_with_short_link = clean_url + f"{subscriber.short_link_hash}"
signed_signature = sign_url(clean_url_with_short_link)

# Verify the signature matches the incoming one
Expand Down
7 changes: 6 additions & 1 deletion backend/src/appointment/database/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
import json
from uuid import UUID
from datetime import datetime, date, time
from typing import Annotated
from typing import Annotated, Optional

from pydantic import BaseModel, Field

Expand Down Expand Up @@ -156,6 +156,7 @@ class Config:
class ScheduleBase(BaseModel):
active: bool | None = True
name: str = Field(min_length=1)
slug: Optional[str] = None
calendar_id: int
location_type: LocationType | None = LocationType.inperson
location_url: str | None = None
Expand Down Expand Up @@ -193,6 +194,10 @@ class ScheduleValidationIn(ScheduleBase):
slot_duration: Annotated[int, Field(ge=10, default=30)]


class ScheduleSlug(BaseModel):
slug: str


""" CALENDAR model schemas
"""

Expand Down
15 changes: 15 additions & 0 deletions backend/src/appointment/dependencies/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -97,3 +97,18 @@ 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),
):
"""Retrieve a subscriber based off a signed url or schedule slug namespaced by their username."""
subscriber = repo.subscriber.verify_link(db, url)
if not subscriber:
subscriber = repo.schedule.verify_link(db, url)

if not subscriber:
raise validation.InvalidLinkException

return subscriber
Loading

0 comments on commit 0ccc949

Please sign in to comment.