Разработан веб-сервер и игровое поле

Этот коммит содержится в:
Глеб Иваницкий 2024-07-26 02:13:03 +03:00
Коммит 55abdb2867
14 изменённых файлов: 860 добавлений и 0 удалений

1
app/__init__.py Обычный файл
Просмотреть файл

@ -0,0 +1 @@
from .main import app

151
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,
)

1
config/__init__.py Обычный файл
Просмотреть файл

@ -0,0 +1 @@
from .main import Database

28
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',
)

107
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;

1
database/__init__.py Обычный файл
Просмотреть файл

@ -0,0 +1 @@
from .main import get_pixels, put_pixel, get_requests_frequency, put_request

204
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,
},
)

27
index.html Обычный файл
Просмотреть файл

@ -0,0 +1,27 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<title>Pixel Battle</title>
<link rel="stylesheet" href="/static/styles/main.css" />
<script src="/static/scripts/main.js"></script>
</head>
<body>
<main id="main">
<canvas id="battlefield" style="left: 50%; top: 50%; scale: .25;"></canvas>
</main>
<aside id="color-bar">
<label for="color-picker">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path d="M19.35,11.72L17.22,13.85L15.81,12.43L8.1,20.14L3.5,22L2,20.5L3.86,15.9L11.57,8.19L10.15,6.78L12.28,4.65L19.35,11.72M16.76,3C17.93,1.83 19.83,1.83 21,3C22.17,4.17 22.17,6.07 21,7.24L19.08,9.16L14.84,4.92L16.76,3M5.56,17.03L4.5,19.5L6.97,18.44L14.4,11L13,9.6L5.56,17.03Z" fill="black"></path>
</svg>
</label>
<input id="color-picker" type="color" />
<div class="color"></div>
<div class="color"></div>
<div class="color"></div>
<div class="color"></div>
<div class="color"></div>
</aside>
</body>
</html>

5
main.py Обычный файл
Просмотреть файл

@ -0,0 +1,5 @@
from app import app
if __name__ == '__main__':
app.run()

1
models/__init__.py Обычный файл
Просмотреть файл

@ -0,0 +1 @@
from .main import area_width, area_height, pixel_size, headers, User, Pixel, Area, RequestsFrequency

53
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

6
requirements.txt Обычный файл
Просмотреть файл

@ -0,0 +1,6 @@
fastapi
gunicorn
psycopg[binary]
pydantic
uvicorn
websockets

221
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();
});

54
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;
}