fix merge conflict

This commit is contained in:
Денис Сарапулов 2023-05-17 03:02:17 +10:00
commit 31cd4f98a8
10 changed files with 300 additions and 88 deletions

View File

@ -0,0 +1,87 @@
from django.http import HttpResponseBadRequest, HttpResponse, JsonResponse, HttpRequest
def VVSUAuthProxy(req: HttpRequest):
proxy = 'https://vvsu.ru/connect' + req.path[len('/api/auth/vvsu'):];
preq = requests.request(req.method, proxy, headers={
'User-Agent': 'OIDC Client / Pairent',
'Origin': 'http://pairent.vvsu.ru',
'Referer': 'http://pairent.vvsu.ru'
});
resp = HttpResponse(preq.content);
resp.headers['Content-Type'] = preq.headers['Content-Type'];
return resp;
def register(oid, provider_id, name):
user = User(
favorites_apartments='',
comparison_apartments='',
name=name,
# date_of_birth=,
about_me='',
gender='?',
phone='+00000',
# email=,
# telegram=,
# discord=,
# city=,
role='s',
photo_provider='VVSU',
openid_addr=oid,
openid_id=provider_id,
);
user.save();
return user;
def get_oauth_token(remote, data):
return requests.post(remote + '/oauth2/token', data,
headers={
'Origin': 'https://pairent.vvsu.ru',
'Referer': 'https://pairent.vvsu.ru'
}).json();
def get_oauth_data(remote, key):
return requests.get(remote + '/userinfo', headers={
'Origin': 'https://pairent.vvsu.ru',
'Authorization': 'Bearer ' + key,
'User-Agent': 'curl/8.1'
}).json();
def create_auth_token(userid, ip):
try:
token = AuthToken.objects.get(user=userid, ip=ip);
if (verify_auth_token(token.key, token.ip)):
return token;
except AuthToken.DoesNotExist:
0 # ignore
token = AuthToken(
user=userid,
key=str(uuid.uuid4()),
# 2 days
# vvv
expires=time.time() + 60 * 60 * 24 * 2,
ip=ip
);
token.save();
return token;
def verify_auth_token(key, ip):
try:
token = AuthToken.objects.get(key=key);
except AuthToken.DoesNotExist:
return False;
if (token.ip != ip):
token.delete();
return False;
if (token.expires > time.time()):
token.delete();
return False;
return True;

View File

@ -17,22 +17,31 @@ class Migration(migrations.Migration):
name='User', name='User',
fields=[ fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('favorites_apartments', models.CharField(max_length=100, help_text="Избранные квартиры (CSV)")), ('favorites_apartments', models.CharField(max_length=100, help_text="Избранные квартиры (CSV)", null=True)),
('comparison_apartments', models.CharField(max_length=100, help_text="Квартиры для сравнения (CSV)")), ('comparison_apartments', models.CharField(max_length=100, help_text="Квартиры для сравнения (CSV)", null=True)),
('name', models.CharField(max_length=256, help_text='ФИО Пользователя')), ('name', models.CharField(max_length=500, help_text='ФИО Пользователя', null=True)),
('date_of_birth', models.DateField(help_text='Дата рождения пользователя')), ('date_of_birth', models.DateField(help_text='Дата рождения пользователя', null=True)),
('about_me', models.CharField(max_length=1000, help_text='Поле "О Себе"')), ('about_me', models.CharField(max_length=1000, help_text='Поле "О Себе"', null=True)),
('gender', models.CharField(max_length=1, help_text='Пол пользователя (f,m,n,?)')), ('gender', models.CharField(max_length=1, help_text='Пол пользователя (f,m,n,?)', null=True)),
('phone', models.CharField(max_length=30, help_text='Телефон пользователя в международном формате (+00000000)', null=True)), ('phone', models.CharField(max_length=30, help_text='Телефон пользователя в международном формате (+00000000)', null=True)),
('email', models.CharField(max_length=1000, help_text='Почтовый ящик пользователя в формате user@example.com', null=True)), ('email', models.CharField(max_length=1000, help_text='Почтовый ящик пользователя в формате user@example.com', null=True)),
('telegram', models.CharField(max_length=1000, help_text='Телеграм пользователя', null=True)), ('telegram', models.CharField(max_length=1000, help_text='Телеграм пользователя', null=True)),
('discord', models.CharField(max_length=1000, help_text='Дискорд ник пользователя', null=True)), ('discord', models.CharField(max_length=1000, help_text='Дискорд ник пользователя', null=True)),
('city', models.CharField(max_length=1000, help_text='Город пользователя', null=True)), ('city', models.CharField(max_length=1000, help_text='Город пользователя', null=True)),
('role', models.CharField(max_length=1, help_text='Роль пользователя (s - student, a - admin, m - moderator)', null=False)), ('role', models.CharField(max_length=1, help_text='Роль пользователя (s - student, a - admin, m - moderator)', null=False)),
('photo_provider', models.CharField(max_length=100, verbose_name='Сервис, из которого загружается фотография пользователя (VVSU, GRAVATAR)')), ('photo_provider', models.CharField(max_length=100, verbose_name='Сервис, из которого загружается фотография пользователя (VVSU, GRAVATAR)', null=True)),
('openid_addr', models.CharField(max_length=1000, null=False, help_text='Адрес Open ID Connect (login@provider.com, для ВВГУ - login@vvsu.ru)')), ('openid_addr', models.CharField(max_length=1000, null=False, help_text='Адрес Open ID Connect (login@provider.com, для ВВГУ - login@vvsu.ru)')),
('openid_id', models.CharField(max_length=5000, verbose_name='ID Пользователя в системе провайдера авторизации (скорее всего ВВГУ)')) ('openid_id', models.CharField(max_length=5000, null=False, verbose_name='ID Пользователя в системе провайдера авторизации (скорее всего ВВГУ)'))
]
),
migrations.CreateModel(
name='AuthToken',
fields=[
('user', models.BigIntegerField(null=False, verbose_name='ID Пользователя, которому принадлежит токен')),
('key', models.TextField(verbose_name='Ключ API')),
('expires', models.BigIntegerField(verbose_name='Когда ключ истечет (Unix timestamp)')),
('ip', models.CharField(max_length=16, verbose_name='IP, с которого был создан ключ'))
] ]
) )
] ]

View File

@ -129,4 +129,10 @@ class PsychTestAnswers(models.Model):
class Meta: class Meta:
verbose_name = "Ответ на психологический тест" verbose_name = "Ответ на психологический тест"
verbose_name_plural = "Ответы на психологический тест" verbose_name_plural = "Ответы на психологический тест"
class AuthToken(models.Model):
user = models.BigIntegerField(null=False, verbose_name='ID Пользователя, которому принадлежит токен');
key = models.TextField(verbose_name='Ключ API');
expires = models.BigIntegerField(verbose_name='Когда ключ истечет (Unix timestamp)');
ip = models.CharField(max_length=16, verbose_name='IP, с которого был создан ключ');

View File

@ -1,6 +1,10 @@
from rest_framework import serializers from rest_framework import serializers
<<<<<<< HEAD
from .models import Apartament, User, PsychTestAnswers from .models import Apartament, User, PsychTestAnswers
=======
from .models import Apartament, User, AuthToken
>>>>>>> 4f5f817d835f6a53448e7d60bb7591c24c825e96
class ApartamentListSerializer(serializers.ModelSerializer): class ApartamentListSerializer(serializers.ModelSerializer):
@ -36,4 +40,8 @@ class PsychTestReultsSerializer(serializers.ModelSerializer):
class UserSerializer(serializers.ModelSerializer): class UserSerializer(serializers.ModelSerializer):
class Meta: class Meta:
model = User model = User
fields = "__all__" fields = "__all__"
class TokenSerializer(serializers.ModelSerializer):
class Meta:
model = AuthToken
fields = '__all__'

View File

@ -10,15 +10,19 @@ from django.db.models.query import QuerySet
from django.core.validators import validate_email from django.core.validators import validate_email
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from .models import Apartament, User, PsychTestAnswers from .models import Apartament, User, PsychTestAnswers, AuthToken
from .serializer import (ApartamentListSerializer, from .serializer import (ApartamentListSerializer,
ApartamentDetailSerializer, ApartamentDetailSerializer,
PsychTestAddResultSerializer, PsychTestAddResultSerializer,
PublicUserSerializer, PublicUserSerializer,
PsychTestReultsSerializer, PsychTestReultsSerializer,
UserSerializer) UserSerializer,
TokenSerializer)
import json, math, random, re, requests, oidc_client, base64, hashlib from .authlib import *
import json, math, random, re, requests, oidc_client, base64, uuid, time, ipware as iplib
ipware = iplib.IpWare();
class ApartamentViewSet(viewsets.ReadOnlyModelViewSet): class ApartamentViewSet(viewsets.ReadOnlyModelViewSet):
"""Вывод списка квартир или отдельной квартиры""" """Вывод списка квартир или отдельной квартиры"""
@ -163,58 +167,15 @@ class CompatibleUsersView(viewsets.ViewSet):
return Response(users); return Response(users);
def VVSUAuthProxy(req: Request):
proxy = 'https://vvsu.ru/connect' + req.path[len('/api/auth/vvsu'):];
preq = requests.request(req.method, proxy, headers={
'User-Agent': 'OIDC Client / Pairent',
'Origin': 'http://pairent.vvsu.ru',
'Referer': 'http://pairent.vvsu.ru'
});
resp = HttpResponse(preq.content);
resp.headers['Content-Type'] = preq.headers['Content-Type'];
return resp;
def regiserUser(oid, provider_id, name, date_of_birth):
user = User(
favorites_apartments='',
comparison_apartments='',
name=name,
date_of_birth=date_of_birth,
about_me='',
gender='?',
phone='+00000',
# email=,
# telegram=,
# discord=,
# city=,
role='s',
# photo_provider=,
openid_addr=oid,
openid_id=provider_id,
)
def get_oauth_token(remote, data):
return requests.post(remote + '/oauth2/token', data,
headers={
'Origin': 'https://pairent.vvsu.ru',
'Referer': 'https://pairent.vvsu.ru'
}).json();
def get_oauth_data(remote, key):
return requests.get(remote + '/userinfo', headers={
'Origin': 'https://pairent.vvsu.ru',
'Authorization': 'Bearer ' + key,
'User-Agent': 'curl/8.1'
}).json();
class UserLogin(APIView): class UserLogin(APIView):
# TODO: Remove csrf exempt when index.html is loaded through django # TODO: Remove csrf exempt when index.html is loaded through django
@csrf_exempt @csrf_exempt
def post(self, req: HttpRequest): def post(self, req: HttpRequest):
# for debug purposes
# return HttpResponse("""{"user_data": {"id": 1, "name": "\u041d\u0438\u043a\u0438\u0442\u0430 \u041f\u0443\u0441\u0442\u043e\u0432\u0430\u043b\u043e\u0432", "date_of_birth": null, "about_me": "", "gender": "?", "phone": "+00000", "email": null, "telegram": null, "discord": null, "city": null, "role": "s", "photo_provider": "VVSU", "openid_addr": "blek__@vvsu.ru", "openid_id": "096C78CD-4943-4D57-BC6D-5CDE12F686E3"}, "new_user": false, "token": {"id": 2, "user": 1, "key": "e1c24581-523a-4f60-973f-02ba873b3edc", "expires": 1684423572, "ip": "127.0.0.1"}}""");
if (req.session.has_key('auth_data')): if (req.session.has_key('auth_data')):
# TODO: Return user object instead of error # TODO: Return user object instead of error
return JsonResponse({'error': 'already authenticated'}) return JsonResponse({'error': 'already authenticated'})
@ -231,42 +192,44 @@ class UserLogin(APIView):
res.status_code = 400; res.status_code = 400;
return res; return res;
# auth_data = get_oauth_token('https://vvsu.ru/connect', { auth_data = get_oauth_token('https://vvsu.ru/connect', {
# 'grant_type': 'authorization_code', 'grant_type': 'authorization_code',
# 'redirect_uri': 'https://pairent.vvsu.ru/sign-in/', 'redirect_uri': 'https://pairent.vvsu.ru/sign-in/',
# 'code': data['code'], 'code': data['code'],
# 'code_verifier': data['code_verifier'], 'code_verifier': data['code_verifier'],
# 'client_id': 'it-hub-client', 'client_id': 'it-hub-client',
# 'client_secret': 'U8y@uPVee6Q^*729esHTo4Vd' 'client_secret': 'U8y@uPVee6Q^*729esHTo4Vd'
# }); });
auth_data = {'access_token': '5kHvrjy91LJgJLKitejBBG24c7JiX45tEstKVHRpfHc._WQDwQ2F13aytbGFjlGnjXJeUWcDD1V3om3cRW0IujM', 'expires_in': 3600, 'id_token': 'eyJhbGciOiJSUzI1NiIsImtpZCI6InB1YmxpYzpoeWRyYS5vcGVuaWQuaWQtdG9rZW4iLCJ0eXAiOiJKV1QifQ.eyJhY3IiOiIwIiwiYXRfaGFzaCI6IjRMR1dRekxVaXFodUVTYjU0QWFIM0EiLCJhdWQiOlsiaXQtaHViLWNsaWVudCJdLCJhdXRoX3RpbWUiOjE2ODQyMzc4MDksImNhbGxiYWNrX3VybCI6IiIsImV4cCI6MTY4NDI0MTQ1NSwiZmFtaWx5X25hbWUiOiLQn9GD0YHRgtC-0LLQsNC70L7QsiIsImdpdmVuX25hbWUiOiLQndC40LrQuNGC0LAiLCJpYXQiOjE2ODQyMzc4NTUsImlkIjoiMDk2Qzc4Q0QtNDk0My00RDU3LUJDNkQtNUNERTEyRjY4NkUzIiwiaXNzIjoiaHR0cHM6Ly93d3cudnZzdS5ydS9jb25uZWN0LyIsImp0aSI6IjEzMTBhNzcwLWFhZWUtNGExYS1hMTc1LWM3MzY3ZWM0ZjVhNyIsImxvZ2luIjoiaHR0cHM6Ly9vcGVuaWQudnZzdS5ydS9ibGVrX18iLCJvcGVuaWQiOiJodHRwczovL29wZW5pZC52dnN1LnJ1L2JsZWtfXyIsInBpY3R1cmUiOiJodHRwczovL3d3dy52dnN1LnJ1L29pc2twL3Bob3RvL3B0aC5hc3A_SUQ9MDk2Qzc4Q0QtNDk0My00RDU3LUJDNkQtNUNERTEyRjY4NkUzXHUwMDI2IiwicHJvZmlsZV91cmwiOm51bGwsInJhdCI6MTY4NDIzNzc5Nywic2lkIjoiOTYyYzg0OGYtZThkNS00ZDJjLWEwZmEtYjI5YmU3YjBlODAxIiwic3ViIjoiaHR0cHM6Ly9vcGVuaWQudnZzdS5ydS9ibGVrX18iLCJzdXJuYW1lIjoi0J_Rg9GB0YLQvtCy0LDQu9C-0LIiLCJ0aXRsZSI6ItCh0YLRg9C00LXQvdGCIiwidnZzdV9JZEVtcGwiOm51bGwsInZ2c3VfSWRTdHVkIjoiMTk3MDgwIiwidnZzdV9JZFVzZXIiOjE5MDQ4OSwidnZzdV9sb2dpbiI6ImJsZWtfXyJ9.A4BiOxpOqnesSiTGRdcTsC-lGhSABswivpUovD9EOdYmqKW753VlLcXQxfBPcfmq8Fdf7RmVvXTXPXYqkX7AKxQT-yUUm7XtJHCb85g2YfL64cjTP2sFYD6wPIU9nzXbCrsgKqKubY3p16Dn9VyrBCXE9N6jdbuNOFbWMLPLPlp7U5fx2SzVGaBMUONlTf8KiLkcisQoN4c_rPGqdi38gzhLf7WGEiKLOldXH1q-s_kPeObFvcdbsFrrnDPnJtdqBx8SF02wqJsrZlBiB9Hl-d6sSJYLZZWumFhS-qscfwRlTEZKqC-hWF5c9R8CUYewk89JxRvCcKrHZvPMip9j9vJF1_OjkSrC5EkGaprl765FgVPEBJqXj9LjGRkTOYfYUFAAMia_HhjtinQFp6XJ-Rh3JrmIfLAQ7DEUSOldMQ1xUw9GeHo_0sIsnjaM6lVx6M_SiDTWihxNu58DiI8tmvkdw7in95OJRoJZ30EhR3SGYsK3b51qdYK1aieufJHX40bN_S1gc84pisTg58z-zC5kGsjsZNv6gRSTO4oOpZMK1FMjv7HyasSMWEu-J052X4Qxquj4pWglpiGQNt3-E0jZUUjqmZ0-7AYiyEC_3IItBqWrve-LTXRF5faIZB5v3F3urY6Qjgn93m_AoK1oujfNAPk8WOLTv419CuC2fAc', 'scope': 'openid vvsu_IdUser vvsu_IdEmpl vvsu_IdStud vvsu_login given_name family_name', 'token_type': 'bearer'} if ('error' in auth_data):
return JsonResponse(auth_data);
user = None; user = None;
new_user = False; new_user = False;
print(auth_data); vvsu_data = get_oauth_data('https://vvsu.ru/connect', auth_data['access_token']);
return JsonResponse(get_oauth_data('https://vvsu.ru/connect', auth_data['access_token'])); if ('error' in vvsu_data):
res = JsonResponse(vvsu_data);
req.session['auth_data'] = vvsu_data; res.status_code = 500;
return res;
if ('error' in vvsu_data): if ('error' in vvsu_data):
res = JsonResponse(vvsu_data); res = JsonResponse(vvsu_data);
res.status_code = cb.status_code; res.status_code = 500;
return res return res
vvsu_data['vvsu_login'] += '@vvsu.ru'; vvsu_data['vvsu_login'] += '@vvsu.ru';
try: try:
user = User.objects.get(openid_addr=vvsu_data['vvsu_login']); user = User.objects.get(openid_addr=vvsu_data['vvsu_login']);
except User.DoesNotExist: except User.DoesNotExist:
registerUser(vvsu_data['vvsu_login'], cb.id, f'{cb.given_name} {cb.family_name}'); user = register(vvsu_data['vvsu_login'], vvsu_data['id'], f"{vvsu_data['given_name']} {vvsu_data['family_name']}");
user = User.objects.get(openid_addr=vvsu_data['vvsu_login']);
new_user = True; new_user = True;
return JsonResponse({ return JsonResponse({
'user_data': user, 'user_data': PublicUserSerializer(user).data,
'new_user': new_user 'new_user': new_user,
'token': TokenSerializer(create_auth_token(user.id, ipware.get_client_ip(req.META)[0].exploded)).data
}); });
class UserGet(APIView): class UserGet(APIView):

View File

@ -4,4 +4,4 @@ djangorestframework
django-cors-headers django-cors-headers
Pillow Pillow
requests requests
oidc-client python-ipware

View File

@ -0,0 +1,57 @@
/**
* Basic API interaction & local caching interface
*/
class IAPIObject {
/**
* Local storage key used to save data.
* @type {string}
*/
static storage_key = undefined;
static _checkStorageSupport() {
if (this.storage_key === undefined) {
throw Error('This doesn\'t support local storage');
}
}
_getStorageKey() {
return Object.getPrototypeOf(this).constructor.storage_key;
}
_IcheckStorageSupport() {
if (this._getStorageKey() === undefined) {
throw Error('This doesn\'t support local storage');
}
}
/** @returns {ThisType} */
static restoreFromLocalStorage() {
_checkStorageSupport();
if (!window.sessionStorage.getItem(this.storage_key))
return false;
return new APIToken(window.sessionStorage.getItem(this.storage_key));
}
/** @returns {boolean} */
static isCached() {
this._checkStorageSupport();
if (window.sessionStorage.getItem(this.storage_key)) {
return true;
}
return false;
}
/**
* Save this object to local storage
* @throws {QuotaExceededError}
* @returns {void}
*/
saveToLocalStorage() {
this._IcheckStorageSupport();
window.sessionStorage.setItem(this._getStorageKey(), JSON.stringify(this));
}
}
export { IAPIObject };

View File

@ -1,6 +1,8 @@
import axios from 'axios'; import axios from 'axios';
import constants from '../constants'; import constants from '../constants';
import { IAPIObject } from './IAPIObject';
const { API_ROOT, api_path } = constants; const { API_ROOT, api_path } = constants;
class UserLoginResponse { class UserLoginResponse {
@ -14,8 +16,43 @@ class UserLoginResponse {
id; id;
} }
class User { class APIToken extends IAPIObject {
static storage_key = 'pairent_api_key';
constructor(data) { constructor(data) {
super();
this.user = data.user;
this.key = data.key;
this.expires = data.expires;
this.ip = data.ip;
}
/** @type {number} */
user;
/** @type {string} */
key;
/** A Unix timestamp (when the token will expire)
* @type {number}
*/
expires;
/** @type {string} */
ip;
}
class User extends IAPIObject {
isLoggedIn() {
return false;
}
static storage_key = 'pairent_user_data';
constructor(data) {
super();
for (const key in data) { for (const key in data) {
this[key] = data[key]; this[key] = data[key];
} }
@ -44,7 +81,16 @@ class User {
} }
const data = await axios.post(api_path('/api/auth/user/login'), response); const data = await axios.post(api_path('/api/auth/user/login'), response);
return new User(data.data); if (data.status !== 200) {
return false;
}
if (!data.data.error) {
new APIToken(data.data.token).saveToLocalStorage();
new User(data.data.user_data).saveToLocalStorage();
}
return data.data;
} }
} }

View File

@ -32,12 +32,12 @@ export default class LoggedIn extends React.Component {
constructor(props) { constructor(props) {
super(props); super(props);
this.response = new SigninResponse(new URL(window.location.href).searchParams); this.response = new SigninResponse(new URL(window.location.href).searchParams);
} }
async componentDidMount() { async componentDidMount() {
if (this.response.error) return; if (this.response.error) return;
window.localStorage.removeItem('auth_fail');
let code_verifier = '?'; let code_verifier = '?';
// get code verifier // get code verifier
for (const key in localStorage) { for (const key in localStorage) {
@ -48,7 +48,21 @@ export default class LoggedIn extends React.Component {
} }
} }
console.log(await User.login({...this.response, code_verifier})); const response = await User.login({...this.response, code_verifier});
if (response.error) {
// pass the data to LoginPage
window.localStorage.setItem('auth_fail', JSON.stringify(response));
window.location.href = '/login';
return;
}
if (response.new_user) {
// TODO: Make the page
window.location.href = '/register';
} else {
window.location.href = '/';
}
} }
render() { render() {

View File

@ -8,6 +8,8 @@ import FloatingBox from '../../components/UI/FloatingBox';
import * as OpenID from 'oidc-client-ts'; import * as OpenID from 'oidc-client-ts';
import constants from '../../constants'; import constants from '../../constants';
import { User } from '../../API/User';
const { OIDCConfig } = constants; const { OIDCConfig } = constants;
const LoginButton = styled(BlueButton)` const LoginButton = styled(BlueButton)`
@ -45,9 +47,11 @@ export default class LoginPage extends React.Component {
super(props); super(props);
this.state = { this.state = {
loading: false loading: false,
error: JSON.parse(window.localStorage.getItem('auth_fail'))
} }
window.localStorage.removeItem('auth_fail');
this.openid = this.openid.bind(this); this.openid = this.openid.bind(this);
} }
@ -56,15 +60,18 @@ export default class LoginPage extends React.Component {
this.setState({loading: true}); this.setState({loading: true});
OpenID.Log.setLogger(console); OpenID.Log.setLogger(console);
OpenID.Log.setLevel(OpenID.Log.DEBUG); OpenID.Log.setLevel(OpenID.Log.NONE);
let client = new OpenID.UserManager(OIDCConfig); let client = new OpenID.UserManager(OIDCConfig);
client.signinRedirect(); client.signinRedirect();
} }
render() { render() {
if (User.isCached())
window.location.href = '/';
return ( return (
<div style={{height: '65vh'}}> <div style={{height: '65vh'}}>
<FloatingBox> <FloatingBox>
@ -84,6 +91,21 @@ export default class LoginPage extends React.Component {
Вход осуществляется только через<br/> Вход осуществляется только через<br/>
Систему Единого Входа ВВГУ Систему Единого Входа ВВГУ
</SmallText> </SmallText>
{
this.state.error ?
<SmallText style={{color: 'darkred', fontWeight: '600'}}>
Произошла ошибка: { this.state.error.error }
{
this.state.error.error_description ?
<>
<br/>
{this.state.error.error_description}
</>
: null
}
</SmallText>
: null
}
</FloatingBox> </FloatingBox>
</div> </div>
); );