after VOEZ launch === <!-- .slide: data-background="#FFDFEF" --> <!-- .slide: data-transition="zoom" --> how to resolve problems of mobile game server development and service maintenance :dizzy: > [name=郭學聰 Hsueh-Tsung Kuo] [time=Fri, 09 Jun 2017] [color=red] --- <!-- .slide: data-transition="convex" --> ## who am I? ![fieliapm](https://pbs.twimg.com/profile_images/591670980021387264/aZAYLRUe_400x400.png) ---- <!-- .slide: data-transition="convex" --> * programmer from Rayark, a game company in Taiwan * backend engineer * usually develop something related to my work in Python, Ruby, Golang, C# * built almost entire VOEZ game server by myself only --- <!-- .slide: data-transition="convex" --> ## VOEZ current status ---- ### spec <!-- .slide: data-transition="convex" --> * rhythm game following after famous titles Cytus and Deemo * profile, save data, achievement, leaderboard... are stored on server * based on Google Cloud Platform ---- <!-- .slide: data-transition="convex" --> ### resource * official website: https://www.rayark.com/g/voez/ * teaser: https://youtu.be/Bh6gQyJHbxI * walkthrough wiki: http://voez.info/ * <small>Apple iTunes: https://itunes.apple.com/jp/app/voez/id1007929736</small> * <small>Google Play: https://play.google.com/store/apps/details?id=com.rayark.valkyrie</small> * <small>Nintendo Switch: https://ec.nintendo.com/JP/ja/titles/70010000000044</small> ---- <!-- .slide: data-transition="convex" --> ### teaser {%youtube Bh6gQyJHbxI %} --- <!-- .slide: data-transition="convex" --> ## outline ---- <!-- .slide: data-transition="convex" --> 3. VOEZ current status ---- <!-- .slide: data-transition="convex" --> 5. HTTP protocol 6. genuine & purchase verification 1. clean leaderboard: signature 2. legal game play: activation 7. stability 1. principle 2. publish order and request order 3. database cache mechanism 4. reliable inter-server request 5. execution resource allocation for request handler and for database ---- <!-- .slide: data-transition="convex" --> 8. performance 1. from database to static file 2. statistics 3. server operation with CDN 4. service downtime and update 9. timezone 1. all about environment variable: TZ 10. conclusion 11. Q&A --- <!-- .slide: data-transition="convex" --> ## HTTP protocol ---- <!-- .slide: data-transition="convex" --> request ``` POST /api/leaderboard/song/add_score HTTP/1.1 Host: voez-api.rayark.net Signature: cHl0aG9uaXN0YQ== { "token": "593a19bc593a19bc", "player": "pythonista", "timestamp": 1496979900, "score": { "total": 999999.0, ...... } } ``` ---- <!-- .slide: data-transition="convex" --> response ``` HTTP/1.1 200 OK Server: nginx/1.9.12 Content-Type: application/json; charset=UTF-8 Content-Length: 22 Cache-Control: public,s-maxage=300 Date: Fri, 09 Jun 2017 10:20:30 GMT { "status": "ok" } ``` ---- <!-- .slide: data-transition="convex" --> API layout 1. gamedata/event/asset download 2. player info setting/getting 3. leaderboard (top/self/friends) 4. start-play authorization & score uploading 5. avatar lottery --- <!-- .slide: data-transition="convex" --> ## genuine ## & ## purchase verification ---- <!-- .slide: data-transition="convex" --> * ~~leaderboard~~ <- fake score * ~~game play~~ <- pirated app & private server ---- <!-- .slide: data-transition="convex" --> ### clean leaderboard: signature - request: send request & attach ==*signature*== <!-- .element: class="fragment" data-fragment-index="1" --> ---- <!-- .slide: data-transition="convex" --> ```sequence client->server: request w/ signature note right of server: verify signature server->client: status ``` ---- <!-- .slide: data-transition="convex" --> ```python= # client side def sign_message(private_key_string, message): private_key = Crypto.PublicKey.RSA.importKey(private_key_string) signer = Crypto.Signature.PKCS1_v1_5.new(private_key) message_hash = Crypto.Hash.SHA256.new(message) signature = signer.sign(message_hash) return base64.standard_b64encode(signature).decode('utf-8') # server side def verify_message(public_key_string, message, signature_b64): public_key = Crypto.PublicKey.RSA.importKey(public_key_string) verifier = Crypto.Signature.PKCS1_v1_5.new(public_key) message_hash = Crypto.Hash.SHA256.new(message) signature = base64.standard_b64decode(signature_b64.encode('utf-8')) return verifier.verify(message_hash, signature) ``` ---- <!-- .slide: data-transition="convex" --> ### legal game play: activation * request: send request & RSA encrypted request hash plus **nonce** (a random variable) * response: send response & attach signature * ==sign response hash plus **nonce**== <!-- .element: class="fragment" data-fragment-index="1" --> * ==client verify signature== <!-- .element: class="fragment" data-fragment-index="2" --> ---- <!-- .slide: data-transition="convex" --> ```sequence client->server: request w/ RSA.encrypt(merge(request.hash, nonce)) note right of server: verify request.hash server->client: response w/ RSA.sign(merge(response.hash, nonce)) note left of client: verify signature ``` ---- <!-- .slide: data-transition="convex" --> ```python= # client side def encrypt_message(public_key_string, message): public_key = Crypto.PublicKey.RSA.importKey(public_key_string) cipher = Crypto.Cipher.PKCS1_OAEP.new(public_key) encrypted_message = cipher.encrypt(message) return base64.standard_b64encode(encrypted_message).decode('utf-8') def encrypt_message_hash_with_nonce(public_key_string, message, nonce): message_hash_digest = Crypto.Hash.SHA256.new(message).digest() # this computation may take too long return encrypt_message(public_key_string, merge(message_hash_digest, nonce)) # server side def decrypt_message(private_key_string, encrypted_message_b64): private_key = Crypto.PublicKey.RSA.importKey(private_key_string) cipher = Crypto.Cipher.PKCS1_OAEP.new(private_key) encrypted_message = base64.standard_b64decode(encrypted_message_b64.encode('utf-8')) return cipher.decrypt(encrypted_message) def verify_encrypted_message_hash_and_extract_nonce(private_key_string, message, encrypted_message_hash_with_nonce_b64): message_hash_with_nonce = decrypt_message(private_key_string, encrypted_message_hash_with_nonce_b64) message_hash_digest = Crypto.Hash.SHA256.new(message).digest() # this computation may take too long (decrypted_message_hash_digest, nonce) = unmerge(message_hash_with_nonce) return (decrypted_message_hash_digest == message_hash_digest, nonce) ``` --- <!-- .slide: data-transition="convex" --> ## stability ---- <!-- .slide: data-transition="convex" --> ### principle * always read & write game data to DB atomically * available DB updating status * before request: * start transaction, not update yet * after request: * success: update completed * fail: not update * caution: * impossible to follow this rule everywhere ---- <!-- .slide: data-transition="convex" --> ### publish order and request order * request order * client read game info A, then B, then C * publish order <!-- .element: class="fragment" data-fragment-index="1" --> * publish game info C, then B, then A * remove game info A, then B, then C * the above principle is suitable for database and online resource distribution <!-- .element: class="fragment" data-fragment-index="2" --> ---- <!-- .slide: data-transition="convex" --> ```python= class Revision(mongo_engine.Document): # read this 1st, publish this 2nd, remove this 1st revision_id = mongo_engine.StringField(required=True) timestamp = mongo_engine.DateTimeField(required=True) name = mongo_engine.StringField(required=True) meta = { 'indexes': [ { 'fields': ['timestamp'], 'unique': True, }, ], } class SongAssetMeta(mongo_engine.Document): # read this 2nd, publish this 1st, remove this 2nd revision_id = mongo_engine.StringField(required=True) song_id = mongo_engine.StringField(required=True) asset_set_checksum = mongo_engine.StringField(required=True) song_cls = mongo_engine.StringField(required=True) song_pack_id = mongo_engine.StringField(required=False) song_title = mongo_engine.StringField(required=True) meta = { 'indexes': [ { 'fields': ['revision_id', 'song_id'], 'unique': True, }, ] } ``` ---- <!-- .slide: data-transition="convex" --> ### database cache mechanism * database in HDD or SSD: slow * Redis or Memcached in RAM: fast ---- <!-- .slide: data-transition="convex" --> * workable mechanism in concurrency environment 1. try to read cache 2. if cache missed, read original data and update data to cache 3. read cache and return data * caution * cache should be set or get atomically * don't build cache from previous cache content * ex: increase & drecrease counter ---- <!-- .slide: data-transition="convex" --> ```python= class SongAssetMetaCache(object): def set_meta(self, revision_id, song_id, asset_set_checksum, song_cls, song_pack_id): meta_key = 'song_asset_meta:%s:%s' % (revision_id, song_id) song_meta = {'asset_set_checksum': asset_set_checksum, 'song_cls': song_cls, 'song_pack_id': song_pack_id} self.strict_redis.hmset(meta_key, song_meta) self.strict_redis.expire(meta_key, self.cache_expire_time) def get_meta(self, revision_id, song_id): return self.strict_redis.hmget(revision_id, song_id) class SongAssetMetaModel(object): def reconstruct_cache(self, revision_id, song_id): try: song_asset_meta = SongAssetMeta.objects.get(revision_id=revision_id, song_id=song_id) except SongAssetMeta.DoesNotExist: SongAssetMeta.delete(revision_id, song_id) else: self.__song_asset_meta_cache.set_meta(song_asset_meta.revision_id, song_asset_meta.song_id, song_asset_meta.asset_set_checksum, song_asset_meta.song_cls, song_asset_meta.song_pack_id) def get_song_asset_meta(self, revision_id, song_id): song_asset_meta = self.__song_asset_meta_cache.get_meta(revision_id, song_id) if song_asset_meta is None: self.reconstruct_cache(revision_id, song_id) song_asset_meta = self.__song_asset_meta_cache.get_meta(revision_id, song_id) return song_asset_meta ``` ---- <!-- .slide: data-transition="convex" --> ### reliable inter-server request * inter server connection and transaction: 1. server A calculate data 2. server A start transaction and write data 3. server A request server B * success: server A finish transaction * fail: server A revert transaction ---- <!-- .slide: data-transition="convex" --> ```python= def consume(player_access_token): failure_count = 0 while True: try: purchase.consume_coin(player_access_token) except (requests.exceptions.RequestException, server_error.ServerError): failure_count += 1 if failure_count >= 3: raise else: break def gacha(player_access_token): selected_avatars = randomly_select_avatars(player_access_token) transaction = begin_appending_avatars_to_player_data(player_access_token, selected_avatars) try: consume(player_access_token) except: revert_appending_avatars_to_player_data(transaction) raise else: finish_appending_avatars_to_player_data(transaction) ``` ---- <!-- .slide: data-transition="convex" --> ### execution resource allocation for request handler and for database * database processing speed per request should be superior to request handler * [x] queue huge requests with load balancer but keep database server reliable ---- <!-- .slide: data-transition="convex" --> ![SINoALICE](https://i.imgur.com/0LZjGBO.jpg) https://i.imgur.com/0LZjGBO.jpg --- <!-- .slide: data-transition="convex" --> ## performance ---- <!-- .slide: data-transition="convex" --> #### Python is so SLOW > :hash: "skip Python code execution > if you can" > [name=Hsueh-Tsung Kuo] [time=Fri, 09 Jun 2017] [color=red] > <!-- .element: class="fragment" data-fragment-index="1" --> ---- <!-- .slide: data-transition="convex" --> ### from database to static file * announce public game info from static file * only per-user data is served from database ---- <!-- .slide: data-transition="convex" --> ```python= def publish_asset_revision(revision): song_list = [process(song) for song in SongAssetMeta.objects(revision_id=revision.id)] song_list_json = json.dumps(song_list, ensure_ascii=False, default=bson.json_util.default) location = get_cloud_storage_location() with io.BytesIO(json_data) as fp: cloud_storage.upload_file(location, fp, 'application/json', len(json_data)) ``` ---- <!-- .slide: data-transition="convex" --> ### statistics * what can we do when exporting log to Google BigQuery * data analysis (boring) * find unusual behavior and alert maintainer (help DevOps) * collect & announce seasonal event result (!?) ---- <!-- .slide: data-transition="convex" --> #### collect & announce seasonal event result * run script with specific intervals (using crontab) * run BigQuery and collect result * save result to Google Cloud Storage and make it public ---- <!-- .slide: data-transition="convex" --> ### server operation with CDN ```sequence client->CDN: request 1 Note left of CDN: cache miss CDN-->server: request 1 server-->CDN: response 1 CDN->client: response 1 client->CDN: request 2 Note right of CDN: cache hit CDN->client: response 2 (same as response 1) ``` ---- <!-- .slide: data-transition="convex" --> #### headers to operating with CDN ``` Cache-Control: public,s-maxage=300 Date: Fri, 09 Jun 2017 10:20:30 GMT ``` ---- <!-- .slide: data-transition="convex" --> #### tips when operating with CDN * asset files * different revisions of asset files should be located at individual URLs * URL must contains revision ID or checksum * entry data which lists asset files * attach event end time via **Cache-Control** or **Expires** * CDN will cache contents until event ended * client can use expire time as refresh timer * ex: back to main menu & display updated game event ---- <!-- .slide: data-transition="convex" --> ```python= @app.route('/api/asset/asset_info/<directory>/<file_name>', methods=('GET',)) @cache_control(get_cdn_cache_maxage()) def get_asset_info(directory, file_name): # handle asset info return flask.Response(response=asset_info_json_data, mimetype='application/json') @app.route('/api/asset/song_list', methods=('GET',) template_response_headers() def get_song_list(): remaining_second = get_current_song_list_remaining_second() cache_maxage = min(remaining_second, get_cdn_cache_maxage()) # handle song list response = flask.Response(response=song_list_json_data, mimetype='application/json') add_precise_cache_control_to_headers(response.headers, cache_maxage) return response ``` ---- <!-- .slide: data-transition="convex" --> ```python= def __add_cache_control_to_headers(headers, s_maxage): headers['Cache-Control'] = 'public,s-maxage=%d' % (s_maxage,) def add_precise_cache_control_to_headers(headers, s_maxage): __add_cache_control_to_headers(headers, s_maxage) def template_response_headers(headers={}): def decorator(func): @wraps(func) def decorated_function(*args, **kwargs): flask.g.current_unix_timestamp = time.time() response = flask.make_response(func(*args, **kwargs)) original_headers = response.headers for (header, value) in headers.items(): original_headers.setdefault(header, value) original_headers.setdefault('Date', werkzeug.http.http_date(flask.g.current_unix_timestamp)) return response return decorated_function return decorator def cache_control(s_maxage): headers = {} __add_cache_control_to_headers(headers, s_maxage) return template_response_headers(headers) ``` ---- <!-- .slide: data-transition="convex" --> ### service downtime and update * cachable: GET 200, 203, 300, 301, 302, 307, 410 (when using Google CDN) * downtime: 503 (non-cachable: even if CDN exists, success HTTP response will be sent to client as long as server become alive again) * update: 410 (cachable: CDN will tell client "this path is abandoned" without bothering original server) --- <!-- .slide: data-transition="convex" --> ## timezone ---- <!-- .slide: data-transition="convex" --> ### all about environment variable: TZ :100: the incredible variable: # TZ=Asia/Taipei <!-- .element: class="fragment" data-fragment-index="1" --> ```python= os.environ['TZ'] = 'Europe/Berlin' time.tzset() ``` <!-- .element: class="fragment" data-fragment-index="2" --> ---- <!-- .slide: data-transition="convex" --> ### all about environment variable: TZ * if you want to announce event and calculate login day accumulation belong to localtime, set TZ to timezone to which you want to refer. * if daylight saving time is applied in this timezone, then UTC offset of this timezone will vary, and this variation will automatically follow rules of this timezone or country. ---- <!-- .slide: data-transition="convex" --> ```python= def get_utc_offset_in_second(timestamp): struct_time = time.localtime(timestamp) return calendar.timegm(struct_time)-int(time.mktime(struct_time)) def get_utc_offset_in_timedelta(timestamp): return datetime.datetime.fromtimestamp(timestamp)-datetime.datetime.utcfromtimestamp(timestamp) def get_unix_epoch_day_in_day(timestamp, utc_offset_in_second, local_day_boundary_in_second=None): if local_day_boundary_in_second is None: local_day_boundary_in_second = 0 return (timestamp+utc_offset_in_second-local_day_boundary_in_second)//86400 def get_unix_epoch_day_in_datetime(datetime_obj, utc_offset_in_timedelta, local_day_boundary_in_timedelta=None): if local_day_boundary_in_timedelta is None: local_day_boundary_in_timedelta = datetime.timedelta() return (datetime_obj+utc_offset_in_timedelta-local_day_boundary_in_timedelta).replace(hour=0, minute=0, second=0, microsecond=0) ``` --- <!-- .slide: data-transition="convex" --> ## conclusion ---- <!-- .slide: data-transition="convex" --> > :hash: "don't repeat the same mistakes we did before!" > [name=Hsueh-Tsung Kuo] [time=Fri, 09 Jun 2017] [color=red] ---- <!-- .slide: data-transition="convex" --> ### special thanks * Rayark Inc. * CTO & CIO * VOEZ team * backend team * QA team * customer service team * IT team * other teams * iKala Interactive Media Inc. --- <!-- .slide: data-transition="zoom" --> ## Q&A --- <style> .reveal code { font-size: 12px !important; line-height: 1.2; } body { background-color: Indigo; } .rightpart{ float:right; width:50%; } .leftpart{ margin-right: 50% !important; height:50%; } .reveal section img { background:none; border:none; box-shadow:none; } p.blo { font-size: 50px !important; background:#B6BDBB; border:1px solid silver; display:inline-block; padding:0.5em 0.75em; border-radius: 10px; box-shadow: 5px 5px 5px #666; } p.blo1 { background: #c7c2bb; } p.blo2 { background: #b8c0c8; } p.blo3 { background: #c7cedd; } p.bloT { font-size: 60px !important; background:#B6BDD3; border:1px solid silver; display:inline-block; padding:0.5em 0.75em; border-radius: 8px; box-shadow: 1px 2px 5px #333; } p.bloA { background: #B6BDE3; } p.bloB { background: #E3BDB3; } .slide-number{ margin-bottom:10px !important; width:100%; text-align:center; font-size:25px !important; background-color:transparent !important; } iframe.myclass{ width:100px; height:100px; bottom:0; left:0; position:fixed; border:none; z-index:99999; } h1.raw { color: #fff; background-image: linear-gradient(90deg,#f35626,#feab3a); -webkit-background-clip: text; -webkit-text-fill-color: transparent; animation: hue 5s infinite linear; } @keyframes hue { from { filter: hue-rotate(0deg); } to { filter: hue-rotate(360deg); } } .progress{ height:14px !important; } .progress span{ height:14px !important; background: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAAMCAIAAAAs6UAAAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAyJpVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADw/eHBhY2tldCBiZWdpbj0i77u/IiBpZD0iVzVNME1wQ2VoaUh6cmVTek5UY3prYzlkIj8+IDx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IkFkb2JlIFhNUCBDb3JlIDUuMy1jMDExIDY2LjE0NTY2MSwgMjAxMi8wMi8wNi0xNDo1NjoyNyAgICAgICAgIj4gPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4gPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIgeG1sbnM6eG1wPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvIiB4bWxuczp4bXBNTT0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wL21tLyIgeG1sbnM6c3RSZWY9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9zVHlwZS9SZXNvdXJjZVJlZiMiIHhtcDpDcmVhdG9yVG9vbD0iQWRvYmUgUGhvdG9zaG9wIENTNiAoV2luZG93cykiIHhtcE1NOkluc3RhbmNlSUQ9InhtcC5paWQ6QUNCQzIyREQ0QjdEMTFFMzlEMDM4Qzc3MEY0NzdGMDgiIHhtcE1NOkRvY3VtZW50SUQ9InhtcC5kaWQ6QUNCQzIyREU0QjdEMTFFMzlEMDM4Qzc3MEY0NzdGMDgiPiA8eG1wTU06RGVyaXZlZEZyb20gc3RSZWY6aW5zdGFuY2VJRD0ieG1wLmlpZDpBQ0JDMjJEQjRCN0QxMUUzOUQwMzhDNzcwRjQ3N0YwOCIgc3RSZWY6ZG9jdW1lbnRJRD0ieG1wLmRpZDpBQ0JDMjJEQzRCN0QxMUUzOUQwMzhDNzcwRjQ3N0YwOCIvPiA8L3JkZjpEZXNjcmlwdGlvbj4gPC9yZGY6UkRGPiA8L3g6eG1wbWV0YT4gPD94cGFja2V0IGVuZD0iciI/PovDFgYAAAAmSURBVHjaYvjPwMAAxjMZmBhA9H8INv4P4TPM/A+m04zBNECAAQBCWQv9SUQpVgAAAABJRU5ErkJggg==") repeat-x !important; } .progress span:after, .progress span.nyancat{ content: ""; background: url('data:image/gif;base64,R0lGODlhIgAVAKIHAL3/9/+Zmf8zmf/MmZmZmf+Z/wAAAAAAACH/C05FVFNDQVBFMi4wAwEAAAAh/wtYTVAgRGF0YVhNUDw/eHBhY2tldCBiZWdpbj0i77u/IiBpZD0iVzVNME1wQ2VoaUh6cmVTek5UY3prYzlkIj8+IDx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IkFkb2JlIFhNUCBDb3JlIDUuMy1jMDExIDY2LjE0NTY2MSwgMjAxMi8wMi8wNi0xNDo1NjoyNyAgICAgICAgIj4gPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4gPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIgeG1sbnM6eG1wTU09Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9tbS8iIHhtbG5zOnN0UmVmPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvc1R5cGUvUmVzb3VyY2VSZWYjIiB4bWxuczp4bXA9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC8iIHhtcE1NOk9yaWdpbmFsRG9jdW1lbnRJRD0ieG1wLmRpZDpDMkJBNjY5RTU1NEJFMzExOUM4QUM2MDAwNDQzRERBQyIgeG1wTU06RG9jdW1lbnRJRD0ieG1wLmRpZDpCREIzOEIzMzRCN0IxMUUzODhEQjgwOTYzMTgyNTE0QiIgeG1wTU06SW5zdGFuY2VJRD0ieG1wLmlpZDpCREIzOEIzMjRCN0IxMUUzODhEQjgwOTYzMTgyNTE0QiIgeG1wOkNyZWF0b3JUb29sPSJBZG9iZSBQaG90b3Nob3AgQ1M2IChXaW5kb3dzKSI+IDx4bXBNTTpEZXJpdmVkRnJvbSBzdFJlZjppbnN0YW5jZUlEPSJ4bXAuaWlkOkM1QkE2NjlFNTU0QkUzMTE5QzhBQzYwMDA0NDNEREFDIiBzdFJlZjpkb2N1bWVudElEPSJ4bXAuZGlkOkMyQkE2NjlFNTU0QkUzMTE5QzhBQzYwMDA0NDNEREFDIi8+IDwvcmRmOkRlc2NyaXB0aW9uPiA8L3JkZjpSREY+IDwveDp4bXBtZXRhPiA8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') !important; width: 34px !important; height: 21px !important; border: none !important; float:right; margin-top:-7px; margin-right:-10px; } </style>
{"metaMigratedAt":"2023-06-14T12:55:43.182Z","metaMigratedFrom":"Content","title":"after VOEZ launch","breaks":true,"contributors":"[]"}
    2794 views