From 55abdb286754af577b622b13216d6932d0c4246f Mon Sep 17 00:00:00 2001 From: "Gleb O. Ivaniczkij" Date: Fri, 26 Jul 2024 02:13:03 +0300 Subject: [PATCH] =?UTF-8?q?=D0=A0=D0=B0=D0=B7=D1=80=D0=B0=D0=B1=D0=BE?= =?UTF-8?q?=D1=82=D0=B0=D0=BD=20=D0=B2=D0=B5=D0=B1-=D1=81=D0=B5=D1=80?= =?UTF-8?q?=D0=B2=D0=B5=D1=80=20=D0=B8=20=D0=B8=D0=B3=D1=80=D0=BE=D0=B2?= =?UTF-8?q?=D0=BE=D0=B5=20=D0=BF=D0=BE=D0=BB=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/__init__.py | 1 + app/main.py | 151 ++++++++++++++++++++++++++++ config/__init__.py | 1 + config/main.py | 28 ++++++ database.sql | 107 ++++++++++++++++++++ database/__init__.py | 1 + database/main.py | 204 +++++++++++++++++++++++++++++++++++++ index.html | 27 +++++ main.py | 5 + models/__init__.py | 1 + models/main.py | 53 ++++++++++ requirements.txt | 6 ++ static/scripts/main.js | 221 +++++++++++++++++++++++++++++++++++++++++ static/styles/main.css | 54 ++++++++++ 14 files changed, 860 insertions(+) create mode 100644 app/__init__.py create mode 100644 app/main.py create mode 100644 config/__init__.py create mode 100644 config/main.py create mode 100644 database.sql create mode 100644 database/__init__.py create mode 100644 database/main.py create mode 100644 index.html create mode 100644 main.py create mode 100644 models/__init__.py create mode 100644 models/main.py create mode 100644 requirements.txt create mode 100644 static/scripts/main.js create mode 100644 static/styles/main.css diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..e4c7aa5 --- /dev/null +++ b/app/__init__.py @@ -0,0 +1 @@ +from .main import app diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..8ccd999 --- /dev/null +++ b/app/main.py @@ -0,0 +1,151 @@ +import asyncio +import database +from fastapi import FastAPI, Request, Body, WebSocket, WebSocketDisconnect +from fastapi.responses import Response +from fastapi.exceptions import HTTPException +import models +import typing + + +app = FastAPI() + + +@app.middleware('http') +async def function( + request: Request, + callback: typing.Callable, +): + async def too_many_requests( + message: str, + delay: int, + ): + raise HTTPException( + status_code=429, + detail={ + 'msg': message, + }, + headers={ + 'Retry-After': str(delay), + }, + ) + + requests_frequency = await database.get_requests_frequency( + ip_address=request.client.host, + ) + if requests_frequency.per_day > 10000: + return await too_many_requests( + message='Слишком много запросов в день.', + delay=86400, + ) + if requests_frequency.per_hour > 1000: + return await too_many_requests( + message='Слишком много запросов в час.', + delay=3600, + ) + if requests_frequency.per_minute > 100: + return await too_many_requests( + message='Слишком много запросов в минуту.', + delay=60, + ) + if requests_frequency.per_second > 1: + return await too_many_requests( + message='Слишком много запросов в секунду.', + delay=1, + ) + asyncio.ensure_future( + database.put_request( + ip_address=request.client.host, + method=request.method, + url=str(request.url), + is_secure=request.base_url.is_secure, + user_agent=request.headers.get('User-Agent'), + referer=request.headers.get('Referer'), + ) + ) + return await callback(request) + + +class ConnectionManager: + def __init__(self): + self.connections: list[WebSocket] = list[WebSocket]() + + async def connect(self, websocket: WebSocket): + await websocket.accept() + self.connections.append(websocket) + + def disconnect(self, websocket: WebSocket): + self.connections.remove(websocket) + + async def broadcast(self, data: dict): + for connection in self.connections: + asyncio.ensure_future(connection.send_json(data)) + + +manager = ConnectionManager() + + +@app.websocket('/ws/v1/pixels') +async def function( + websocket: WebSocket, +): + await manager.connect(websocket) + try: + await websocket.receive() + except WebSocketDisconnect: + manager.disconnect(websocket) + + +@app.get('/api/v1/pixels') +async def function(): + pixels_co = database.get_pixels() + return Response( + content=models.Area( + pixels=await pixels_co, + ).json(), + status_code=200, + headers=models.headers, + media_type='application/json', + ) + + +@app.put('/api/v1/pixels') +async def function( + request: Request, + x: int = Body( + ..., + ge=0, + le=models.area_width - 1, + ), + y: int = Body( + ..., + ge=0, + le=models.area_height - 1, + ), + color: int = Body( + ..., + ge=0x000000, + le=0xFFFFFF, + ), +): + await database.put_pixel( + x=x, + y=y, + color=color, + ip_address=request.client.host, + ) + await manager.broadcast({ + 'x': x, + 'y': y, + 'color': color, + }) + return Response( + status_code=201, + headers=models.headers, + ) + + +@app.options('/api/v1/pixels') +async def function(): + return Response( + headers=models.headers, + ) diff --git a/config/__init__.py b/config/__init__.py new file mode 100644 index 0000000..fe4dbe0 --- /dev/null +++ b/config/__init__.py @@ -0,0 +1 @@ +from .main import Database diff --git a/config/main.py b/config/main.py new file mode 100644 index 0000000..7c680c4 --- /dev/null +++ b/config/main.py @@ -0,0 +1,28 @@ +from configparser import RawConfigParser + + +config = RawConfigParser() +config.read( + filenames='./config.ini', +) + + +class Database: + host = config.get( + section='Database', + option='host', + fallback='localhost', + ) + user = config.get( + section='Database', + option='user', + ) + password = config.get( + section='Database', + option='password', + fallback='', + ) + dbname = config.get( + section='Database', + option='dbname', + ) diff --git a/database.sql b/database.sql new file mode 100644 index 0000000..2781b9a --- /dev/null +++ b/database.sql @@ -0,0 +1,107 @@ +create table users ( + id bigserial not null, + ip_address cidr not null, + created date default now()::date not null, + primary key (id), + unique (ip_address) +); + +create table user_colors ( + user_id bigint not null, + color integer not null, + primary key (user_id, color), + foreign key (user_id) references users +); + +create table pixels ( + x smallint not null, + y smallint not null, + color integer not null, + user_id bigint not null, + last_update timestamp default now() not null, + primary key (x, y), + foreign key (user_id) references users +); + +create table history ( + id bigserial not null, + user_id bigint not null, + time timestamp default now() not null, + primary key (id), + foreign key (user_id) references users +); + +create table requests ( + id bigserial not null, + ip_address cidr not null, + method text not null, + url text not null, + is_secure bool not null, + user_agent character varying (4096), + referer character varying (2048), + time timestamp default now() not null, + primary key (id) +); + +create function pixels_history() returns trigger as $$ + begin + insert into history ( + user_id + ) values ( + new.user_id + ); + return new; + end; +$$ language plpgsql; + +create trigger pixels_history after insert or update on pixels + for each row execute procedure pixels_history(); + +create procedure put_pixel( + _x smallint, + _y smallint, + _color integer, + _ip_address cidr +) as $$ + declare + _user_id bigint; + begin + insert into users ( + ip_address + ) values ( + _ip_address + ) on conflict ( + ip_address + ) do nothing; + _user_id = ( + select + users.id + from + users + where + users.ip_address = _ip_address + ); + insert into pixels ( + x, + y, + color, + user_id, + last_update + ) values ( + _x, + _y, + _color, + _user_id, + default + ) + on conflict ( + x, + y + ) do update set + color = excluded.color, + user_id = excluded.user_id, + last_update = excluded.last_update; + end; +$$ language plpgsql; + +-- truncate pixels; diff --git a/database/__init__.py b/database/__init__.py new file mode 100644 index 0000000..00e789d --- /dev/null +++ b/database/__init__.py @@ -0,0 +1 @@ +from .main import get_pixels, put_pixel, get_requests_frequency, put_request diff --git a/database/main.py b/database/main.py new file mode 100644 index 0000000..addb618 --- /dev/null +++ b/database/main.py @@ -0,0 +1,204 @@ +import config +from models import User, Pixel, RequestsFrequency +from psycopg import AsyncConnection + + +conninfo = 'host=%(host)s user=%(user)s password=%(password)s dbname=%(dbname)s' % { + 'host': config.Database.host, + 'user': config.Database.user, + 'password': config.Database.password, + 'dbname': config.Database.dbname, +} + + +async def get_user( + ip_address: str, +) -> list[Pixel]: + async with await AsyncConnection.connect(conninfo) as connection: + async with connection.cursor() as cursor: + sql = ''' + select + users.id, + users.ip_address, + users.created, + count(history), + unnest(user_colors.color) + from + users + left outer join history on + users.id = history.user_id + left outer join user_colors on + users.id = user_colors.user_id + group by + users.id, + users.ip_address, + users.created; + ''' + await cursor.execute(sql) + records = await cursor.fetchall() + return list( + Pixel( + x=pixel_x, + y=pixel_y, + color=pixel_color, + user=User( + id=user_id, + ip_address=user_ip_address, + progress=user_progress, + created=user_created, + ), + last_update=pixel_last_update, + ) + for + pixel_x, + pixel_y, + pixel_color, + user_id, + user_ip_address, + user_progress, + user_created, + pixel_last_update, + in + records + ) + + +async def get_pixels() -> list[Pixel]: + async with await AsyncConnection.connect(conninfo) as connection: + async with connection.cursor() as cursor: + sql = ''' + select + pixels.x, + pixels.y, + pixels.color, + pixels.last_update + from + pixels; + ''' + await cursor.execute(sql) + records = await cursor.fetchall() + return list( + Pixel( + x=pixel_x, + y=pixel_y, + color=pixel_color, + last_update=pixel_last_update, + ) + for + pixel_x, + pixel_y, + pixel_color, + pixel_last_update, + in + records + ) + + +async def put_pixel( + x: int, + y: int, + color: int, + ip_address, +): + async with await AsyncConnection.connect(conninfo) as connection: + async with connection.cursor() as cursor: + sql = ''' + call put_pixel( + %(x)s, + %(y)s, + %(color)s, + %(ip_address)s + ); + ''' + await cursor.execute( + sql, + { + 'x': x, + 'y': y, + 'color': color, + 'ip_address': ip_address, + }, + ) + + +async def get_requests_frequency( + ip_address: str, +) -> RequestsFrequency: + async with await AsyncConnection.connect(conninfo) as connection: + async with connection.cursor() as cursor: + sql = ''' + select + count(requests_per_second), + count(requests_per_minute), + count(requests_per_hour), + count(requests_per_day) + from + requests + left join requests requests_per_second on + requests.id = requests_per_second.id and + requests_per_second.time > now() - '1 second'::interval + left join requests requests_per_minute on + requests.id = requests_per_minute.id and + requests_per_minute.time > now() - '1 minute'::interval + left join requests requests_per_hour on + requests.id = requests_per_hour.id and + requests_per_hour.time > now() - '1 hour'::interval + left join requests requests_per_day on + requests.id = requests_per_day.id and + requests_per_day.time > now() - '1 day'::interval + where + requests.ip_address = '91.195.204.120/32'::cidr; + ''' + await cursor.execute( + sql, + { + 'ip_address': ip_address, + }, + ) + requests_per_second, requests_per_minute, requests_per_hour, requests_per_day, = await cursor.fetchone() + return RequestsFrequency( + per_second=requests_per_second, + per_minute=requests_per_minute, + per_hour=requests_per_hour, + per_day=requests_per_day, + ) + + +async def put_request( + ip_address: str, + method: str, + url: str, + is_secure: bool, + user_agent: str = None, + referer: str = None, +): + async with await AsyncConnection.connect(conninfo) as connection: + async with connection.cursor() as cursor: + sql = ''' + insert into requests ( + ip_address, + method, + url, + is_secure, + user_agent, + referer + ) values ( + %(ip_address)s, + %(method)s, + %(url)s, + %(is_secure)s, + %(user_agent)s, + %(referer)s + ); + ''' + await cursor.execute( + sql, + { + 'ip_address': ip_address, + 'method': method, + 'url': url, + 'is_secure': is_secure, + 'user_agent': user_agent, + 'referer': referer, + }, + ) diff --git a/index.html b/index.html new file mode 100644 index 0000000..72eb4b4 --- /dev/null +++ b/index.html @@ -0,0 +1,27 @@ + + + + + Pixel Battle + + + + +
+ +
+ + + diff --git a/main.py b/main.py new file mode 100644 index 0000000..74929ed --- /dev/null +++ b/main.py @@ -0,0 +1,5 @@ +from app import app + + +if __name__ == '__main__': + app.run() diff --git a/models/__init__.py b/models/__init__.py new file mode 100644 index 0000000..357d4d1 --- /dev/null +++ b/models/__init__.py @@ -0,0 +1 @@ +from .main import area_width, area_height, pixel_size, headers, User, Pixel, Area, RequestsFrequency diff --git a/models/main.py b/models/main.py new file mode 100644 index 0000000..0a8b3c0 --- /dev/null +++ b/models/main.py @@ -0,0 +1,53 @@ +from pydantic import BaseModel, Field, IPvAnyNetwork +from datetime import datetime as DateTime, date as Date + + +area_width = 64 +area_height = 32 +pixel_size = 24 +headers = { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': '*', + 'Access-Control-Allow-Headers': '*', +} + + +class User(BaseModel): + id: int + ip_address: IPvAnyNetwork + created: Date + progress: int + colors: list[int] + + +class Pixel(BaseModel): + x: int = Field( + ..., + ge=0, + le=area_width - 1, + ) + y: int = Field( + ..., + ge=0, + le=area_height - 1, + ) + color: int = Field( + ..., + ge=0x000000, + le=0xFFFFFF, + ) + last_update: DateTime + + +class Area(BaseModel): + width: int = area_width + height: int = area_height + pixel_size: int = pixel_size + pixels: list[Pixel] + + +class RequestsFrequency(BaseModel): + per_second: int + per_minute: int + per_hour: int + per_day: int diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..a428487 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,6 @@ +fastapi +gunicorn +psycopg[binary] +pydantic +uvicorn +websockets diff --git a/static/scripts/main.js b/static/scripts/main.js new file mode 100644 index 0000000..7509db4 --- /dev/null +++ b/static/scripts/main.js @@ -0,0 +1,221 @@ +const apiURL = new URL('http://localhost:8000'); +const wsURL = new URL('ws://localhost:8000/ws/v1/pixels'); + +class ColorBar { + #colorPicker; + + constructor() { + this.#colorPicker = document.querySelector('#color-picker'); + } + + get hex() { + return this.#colorPicker.value.substring(1, 7); + }; + + get decimal() { + return parseInt(this.hex, 16); + }; +} + +class BattleField { + WIDTH$U = 64; + HEIGHT$U = 32; + PIXEL_SIZE$PX = 96; + BORDER_SIZE$PX = 4; + BACKGROUND_COLOR$HEX = '#FFFFFFFF'; + BORDER_COLOR$HEX = '#00000010'; + + #colorBar; + #main; + #canvas; + #context; + + constructor() { + this.#colorBar = new ColorBar(); + this.#main = document.querySelector('#main'); + this.#canvas = document.querySelector('#battlefield'); + this.#context = this.#canvas.getContext('2d'); + this.#canvas.width = this.WIDTH$U * this.PIXEL_SIZE$PX; + this.#canvas.height = this.HEIGHT$U * this.PIXEL_SIZE$PX; + + this.#main.onpointerdown = (event) => { + const movement = () => Math.sqrt(Math.pow(xd$px, 2) + Math.pow(yd$px, 2)) / window.innerWidth > .005; + + let x0$pct = event.pageX * 100 / window.innerWidth; + let y0$pct = event.pageY * 100 / window.innerHeight; + let x0$px = event.pageX; + let y0$px = event.pageY; + let xd$px = 0; + let yd$px = 0; + + this.#main.onpointermove = (event) => { + if (movement()) document.body.classList.add('grabbing'); + this.x += event.pageX * 100 / window.innerWidth - x0$pct; + this.y += event.pageY * 100 / window.innerHeight - y0$pct; + x0$pct = event.pageX * 100 / window.innerWidth; + y0$pct = event.pageY * 100 / window.innerHeight; + xd$px += Math.abs(x0$px - event.pageX); + yd$px += Math.abs(y0$px - event.pageY); + }; + + this.#main.onpointerup = () => { + this.#main.onpointermove = null; + this.#main.onpointerup = null; + document.body.classList.remove('grabbing'); + if (movement()) return; + const xhr = new XMLHttpRequest(); + const url = new URL(apiURL); + url.pathname = '/api/v1/pixels'; + xhr.open('PUT', url); + xhr.setRequestHeader('Content-Type', 'application/json; charset=UTF-8'); + xhr.send( + JSON.stringify({ + x: Math.floor((event.pageX - this.#canvas.offsetLeft) / this.scale / this.PIXEL_SIZE$PX + this.WIDTH$U / 2), + y: Math.floor((event.pageY - this.#canvas.offsetTop) / this.scale / this.PIXEL_SIZE$PX + this.HEIGHT$U / 2), + color: this.#colorBar.decimal, + }) + ); + }; + }; + + document.onwheel = (event) => { + if (event.deltaY > 0) this.scale += .1; + if (event.deltaY < 0) this.scale -= .1; + }; + + for (let x = 0; x < this.WIDTH$U; x++) { + for (let y = 0; y < this.HEIGHT$U; y++) { + this.setPixel( + x, + y, + this.BACKGROUND_COLOR$HEX, + ); + } + } + }; + + get x() { + return Number(this.#canvas.style.left.match(/^(.*)%$/)[1]); + }; + + set x(value) { + // const min$pct = -100 * this.scale; + // const max$pct = 100 * this.scale + 100; + // if (value < min$pct) { + // this.#canvas.style.left = `${min$pct}%`; + // return; + // } + // if (value > max$pct) { + // this.#canvas.style.left = `${max$pct}%`; + // return; + // } + this.#canvas.style.left = `${value}%`; + }; + + get y() { + return Number(this.#canvas.style.top.match(/^(.*)%$/)[1]); + }; + + set y(value) { + // const min$pct = -100 * this.scale; + // const max$pct = 100 * this.scale + 100; + // if (value < min$pct) { + // this.#canvas.style.top = `${min$pct}%`; + // return; + // } + // if (value > max$pct) { + // this.#canvas.style.top = `${max$pct}%`; + // return; + // } + this.#canvas.style.top = `${value}%`; + }; + + get scale() { + return Number(this.#canvas.style.scale); + }; + + set scale(value) { + if (value >= .2 && value <= 1) { + this.#canvas.style.scale = String(value); + + // TODO(костыль) + // this.x = this.x; + // this.y = this.y; + } + }; + + fill() { + const xhr = new XMLHttpRequest(); + const url = new URL(apiURL); + url.pathname = '/api/v1/pixels'; + xhr.onload = () => { + if (xhr.status !== 200) return; + const response = JSON.parse(xhr.response); + for (const pixel of response.pixels) { + this.setPixel( + pixel.x, + pixel.y, + decimalToHex(pixel.color), + ); + } + }; + xhr.open('GET', url); + xhr.send(); + }; + + setPixel(x, y, color) { + let leftPadding = x === 0 ? this.BORDER_SIZE$PX : 0; + let topPadding = y === 0 ? this.BORDER_SIZE$PX : 0; + this.#context.fillStyle = this.BACKGROUND_COLOR$HEX; + this.#context.fillRect( + x * this.PIXEL_SIZE$PX, + y * this.PIXEL_SIZE$PX, + this.PIXEL_SIZE$PX, + this.PIXEL_SIZE$PX, + ); + this.#context.fillStyle = this.BORDER_COLOR$HEX; + this.#context.fillRect( + x * this.PIXEL_SIZE$PX, + y * this.PIXEL_SIZE$PX, + this.PIXEL_SIZE$PX, + this.PIXEL_SIZE$PX, + ); + this.#context.fillStyle = color; + this.#context.fillRect( + x * this.PIXEL_SIZE$PX + leftPadding, + y * this.PIXEL_SIZE$PX + topPadding, + this.PIXEL_SIZE$PX - this.BORDER_SIZE$PX - leftPadding, + this.PIXEL_SIZE$PX - this.BORDER_SIZE$PX - topPadding, + ); + }; +} + +const decimalToHex = (decimal) => `#${decimal.toString(16)}`; + +window.addEventListener('DOMContentLoaded', () => { + const battlefield = new BattleField(); + battlefield.fill(); + + const wsConnect = () => { + const ws = new WebSocket(wsURL); + + ws.onmessage = (event) => { + const pixel = JSON.parse(event.data); + battlefield.setPixel( + pixel.x, + pixel.y, + decimalToHex(pixel.color), + ); + }; + + ws.onclose = (event) => { + setTimeout(wsConnect, 5000); + } + + ws.onerror = () => { + ws.close(); + }; + }; + + wsConnect(); +}); diff --git a/static/styles/main.css b/static/styles/main.css new file mode 100644 index 0000000..429ceee --- /dev/null +++ b/static/styles/main.css @@ -0,0 +1,54 @@ +html { + overflow: hidden; +} + +main { + position: fixed; + top: 0; + right: 0; + bottom: 0; + left: 0; +} + +#battlefield { + position: fixed; + translate: -50% -50%; + pointer-events: none; + transition: scale .2s; +} + +.grabbing { + cursor: grabbing !important; +} + +#color-bar { + position: fixed; + right: 1em; + bottom: 1em; + left: 1em; + display: flex; + align-items: center; + padding: 1em; + border-radius: 1em; + background-color: transparent; +} + +#color-picker { + position: absolute; + visibility: hidden; +} + +label[for="color-picker"] { + width: 2em; + height: 2em; + border-radius: .5em; + cursor: pointer; +} + +.color { + margin-left: .5em; + width: 2em; + height: 2em; + border-radius: .5em; + cursor: pointer; +}