diff --git a/optional-requirements.txt b/optional-requirements.txt index ac71e85..a5b5d87 100644 --- a/optional-requirements.txt +++ b/optional-requirements.txt @@ -8,3 +8,4 @@ attrs setuptools aiodns ruamel.yaml +jsonschema diff --git a/setup.py b/setup.py index 6e8cb41..c25d48d 100644 --- a/setup.py +++ b/setup.py @@ -71,5 +71,5 @@ setuptools.setup( "frontend/index.html", "frontend/setup/index.html", "frontend/src/*", "frontend/lib/*/*.js", "frontend/res/*", "frontend/style/*.css", - ]} + ], "sticker.server.api": ["pack.schema.json"]} ) diff --git a/sticker/server/api/errors.py b/sticker/server/api/errors.py index f84df57..68b5d5a 100644 --- a/sticker/server/api/errors.py +++ b/sticker/server/api/errors.py @@ -13,7 +13,8 @@ # # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . -from typing import Dict +from typing import Dict, Optional +from collections import deque import json from aiohttp import web @@ -99,6 +100,14 @@ class _ErrorMeta: return web.HTTPNotFound(**self._make_error("NET.MAUNIUM_PACK_NOT_FOUND", "Sticker pack not found")) + def schema_error(self, message: str, path: Optional[deque] = None) -> web.HTTPException: + if path: + path_str = "in " + " → ".join(str(part) for part in path) + else: + path_str = "at top level" + return web.HTTPBadRequest(**self._make_error( + "M_BAD_REQUEST", f"Schema validation error {path_str}: {message}")) + @property def client_well_known_error(self) -> web.HTTPException: return web.HTTPForbidden(**self._make_error("NET.MAUNIUM_CLIENT_WELL_KNOWN_ERROR", diff --git a/sticker/server/api/pack.schema.json b/sticker/server/api/pack.schema.json new file mode 100644 index 0000000..ccb45c8 --- /dev/null +++ b/sticker/server/api/pack.schema.json @@ -0,0 +1,126 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "description": "A sticker pack compatible with maunium-stickerpicker", + "properties": { + "id": { + "type": "string", + "description": "An unique identifier for the sticker pack", + "readOnly": true + }, + "title": { + "type": "string", + "description": "The title of the sticker pack" + }, + "stickers": { + "type": "array", + "description": "The stickers in the pack", + "items": { + "type": "object", + "description": "A single sticker", + "properties": { + "id": { + "type": "string", + "description": "An unique identifier for the sticker" + }, + "url": { + "type": "string", + "description": "The Matrix content URI to the sticker", + "pattern": "mxc://.+?/.+" + }, + "body": { + "type": "string", + "description": "The description text for the sticker" + }, + "info": { + "type": "object", + "description": "Matrix media info", + "properties": { + "w": { + "type": "integer", + "description": "The intended display width of the sticker" + }, + "h": { + "type": "integer", + "description": "The intended display height of the sticker" + }, + "size": { + "type": "integer", + "description": "The size of the sticker image in bytes" + }, + "mimetype": { + "type": "string", + "description": "The mime type of the sticker image" + } + }, + "additionalProperties": true, + "required": [ + "w", + "h", + "size", + "mimetype" + ] + }, + "net.maunium.telegram.sticker": { + "type": "object", + "description": "Telegram metadata about the sticker", + "properties": { + "pack": { + "type": "string", + "description": "Information about the pack the sticker is in", + "properties": { + "id": { + "type": "string", + "description": "The ID of the sticker pack" + }, + "short_name": { + "type": "string", + "description": "The short name of the Telegram sticker pack from t.me/addstickers/" + } + } + }, + "id": { + "type": "string", + "description": "The ID of the sticker document" + }, + "emoticons": { + "type": "array", + "description": "Emojis that are associated with the sticker", + "items": { + "type": "string", + "description": "A single unicode emoji" + } + } + } + } + }, + "required": [ + "id", + "url", + "body", + "info" + ], + "additionalProperties": true + } + }, + "net.maunium.telegram.pack": { + "type": "object", + "description": "Telegram metadata about the pack", + "properties": { + "short_name": { + "type": "string", + "description": "The short name of the Telegram sticker pack from t.me/addstickers/" + }, + "hash": { + "type": "string", + "description": "The Telegram-specified hash of the stickerpack that can be used to quickly check if it has changed" + } + } + } + }, + "additionalProperties": true, + "required": [ + "title", + "stickers" + ] +} diff --git a/sticker/server/api/setup.py b/sticker/server/api/setup.py index 3a60001..dcb38bc 100644 --- a/sticker/server/api/setup.py +++ b/sticker/server/api/setup.py @@ -13,12 +13,22 @@ # # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . +from typing import Any +import random +import string +import json + from aiohttp import web +from pkg_resources import resource_stream +import jsonschema -from ..database import User, AccessToken +from ..database import User, AccessToken, Pack, Sticker +from .errors import Error routes = web.RouteTableDef() +pack_schema = json.load(resource_stream("sticker.server.api", "pack.schema.json")) + @routes.get("/whoami") async def whoami(req: web.Request) -> web.Response: @@ -30,3 +40,68 @@ async def whoami(req: web.Request) -> web.Response: "homeserver_url": user.homeserver_url, "last_seen": int(token.last_seen_date.timestamp() / 60) * 60, }) + + +@routes.get("/packs") +async def packs(req: web.Request) -> web.Response: + user: User = req["user"] + packs = await user.get_packs() + return web.json_response([pack.to_dict() for pack in packs]) + + +async def get_json(req: web.Request, schema: str) -> Any: + try: + data = await req.json() + except json.JSONDecodeError: + raise Error.request_not_json + try: + jsonschema.validate(data, schema) + except jsonschema.ValidationError as e: + raise Error.schema_error(e.message, e.path) + return data + + +@routes.post("/packs/create") +async def upload_pack(req: web.Request) -> web.Response: + data = await get_json(req, pack_schema) + user: User = req["user"] + title = data.pop("title") + raw_stickers = data.pop("stickers") + pack_id_suffix = data.pop("id", "".join(random.choices(string.ascii_lowercase, k=12))) + pack = Pack(id=f"{user.id}_{pack_id_suffix}", owner=user.id, title=title, meta=data) + stickers = [Sticker(pack_id=pack.id, id=sticker.pop("id"), url=sticker.pop("url"), + body=sticker.pop("body"), meta=sticker) for sticker in raw_stickers] + await pack.insert() + await pack.set_stickers(stickers) + await user.add_pack(pack) + + return web.json_response({ + **pack.to_dict(), + "stickers": [sticker.to_dict() for sticker in stickers], + }) + + +@routes.get("/pack/{pack_id}") +async def get_pack(req: web.Request) -> web.Response: + user: User = req["user"] + pack = await user.get_pack(req.match_info["pack_id"]) + if pack is None: + raise Error.pack_not_found + return web.json_response({ + **pack.to_dict(), + "stickers": [sticker.to_dict() for sticker in await pack.get_stickers()], + }) + + +@routes.delete("/pack/{pack_id}") +async def delete_pack(req: web.Request) -> web.Response: + user: User = req["user"] + pack = await user.get_pack(req.match_info["pack_id"]) + if pack is None: + raise Error.pack_not_found + + if pack.owner != user.id: + await user.remove_pack(pack) + else: + await pack.delete() + return web.Response(status=204) diff --git a/sticker/server/database/pack.py b/sticker/server/database/pack.py index 3c2e530..4ac0847 100644 --- a/sticker/server/database/pack.py +++ b/sticker/server/database/pack.py @@ -14,6 +14,7 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . from typing import List, Dict, Any +import json from attr import dataclass @@ -35,15 +36,20 @@ class Pack(Base): async def insert(self) -> None: await self.db.execute("INSERT INTO pack (id, owner, title, meta) VALUES ($1, $2, $3, $4)", - self.id, self.owner, self.title, self.meta) + self.id, self.owner, self.title, json.dumps(self.meta)) + + @classmethod + def from_data(cls, **data: Any) -> 'Pack': + meta = json.loads(data.pop("meta")) + return cls(**data, meta=meta) async def get_stickers(self) -> List[Sticker]: res = await self.db.fetch('SELECT id, url, body, meta, "order" ' 'FROM sticker WHERE pack_id=$1 ORDER BY "order"', self.id) - return [Sticker(**row, pack_id=self.id) for row in res] + return [Sticker.from_data(**row, pack_id=self.id) for row in res] async def set_stickers(self, stickers: List[Sticker]) -> None: - data = ((sticker.id, self.id, sticker.url, sticker.body, sticker.meta, order) + data = ((sticker.id, self.id, sticker.url, sticker.body, json.dumps(sticker.meta), order) for order, sticker in enumerate(stickers)) columns = ["id", "pack_id", "url", "body", "meta", "order"] async with self.db.acquire() as conn, conn.transaction(): diff --git a/sticker/server/database/sticker.py b/sticker/server/database/sticker.py index 03aa21c..356935c 100644 --- a/sticker/server/database/sticker.py +++ b/sticker/server/database/sticker.py @@ -14,6 +14,7 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . from typing import Dict, Any +import json from attr import dataclass import attr @@ -26,20 +27,12 @@ from .base import Base @dataclass(kw_only=True) class Sticker(Base): pack_id: str - order: int + order: int = 0 id: str url: ContentURI = attr.ib(order=False) body: str = attr.ib(order=False) meta: Dict[str, Any] = attr.ib(order=False) - async def delete(self) -> None: - await self.db.execute("DELETE FROM sticker WHERE id=$1", self.id) - - async def insert(self) -> None: - await self.db.execute('INSERT INTO sticker (id, pack_id, url, body, meta, "order") ' - "VALUES ($1, $2, $3, $4, $5, $6)", - self.id, self.pack_id, self.url, self.body, self.meta, self.order) - def to_dict(self) -> Dict[str, Any]: return { **self.meta, @@ -47,3 +40,8 @@ class Sticker(Base): "url": self.url, "id": self.id, } + + @classmethod + def from_data(cls, **data: Any) -> 'Sticker': + meta = json.loads(data.pop("meta")) + return cls(**data, meta=meta) diff --git a/sticker/server/database/upgrade.py b/sticker/server/database/upgrade.py index b2c98be..c191e25 100644 --- a/sticker/server/database/upgrade.py +++ b/sticker/server/database/upgrade.py @@ -47,10 +47,11 @@ async def upgrade_v1(conn: Connection) -> None: PRIMARY KEY (user_id, pack_id) )""") await conn.execute("""CREATE TABLE sticker ( - id TEXT PRIMARY KEY, - pack_id TEXT NOT NULL REFERENCES pack(id) ON DELETE CASCADE, + id TEXT, + pack_id TEXT REFERENCES pack(id) ON DELETE CASCADE, url TEXT NOT NULL, body TEXT NOT NULL, meta JSONB NOT NULL, - "order" INT NOT NULL DEFAULT 0 + "order" INT NOT NULL DEFAULT 0, + PRIMARY KEY (id, pack_id) )""") diff --git a/sticker/server/database/user.py b/sticker/server/database/user.py index 138efda..552c943 100644 --- a/sticker/server/database/user.py +++ b/sticker/server/database/user.py @@ -16,6 +16,7 @@ from typing import Optional, List, ClassVar import random import string +import time from attr import dataclass import asyncpg @@ -76,7 +77,7 @@ class User(Base): res = await self.db.fetch("SELECT id, owner, title, meta FROM user_pack " "LEFT JOIN pack ON pack.id=user_pack.pack_id " 'WHERE user_id=$1 ORDER BY "order"', self.id) - return [Pack(**row) for row in res] + return [Pack.from_data(**row) for row in res] async def get_pack(self, pack_id: str) -> Optional[Pack]: row = await self.db.fetchrow("SELECT id, owner, title, meta FROM user_pack " @@ -84,7 +85,7 @@ class User(Base): "WHERE user_id=$1 AND pack_id=$2", self.id, pack_id) if row is None: return None - return Pack(**row) + return Pack.from_data(**row) async def set_packs(self, packs: List[Pack]) -> None: data = ((self.id, pack.id, order) @@ -93,3 +94,11 @@ class User(Base): async with self.db.acquire() as conn, conn.transaction(): await conn.execute("DELETE FROM user_pack WHERE user_id=$1", self.id) await conn.copy_records_to_table("user_pack", records=data, columns=columns) + + async def add_pack(self, pack: Pack) -> None: + q = 'INSERT INTO user_pack (user_id, pack_id, "order") VALUES ($1, $2, $3)' + await self.db.execute(q, self.id, pack.id, int(time.time())) + + async def remove_pack(self, pack: Pack) -> None: + q = "DELETE FROM user_pack WHERE user_id=$1 AND pack_id=$2" + await self.db.execute(q, self.id, pack.id)