Make some basic pack creating endpoints work

This commit is contained in:
Tulir Asokan 2020-11-01 15:21:43 +02:00
parent 0b15a44820
commit 12e1cb265d
9 changed files with 246 additions and 21 deletions

View File

@ -8,3 +8,4 @@ attrs
setuptools setuptools
aiodns aiodns
ruamel.yaml ruamel.yaml
jsonschema

View File

@ -71,5 +71,5 @@ setuptools.setup(
"frontend/index.html", "frontend/setup/index.html", "frontend/index.html", "frontend/setup/index.html",
"frontend/src/*", "frontend/lib/*/*.js", "frontend/res/*", "frontend/style/*.css", "frontend/src/*", "frontend/lib/*/*.js", "frontend/res/*", "frontend/style/*.css",
]} ], "sticker.server.api": ["pack.schema.json"]}
) )

View File

@ -13,7 +13,8 @@
# #
# You should have received a copy of the GNU Affero General Public License # You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>. # along with this program. If not, see <https://www.gnu.org/licenses/>.
from typing import Dict from typing import Dict, Optional
from collections import deque
import json import json
from aiohttp import web from aiohttp import web
@ -99,6 +100,14 @@ class _ErrorMeta:
return web.HTTPNotFound(**self._make_error("NET.MAUNIUM_PACK_NOT_FOUND", return web.HTTPNotFound(**self._make_error("NET.MAUNIUM_PACK_NOT_FOUND",
"Sticker 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 @property
def client_well_known_error(self) -> web.HTTPException: def client_well_known_error(self) -> web.HTTPException:
return web.HTTPForbidden(**self._make_error("NET.MAUNIUM_CLIENT_WELL_KNOWN_ERROR", return web.HTTPForbidden(**self._make_error("NET.MAUNIUM_CLIENT_WELL_KNOWN_ERROR",

View File

@ -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/<shortname>"
}
}
},
"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/<shortname>"
},
"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"
]
}

View File

@ -13,12 +13,22 @@
# #
# You should have received a copy of the GNU Affero General Public License # You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>. # along with this program. If not, see <https://www.gnu.org/licenses/>.
from aiohttp import web from typing import Any
import random
import string
import json
from ..database import User, AccessToken from aiohttp import web
from pkg_resources import resource_stream
import jsonschema
from ..database import User, AccessToken, Pack, Sticker
from .errors import Error
routes = web.RouteTableDef() routes = web.RouteTableDef()
pack_schema = json.load(resource_stream("sticker.server.api", "pack.schema.json"))
@routes.get("/whoami") @routes.get("/whoami")
async def whoami(req: web.Request) -> web.Response: 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, "homeserver_url": user.homeserver_url,
"last_seen": int(token.last_seen_date.timestamp() / 60) * 60, "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)

View File

@ -14,6 +14,7 @@
# You should have received a copy of the GNU Affero General Public License # You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>. # along with this program. If not, see <https://www.gnu.org/licenses/>.
from typing import List, Dict, Any from typing import List, Dict, Any
import json
from attr import dataclass from attr import dataclass
@ -35,15 +36,20 @@ class Pack(Base):
async def insert(self) -> None: async def insert(self) -> None:
await self.db.execute("INSERT INTO pack (id, owner, title, meta) VALUES ($1, $2, $3, $4)", 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]: async def get_stickers(self) -> List[Sticker]:
res = await self.db.fetch('SELECT id, url, body, meta, "order" ' res = await self.db.fetch('SELECT id, url, body, meta, "order" '
'FROM sticker WHERE pack_id=$1 ORDER BY "order"', self.id) '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: 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)) for order, sticker in enumerate(stickers))
columns = ["id", "pack_id", "url", "body", "meta", "order"] columns = ["id", "pack_id", "url", "body", "meta", "order"]
async with self.db.acquire() as conn, conn.transaction(): async with self.db.acquire() as conn, conn.transaction():

View File

@ -14,6 +14,7 @@
# You should have received a copy of the GNU Affero General Public License # You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>. # along with this program. If not, see <https://www.gnu.org/licenses/>.
from typing import Dict, Any from typing import Dict, Any
import json
from attr import dataclass from attr import dataclass
import attr import attr
@ -26,20 +27,12 @@ from .base import Base
@dataclass(kw_only=True) @dataclass(kw_only=True)
class Sticker(Base): class Sticker(Base):
pack_id: str pack_id: str
order: int order: int = 0
id: str id: str
url: ContentURI = attr.ib(order=False) url: ContentURI = attr.ib(order=False)
body: str = attr.ib(order=False) body: str = attr.ib(order=False)
meta: Dict[str, Any] = 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]: def to_dict(self) -> Dict[str, Any]:
return { return {
**self.meta, **self.meta,
@ -47,3 +40,8 @@ class Sticker(Base):
"url": self.url, "url": self.url,
"id": self.id, "id": self.id,
} }
@classmethod
def from_data(cls, **data: Any) -> 'Sticker':
meta = json.loads(data.pop("meta"))
return cls(**data, meta=meta)

View File

@ -47,10 +47,11 @@ async def upgrade_v1(conn: Connection) -> None:
PRIMARY KEY (user_id, pack_id) PRIMARY KEY (user_id, pack_id)
)""") )""")
await conn.execute("""CREATE TABLE sticker ( await conn.execute("""CREATE TABLE sticker (
id TEXT PRIMARY KEY, id TEXT,
pack_id TEXT NOT NULL REFERENCES pack(id) ON DELETE CASCADE, pack_id TEXT REFERENCES pack(id) ON DELETE CASCADE,
url TEXT NOT NULL, url TEXT NOT NULL,
body TEXT NOT NULL, body TEXT NOT NULL,
meta JSONB NOT NULL, meta JSONB NOT NULL,
"order" INT NOT NULL DEFAULT 0 "order" INT NOT NULL DEFAULT 0,
PRIMARY KEY (id, pack_id)
)""") )""")

View File

@ -16,6 +16,7 @@
from typing import Optional, List, ClassVar from typing import Optional, List, ClassVar
import random import random
import string import string
import time
from attr import dataclass from attr import dataclass
import asyncpg import asyncpg
@ -76,7 +77,7 @@ class User(Base):
res = await self.db.fetch("SELECT id, owner, title, meta FROM user_pack " res = await self.db.fetch("SELECT id, owner, title, meta FROM user_pack "
"LEFT JOIN pack ON pack.id=user_pack.pack_id " "LEFT JOIN pack ON pack.id=user_pack.pack_id "
'WHERE user_id=$1 ORDER BY "order"', self.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]: async def get_pack(self, pack_id: str) -> Optional[Pack]:
row = await self.db.fetchrow("SELECT id, owner, title, meta FROM user_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) "WHERE user_id=$1 AND pack_id=$2", self.id, pack_id)
if row is None: if row is None:
return None return None
return Pack(**row) return Pack.from_data(**row)
async def set_packs(self, packs: List[Pack]) -> None: async def set_packs(self, packs: List[Pack]) -> None:
data = ((self.id, pack.id, order) data = ((self.id, pack.id, order)
@ -93,3 +94,11 @@ class User(Base):
async with self.db.acquire() as conn, conn.transaction(): async with self.db.acquire() as conn, conn.transaction():
await conn.execute("DELETE FROM user_pack WHERE user_id=$1", self.id) 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) 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)