A. Championship & Tournaments is a mobile app for Android platform that I developed in 2020 using mainly Django to build up the webservice and the MVC app, Angular 15 (migrated from Angular 8 since version 2.3.0) to build up functional user interfaces, PythonAnywhere as host platform-as-service (migrating to AWS) and MySQL as database. The Angular application has been compiled for Android leveraging Capacitor 5 and its plugins (migrated from Apache Cordova framework since version 2.3.0).
The use of the app is bound to the user access that can be carried out by direct regitstration on the app via e-mail verification, Google Single-Sign-On or Facebook Single-Sign-On. It is possible to access the app features also as guest user without having to carry out registration, however in this mode the persistence of processed data is safeguarded only by the device local storage. The app contains also ads banner mediated by AdWords, moreover to access the creation of new championships, excepting for the user's first one, it's necessary to watch an advertising rewarded video (video rewarded mediated by AdMob). Those announcements were included to try to cover the server maintenance expenses.
The mobile app is available on Google Play Store and on Amazon Store of Android.
The application was built on the cinders of Amanda Race (2018), an elementary mobile application written in Javascript and compiled for Android with Apache Cordova. Between the main features we have;
In Autumn 2020 I developed also a desktop application for Windows, leveraging Electron libraries to build the executable, and Squirrel libreries to generate the installer. This application contains the following adding features:
Following I'll talk about a selection of the most fascinating technical aspects with which I had to confront during the making of the project
The front-end application architecture of A. Championship & Torunaments leverages Angular submodules subdivision according to the lazy loading modules paradigm. In the routing of the main module (AppModule
) sub-module's routing modules are injected, conceived to perform specific functions, for example the visualization and editing of the team's data:
{
path: 'team-detail:id',
loadChildren: () => import('./modules/team-detail/team-detail.module').then(m => m.TeamDetailModule),
},
To do so, the submodule routing must be exported:
@NgModule({
imports: [
RouterModule.forChild(routes)
],
exports: [RouterModule]
})
export class TeamDetailRoutingModule { }
By developing the comunication between web application and server I widely made use of rxjs
libraries, in particular the Subject
and BehaviorSubject
operators. The webservice was implemented by largely using Python Django REST Framework library. The authentication at the end-points happend through JSON Web Token, held by the user that got it from the client on the sign in.
For instance, follows the call that the app sends to the webservice to get the list of the user's tournaments:
getRaces(): Observable<Race[]> {
this.blockUI.start();
return this.http.get<Race[]>(`${this.getRacesUrl}?owner=${false}`).pipe(
tap(() => this.blockUI.stop()),
catchError(this.errorService.handleError<Race[]>("get races"))
);
}
The owner path variable tells the ws if to include in the list also the championships on which the user is not creator, but only editor. From the other side the server end-point handles that call and send back the response to the app making use of Django REST ViewSets and Serializers:
# serializer/championship.py
class ChampionshipSerializer(serializers.ModelSerializer):
name = serializers.CharField(read_only=False, required=True, source="name")
...other fields...
owner = ConfirmedProfileSerializer(many=False, read_only=True, source="owner")
matches = MatchSerializer(many=True, read_only=False, source="matches")
teams = TeamSerializer(many=True, read_only=False, source="teams")
class Meta:
model = Championship
fields = ['name', 'owner', ...list of fields..., 'matches', 'teams']
def validate_name(self, value):
act_user = ConfirmedProfile.objects.filter(user=self.request.user).first()
if value and Championship.objects.filter(Q(owner=act_user) & Q(name__exact=value)).exists():
raise serializers.ValidationError("Championship with this name already exists!")
return value
def create(self, validated_data):
user = self.request.user
act_user = ConfirmedProfile.objects.filter(user=user).first()
championship, created = Championship.objects.update_or_create(name=validated_data.get("name"),
owner=act_user, defaults=validated_data)
matches_dict = validated_data.pop('maches')
teams_dict = validated_data.pop('teams')
for match in matches_dict:
Match.objects.create(championship=championship, **match)
for team in teams_dict:
Team.objects.create(championship=championship, **team)
...
championship.save()
return championship
...
def to_internal_value(self, data):
resource_data = data
resource_data["owner"] = data["owner"]["username"]
...
return super().to_internal_value(resource_data)
def to_representation(self, instance):
representation = super().to_representation(instance)
representation['owner'] = ConfirmedProfileSerializer(ConfirmedProfile.objects.get(pk=representation['owner'])).data
...
return representation
# views/championships.py
class ChampionshipViewSet(viewsets.ModelViewSet):
serializer_class = ChampionshipSerializer
permission_classes = (IsAuthenticated, ActCustomPermission) # ActCustomPermission verifies that the user is confirmed (so he's able to perform actions) and some other things.
def get_queryset(self):
user = self.request.user
act_user = ConfirmedProfile.objects.filter(user=self.request.user).first()
return Championship.objects.filter(Q(owner=act_user) | Q(user_editors__in=[act_user])).order_by('-created')
def destroy(self, request, *args, **kwargs):
try:
championship = self.get_object()
act_user = ConfirmedProfile.objects.filter(user=self.request.user).first()
if championship.owner == act_user:
self.perform_destroy(championship)
else:
return Response(status=status.HTTP_403_FORBIDDEN)
except Http404:
pass
return Response(status=status.HTTP_204_NO_CONTENT)
@action(detail=True, methods=['get'], permission_classes=[ActCustomPermission, IsAuthenticated])
def get(self, request, pk=None):
act_user = ConfirmedProfile.objects.filter(user=request.user).first()
queryset = Championship.objects.filter(Q(owner=act_user) | Q(user_editors__in=[act_user]))
championship = get_object_or_404(queryset, pk=pk)
serializer = ChampionshipSerializer(championship)
return Response(serializer.data)
@action(detail=False, permission_classes=[ActCustomPermission, IsAuthenticated])
def paginated_list(self, request):
queryset = Championship.objects.filter(Q(owner=act_user) | Q(user_editors__in=[act_user]))
championships = queryset.order_by('-created')
page = self.paginate_queryset(championships)
if page is not None:
serializer = self.get_serializer(page, many=True)
return self.get_paginated_response(serializer.data)
serializer = self.get_serializer(championships, many=True)
return Response(serializer.data)
@action(detail=True, methods=['put'])
def share_championship(self, request, pk=None):
championship = self.get_object()
serializer = ChampionshipSerializer(data=request.data)
editor = request.data.get("editor")
if serializer.is_valid():
if editor != request.user.username:
championship = Championship.objects.filter(owner__user=request.user, id=pk).first()
new_editor_usr = User.objects.get(username=editor)
new_editor = ConfirmedProfile.objects.get(user=new_editor_user)
if len(championship.shared_users.all()) is settings.MAX_EDITORS:
return Response("Max 10 editors!", status=status.HTTP_400_BAD_REQUEST)
championship.shared_users.add(new_editor)
return Response(status=status.HTTP_200_OK)
else:
return Response("You cannot add yourself as editor of a item you are owner", status=status.HTTP_400_BAD_REQUEST)
else:
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
...
# urls.py
router = routers.DefaultRouter()
router.register(r'championships', ChampionshipViewSet, basename="championships")
Each HTTP call executed by the app is first processed and completed through the Angular HttpInterceptor
.
@Injectable()
export class ACInterceptor implements HttpInterceptor
[...]
if (!request.url.includes("api")) {
let token = this.restService.getToken();
if (token) {
request = request.clone({
setHeaders: {
Authorization: `Bearer ${token}`,
"ac-version": CURRENT_VERSION
},
withCredentials: true
});
}
}
The interceptor appends the authentication token to the request's headers. Furthermore it adds the HTTP_AC_VERSION
header (setting it with the app version number, held in a global constant) that is used by the server to verify if the app version is compatible with the last webservice installation. Further the interceptor, in the case of an error response from the server with status=0
, tries to invoke the old DNS name of the webserver. I introduced this fallback, when the webservice was still hosted on my personal website, to keep the most number of versions of the app compatible with the DNS migration I was going to take (the current domain is service.achampionshiptournament.com).
By answer the webservice protects end-points with a series of decorator annotation and a Middleware. The first one, @api_view()
, is the standard decorator of Python Django Rest Framework, aimed to expose the underlying method as end-point reachable from a specific URL. Also @permission_classes
is a decorator of the REST Framework, it automatically manages authentication via JWT. @check_request
, on the other hand, is a custom decorator that I implemented to filter the version and type of the app sending the request. This filter reads the HTTP_AC_VERSION
header set by the interceptor and compares it with the latest compatible version provided by the webservice. If the version of the app making the request is lower than the latest compatible version, this filter rejects the request and replies with a 501_NOT_IMPLEMENTED
. The filter also certifies that the authenticated user making the call is a Confirmed Profile
, i.e. that he has confirmed his identity via email after registration. This step, of course, is not necessary for those who register via Google or Facebook SSO.
def check_request(func):
def inner1(*args, **kwargs):
request = args[0]
version_raw = request.META.get('HTTP_AC_VERSION')
if version_raw is not None:
version = int(version_raw)
if version >= settings.MOBILE_VERSION:
if check_confirmed_profile(request.user) is True:
return func(*args, **kwargs)
else:
return Response(
"Please activate your user following the link in the email we sent to you.",
status=status.HTTP_401_UNAUTHORIZED)
return Response("UPDERR1134 - The application is deprecated and it may will not work properly.\nPlease update the app.", status=status.HTTP_501_NOT_IMPLEMENTED, template_name="Update needed")
return inner1
The client app, in turn, handles the webservice's 501
error response by making the user see an update request popup, which specifies how the use of the application is bound to the need to update it. This dialog also includes the direct link to the Google Play Store to make it easier for the user. Follows the logic performed by the error.service through which all the response in error of the application pass:
if (error.description && error.description.includes("UPDERR1134")) {
**this.showUpdateAlert(error.description);**
} else {
[...]
}
The @server_maintenance
decorator makes the end-point unreachable during the short period of down service due to maintenance operations.
As is well known the Json Web Token has a short term validity expiration (duration) thought to avoid Session Hijacking attacks. However, to avoid the user having to log in again too frequently, I decided to make use of the refresh token, by taking advantage of the configurations of the Simple JWT libraries. Again Angular's HttpInterceptor comes into play. Another interceptor, (different from the one described above) called ErrorInterceptor, handles cases where an HTTP call fails. In case the status code is 401 Unauthorized
and the call is directed to the webservice, the application requests the server to generate a new access token. This renewal access token is guaranteed by the other token, precisely, the refresh token. This one is returned to the client by the webservice when logging in, so that the client can use it to extend the life of the user session. During this renewal operation, the refresh token is renewed in turn, otherwise limiting the duration of the access token would be useless. Furthermore, it too has its own expiry, which however is on average longer than that of the primary token. If the user has not previously authenticated, then the client will not have the refresh token, so this operation undertaken by the ErrorInterceptor
will fail, redirecting the user to login. This also happens in case the refresh token itself has expired, which occurs after long periods of inactivity during which, obviously, this operation has not been performed. Follows the handling of the 401 response by the ErrorInterceptor:
intercept(request: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
return next.handle(request).pipe(catchError(err => {
if (err.status === 401 && !request.url.includes("api")) {
this.restService.refreshToken().subscribe(_ => {
next.handle(request)
});
} else if (err.status === 401) {
this.restService.logout();
location.reload(true);
}
[...]
}))
}
In the making of the timer, although apparently corny, I had to clash against the issue of the seconds precision. The mere use of the Javascript operator setInterval
to refresh time counting proved to be insufficient and inaccurate. setInterval
is subject to temporal slowings dued to CPU computing that, in some situations, can cause the loss of some milliseconds each iteration. This translates, on a scale of long periods of time (for example a game match can last even 90 minutes) in a loss of several seconds if not even entire minutes, making the chronometer inaccurate, unreliable and so unsusable.
The timer refresh iteration is managed inside a method of the timer service named timerFrame()
. Inside it take place the redrawing of both the minutage count and the circular arc that visually represent the passage of time (see the screenshot above). The graphic coordinates of the arc are calculated by interpolating current chronometer's time with the initial time and by trigonometrically proportioning the result to \(2\pi\) (the 360° che composes the complete round). The redrawing invocation of the canvas HTML is:
this.context.arc(width / 2, height / 2, 120, 0, Math.PI * (this.globTime * 2));
where this.globTime
is the variable that represents the proportion between the remaining time and the total time set at the start of the timer. For example if were remaining 30 seconds on a period of 1 minute, the variable this.globTime
would take value 0.5, so an arc of value \(\pi\) would be drawn, that is a semicircle. If, instead, were remaining 60 seconds on 60 seconds, then this.globTime
would have a value of 1.0, resulting in a round circle (\(2\pi\)), a complete circle.
But how to calculate globTime
accurately without being subject to slowing or speedups of CPU keeping the timer aligned to the clock time?
To overcome this issue the afore descripted function timerFrame()
is invoked externally only at the start of the chronometer, when the user press the start button after having set total time. From that moment on is the method that invokes itself at the end of each iteration (it consists in a recursive function) with an interval of 75 milliseconds:
setTimeout(thread => {
this.timerFrame();
}, 75);
At each iteration, the function first of all verifies that the time did not run out. In that case the recursivity would be stopped and would be triggered the notify alert of match conclusion. If time, instead, is not expired yet then the function would proceed to the drawing refresh (afore descripted) and to the recalculation of the value of globTime
. First of all the system time at the moment of the invocation is detected through Javascript function new Date()
. The previous detected time is subtracted to the new one so we get the exact time occurred between an iteraction and the other (delta). Clearly the new detected time is stored in the same variable to be used to be subtracted in the next iteration. This delta is then divided for the total game match time set by the user, resulting in the float proportional value to assign to this.globTime
.
let newTime = new Date().getTime() / 1000;
if (this.lastTime){
this.globTime -= ((newTime - this.lastTime) / this.seconds);
} else {
[..]
}
this.lastTime = newTime;
globTime
will then be used also to extract minutes and seconds to show them in the chronometer numeric data, at the center of the green circle (see screenshot above).
In addition to this I implemented also a series of handlers to allow timer going on keeping timing even when the user is not physically on the ongoing match screen. This allows the user to manage more matches at the same time or even to minimize the app to do something else and wait for the ending game background notification. To do so I leverage the Local Storage of the browser AppView
which, in the case of leaving the match panel, records date and globTime
related to the leaving instant. When the resume occurs it triggers the execution of calculus to determine the time lasted between the leaving of the screen and the resume, so to render a timer that is aware of the time lasted. Everything works even is the timer was paused. The @ionic/angualr
library exposes callbacks to record and manage operations in case of pause or resume of the application.
this.platform.pause.subscribe(() => {
this.getTimer();
});
getTimer() {
[...]
this.match.totalSeconds = this.seconds;
this.match.globTime = this.globTime;
this.match.backTime = Math.round(+new Date() / 1000);
this.match.paused = this.paused;
localStorage.setItem(`match_${this.match.identifier}`, JSON.stringify(this.match));
[...]
}
From these snippets is it clear as, in the case of leaving the app, the necessary informations to resume the counting are recorded inside a match object. Such object is serialized through the JSON.stringify()
function and recorded on localStorage
referencing the unique ID of the game.
The reading of the data from Local Storage happens in the case of resume or simple access into the match detail screen, as long as, obviously, everything was previously recorded.
this.platform.resume.subscribe(() => {
if (localStorage.getItem(`match_${this.match.identifier}`)){
let storedMatch = JSON.parse(localStorage.getItem(`match_${this.match.identifier}`));
if (storedMatch.globTime > 0) {
this.resumeAlreadyStarted(storedMatch.totalSeconds, storedMatch.globTime, storedMatch.backTime, storedMatch.paused, false);
this.givenSecondsBasedFactor = 0.075 / this.seconds;
}
}
});
The match object previously saved is deserialized with funciton JSON.parse()
. After this the method resumeAlreadyStarted()
rebuilds the timer by pretending to have kept time in the meanwhile the user was not present on the match screen. The saving of such informations in Local Storage was thought to make the timer more consistent, actually in the case the user simply minimizes the app, the drawing-refresh function and time counting keep run in background, also allowing the triggering of the time's up notification.
In the detail page of the team it is possible to access some statistics represented by graphics. For the making of the charts I developed three typologies of diagrams contained inside the three related Angular components: app-doughnut
(doughnut chart), app-histogram
(histogram) e app-cartesian
(Cartesian graph). This three components are instanciated to view different typologies of statistics and receive related data in input, and the associated labels, from the team statistics management component; for example:
<div [@inOutStatistic] class="chart-wrapper" *ngIf="pointsLabels && pointsData && pointsShow">
<app-cartesian [labels]="pointsLabels" [data]="pointsData"></app-cartesian>
</div>
where pointsLabels
and pointsData
are arrays of data previously built up and sorted by the main component.
Inside the components, to render concretely input received data, I used, even this time, the HTMLCanvas technology. I take for example the x/y diagram.
The HTML code of the diagram component is made up only by the canvas.
<div class="w-100 h-100" style="overflow-x: scroll;">
<canvas #cartesian id="cartesian" height="310vh" width="310vh"></canvas>
</div>
The visualization of the data is elaborated inside the typescript component logic the makes use of canvas instructions. Here are performed a series of interpolation and resampling operations to redistribute data commensurately along the cartesian space of the diagram.
The crest editor of the teams, instead, was developed exclusively for the desktop application. This editor is equipped with a series of options that combine together through a logic processing in the calls to HTML canvas instructions. It is possible to:
From the editor the user can also edit old crests and export them to their PC in PNG format. For the rendering service logo-creator. service it's a matter of handling a series of shapes and colors to overlap one on each other until to get the desired outcome. Each of the graphic elements aforepointed is handled inside a function. Those functions are then invoked by the the main method draw()
that performs the clear canvas and set its initialization. As an example follows the function that manage the strips of the club colours and the arrangement of the little stars:
drawStrips(logo: Logo) {
if (logo.strips == 'VERTICAL' || logo.strips == 'HORIZONTAL') {
let numStrips = this.CANVAS_WIDTH / logo.stripsWidth;
for (let i = 0; i < numStrips; i++) {
if (i % 2 == 0 || i == 0) {
this.ctx.fillStyle = logo.stripsColor0;
} else {
this.ctx.fillStyle = logo.stripsColor1;
}
if (logo.strips == 'VERTICAL') {
this.ctx.fillRect(logo.stripsWidth * i, 0, logo.stripsWidth, this.CANVAS_HEIGHT);
} else if (logo.strips == 'HORIZONTAL') {
this.ctx.fillRect(0, logo.stripsWidth * i, this.CANVAS_WIDTH, logo.stripsWidth);
}
}
} else if (logo.strips == 'OBLIQUE') {
let numStrips = this.CANVAS_HEIGHT / logo.stripsWidth;
numStrips *= 2;
this.ctx.transform(1, 0.8, -0.8, 1, 1, 1); // X DEFORMAZIONE
for (let i = -(numStrips / 2); i < numStrips; i++) {
if (i % 2 == 0 || i == 0) {
this.ctx.fillStyle = logo.stripsColor0;
} else {
this.ctx.fillStyle = logo.stripsColor1;
}
this.ctx.fillRect(logo.stripsWidth * i, -this.CANVAS_HEIGHT, logo.stripsWidth, this.CANVAS_HEIGHT * 2);
}
this.ctx.setTransform(1, 0, 0, 1, 0, 0); // X RIPRISTINARE MATRICE
} else {
this.ctx.fillStyle = logo.backgroundColor;
this.ctx.fillRect(0, 0, this.CANVAS_WIDTH, this.CANVAS_HEIGHT);
}
}
drawStars(logo: Logo) {
this.ctx.globalCompositeOperation = "source-over";
this.ctx.fillStyle = logo.starsColor;
let divideFactor = Number(logo.stars) + 1;
let xOff = (this.CANVAS_WIDTH / divideFactor);
let s = logo.starsSize;
for (let i = 1; i <= logo.stars; i++) {
this.ctx.transform(s, 0.0, 0.0, s, (xOff * i), 0.0);
this.ctx.beginPath();
this.ctx.moveTo(0.0, 0.0);
this.ctx.lineTo(33, 70);
this.ctx.lineTo(110, 78.3);
this.ctx.lineTo(54, 131);
this.ctx.lineTo(67, 205);
this.ctx.lineTo(0.0, 170);
this.ctx.lineTo(-66.8, 205);
this.ctx.lineTo(-53, 131);
this.ctx.lineTo(-107, 78);
this.ctx.lineTo(-33, 68);
this.ctx.lineTo(0, 0);
this.ctx.closePath();
this.ctx.fill();
this.ctx.resetTransform();
}
}
As aforementioned, inside the application are some advertisments. They are rendered in two ways:
The implementation of AdMob and FAN SDKs was not easy because the application was developed leveraging the Apache Cordova libraries rather than being written directly in Android language. This was translated in the needing of do a (as defined by me) dependencies tuning work, consisting in adjusting the versions of the AdMob SDK's dependencies on Android Studio, before proceed with the build of the apk or of the bundle.
It's expected that the torunament could be created only if the user completes the watching of the video generated by Google. However it may happen (due to the lack of offers or inadequate user profiling data) that AdMob is not able to raise a video to be shown to the user. In this case the user may not be able to proceed in the tournament creation, an eventuality that must be avoided at all. For this the unlock of the torunament happens not only by implementing the "video rewarded completed" callback, but also in the "error in the watching of the video rewarded" case. The "error in the watching of the video rewarded" callback differs from the one that is triggerd in the case the user would interrupt the watching by himself. In this case, however, to limit the loss of impressions deriving from the missing watching, I implemented the video rewarded advertisment request by Facebook Audience Network, as a fallback to the eventual AdMob failure. At this point there is a "second chance" to watch the video rewarded advertisment. If even FAN were to fail on the rise of the advertisment, then it would proceed, in the end, at enabling the new tournament creation by default.
private adMobOnAdFailLoad = (data) => {
console.debug("Failed to load AD: ", data);
this.setUpFANRewarded(); <-- fallback su FAN in caso di fallimento di AdMob
};
Currently I'm asking myself if it's more convenient to invert the hierarchical order of the mediators, because seems like FAN has an higher average ECPM (measure of gain for each impression).
This was implemented before the Server-side Verification Notice (SVN) was made available by hybrid apps plugins.
In a previous pharagraph I talk about the managing of advertising of the video rewarded type. The user is enabled to create a new tournament through an HTTP call to the server. The HTTP request is executed by the application inside the successful watching of the video callback.
But, how to prevent the user from executing this HTTP request (through, for example, an API tool) without having really watched the video?
The relation between the priviledge of create a new tournament and successful watching of the rewarded advertisment is ruled by a session code (named available_creation_security_code
). Before invoking the Google APIs to request the video rewarded, the client app calls the webservice claiming the generation of a randomic 15 characters long code. The server holds that code associating it to the user that executed the request to create a new tournament. Once the code is generated the service proceeds setting up the AdMob advertisment and its eventual fallback on FAN. The watching completion callback is handled as follow:
private onRewardedAd() {
[...]
this.adsService.setAvailableCreation(true, this.adSecurityCode).subscribe(() => {
this.router.navigate(["new-race"]);
});
[...]
}
where setAvailableCreation
sends a request to the webservice holding the session code generated at the beginning of the process. The code is compared to the one previously associated with the user and, once validated, it proceeds unlocking the access to the new tournament creations. Than the code is returned within the response, and following it the application will navigate directly at the tournament creation page, not reachable until that moment. By creating a new tournament the webservice disables the user privileges until the next reiteration of the operation.
The Desktop application offers a 20-day trial after which its use is bound by the payment of an annual subscription. The usage license is bound to the user who accesses it. To set up the licensing of the Desktop application functions, I took advantage of the PayPal APIs which, upon receipt of the payment, feed the activation of the license, setting its annual term. Other server algorithms regulate the duration of the trial period and the license. The license must be renewed manually when it expires or close to its expiration. When accessed with invalid license the front-end shows a payment renewal request banner instead of the ordinary widgets. The back-end functionalities of the REST service are inhibited if the calls are received from a Desktop source with an invalid license. The service distinguishes desktop invocations from mobile ones through the use of an encoded HTTP header. I will leverage this verification method, in a much more sophisticated way from a cryptographic point of view, in Forging History Saga, always to manage privileged client-server interactions. The payment_controller.py
is in charge of managing the renewal of the license and invoking the PayPal API, setting all the user attributes necessary to allow them to proceed with the use of the application once they have the license. I implemented the payment process using Django's MVC system, instead of exposing other end-points to communicate with further Angular components. These HTML pages then lead to the PayPal platform and display the response resulting from the transaction performed by the user.
In addition to the decorators described in the context of REST services, the webservice filters HTTP requests also through the use of a Django custom Middleware, aimed at verifying the License of Use of the desktop application :
MIDDLEWARE = [
[...]
'django.middleware.csrf.CsrfViewMiddleware',
[...]
'django.middleware.clickjacking.XFrameOptionsMiddleware',
'corsheaders.middleware.CorsMiddleware',
'django.middleware.common.CommonMiddleware',
**'webservice.middleware.request_middleware.LicenseMiddleware'**
]
The LicenseMiddleware
firstly ascertains that the request comes from a Desktop and non-mobile application (the mobile app is free to use), after which it performs a series of checks on the User License which I cannot report in a complete way to protect their purpose.
class LicenseMiddleware:
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
response = self.get_response(request)
if request.user.is_authenticated:
verify_response = license_service.verify_desktop_licence(request)
if verify_response.status_code > 202:
response.status_code = verify_response.status_code
response.data = verify_response.data
response._is_rendered = False
response.render()
return response
return response
In the verify_desktop_licence()
method, the response to be sent to the client in case of invalidity of the call is prepared. At this point there are six possible combinations:
200_OK
and no further checks are performed;402_PAYMENT_REQUIRED
and the message Trial expired - Please buy a one-year license for only {} $".format(ONE_YEAR_LICENSE_PRICE)
;202_ACCEPTED
and the message Trial will expire in {} days".format(days_left)
;402_PAYMENT_REQUIRED
and the message License expired - Please renew license for only {}$".format(ONE_YEAR_LICENSE_PRICE)
;200_OK
and the message Your license will expire on {}".format(confirmed_profile.premium_expiring_date)
;500_INTERNAL_SERVER_ERROR
as happens for all end-points.Of course the client application will handle any responses by showing the respective panels to provide such information to the user. In the event of an invalid license, the client application hides the functionality and directs the user to pay.
The SSO authentication of both Google and Facebook required specific front-end and server-side implementations, in addition, of course, to the registrations and configurations at the respective portals. I take authentication via Google as an example.
In the client application I took advantage of the Ionic libraries @ionic-native/google-plus/ngx
. The invocation of the authentication function is triggered when the user presses the "Google Sign In" button and completes access to his account:
this.googlePlus.login({
'scopes': 'profile email',
'webClientId': GOOGLE_WEB_CLIENT_ID,
'offline': false
})
The login()
function of ionic-native/google-plus/ngx returns a promise in which the user
object is kept, a data structure containing the information necessary to proceed with reproducing the Google authentication code to the respective app user. To invoke it, I must provide the GOOGLE_WEB_CLIENT_ID
generated when registering the app on the Google Cloud Platform portal.
First, I extract the Google idToken
from the response. I send it to the webservice to verify that it is already associated with a user registered with the app or that it is a new first access. At this point, the webservice must also make use of the Google SDKs to complete the authentication process. In fact, the idToken
alone is not sufficient to complete the procedure, being for obvious reasons a generic code and not related to my application. To do this I had to add dependencies to the google.auth
libraries to the webservice:
from google.oauth2 import id_token
from google.auth.transport import requests
Through this invocation of the Google authentication service
idinfo = i_token.verify_oauth2_token(request.data.get("idToken"), requests.Request(), settings.CLIENT_ID)
if idinfo['iss'] not in ['accounts.google.com', 'https://accounts.google.com']:
return Response('Wrong issuer.', status=status.HTTP_401_UNAUTHORIZED)
userid = idinfo['sub']
I get the userid
, the real code used to associate the user profile of the app with that of the Google account. The invocation must always be done supplying the CLIENT_ID
. Once I have obtained the userid
I check its presence in the database.
confirmed_profiles = ConfirmedProfile.objects.filter(google_id=str(userid))
if len(confirmed_profiles) > 0:
return Response(True, status=status.HTTP_200_OK)
else:
return Response(False, status=status.HTTP_200_OK)
It there are no accounts with such code associated, then the user is executing the access for the firs time, so its
If there are no accounts with this associated code then the user is logging in for the first time, therefore his username must be registered; otherwise it is simply logging in. Based on this boolean response, the client application redirects the user either to definitive access via Google or to the registration wizard, the only difference of which is to ask in advance to confirm the Cookies and Privacy Policies and the authorization to newsletter. The sign-in takes place in the webservice; the client app sends the server email
and idToken
returned by Google to complete the back-end process. Here I implement the same operation as above to get the userid
. Once in its possession, I check again the presence in the database of an account associated with the user. If there is, I proceed to authenticate on webservice A. Championship & Tournament producing a new JWT with attached Refresh Token and Access Token to be sent to the client.
if confirmed_profiles:
user = confirmed_profile.user
refresh = RefreshToken.for_user(user)
data = {
'tokens': {
'refresh': str(refresh),
'access': str(refresh.access_token),
},
'username': user.username
}
Otherwise I proceed to the creation of a new user and only after that to his authentication on the webservice.
The registration of a new user begins with the creation of his username. Initially I extract what is before the "at" @ in the email address; after which I verify the hominimity. In fact, although unlikely, it is always possible that users with that same username already exist, and as we know this field is subject to uniqueness constraint. Perhaps the same person may have already registered previously, via email verification or via Facebook SSO, and have forgotten about it. The handle_signin_homonymy()
function is responsible for carrying out this check and in case of homonymy generates a random code to be concatenated to the username extracted from the address. In any case, the user will have the possibility to modify his username ("name.surname37YXz7h1" may not be to his liking) through the account settings, however in doing so he will always be limited by the uniqueness constraint.
Note that the handle_signin_homonymy
function recursively invokes itself in the (extremely remote) eventuality that even the user + random code concatenation itelf turns out to be already assigned to another user. And so it goes, potentially indefinitely, until the username is finally unique (it is still statistically unlikely that a Recursive Exceeded Exception
will be generated, even if there were millions of registered users).
def handle_signin_homonymy(username, original_username):
rcl = 4
already_existing_users = User.objects.filter(username=username)
if len(already_existing_users) > 0:
random_number = str(randint(10 ** (rcl - 1), (10 ** rcl) - 1))
if len(username) >= settings.USERNAME_MAX_LENGTH - rcl:
username = original_username[:-rcl\] + random_number
else:
username = original_username + random_number
if len(username) > settings.USERNAME_MAX_LENGTH:
username = username[:settings.USERNAME_MAX_LENGTH\]
return handle_signin_homonymy(username, original_username)
else:
return username
Once the username has been extrapolated, it proceeds with the creation of the user and relative initial data, persisting everything in the database and recording the opted choice about the newsletter. Furthermore, a specific field indicates that this user has registered and will log in via Google SSO. At this point it proceeds, as in the login, to the generation of the JWT and related access tokens to be returned to the client.
The registration and login process through Facebook SSO is similar, with some differences based on the exposed interfaces of the Facebook libraries.
In 2023 I migrated the app from using Cordova as framework for Android to Capacitor 5; this was necessary due te increasing incompatibility of Cordova plugins with the new Android versions (some plugins were not working well on Android 12 and Android 13 devices).
I had to uninstall all Cordova plugins and replace them by installing the brand new alternatives provided by Capacitor official libraries or by Capacitor Community.
Thus, it made an update of Angular was needed as well; so I had to migrate from version 8 to version 15 and, consequently, all the related packages.
The release of this new modernity-compliant version is available on the Play Store since version 2.3.003.