From d8fb77ecce6da784dc64190c0f48d11c54657a4d Mon Sep 17 00:00:00 2001 From: misodengaku Date: Mon, 6 Nov 2023 17:45:08 +0900 Subject: [PATCH] python: Initial implementation --- .github/workflows/python.yml | 88 ++ development/docker-compose-python.yml | 27 + webapp/python/Dockerfile | 33 + webapp/python/Pipfile | 20 + webapp/python/Pipfile.lock | 422 +++++++ webapp/python/app.py | 1688 +++++++++++++++++++++++++ webapp/python/models.py | 196 +++ 7 files changed, 2474 insertions(+) create mode 100644 .github/workflows/python.yml create mode 100644 development/docker-compose-python.yml create mode 100644 webapp/python/Dockerfile create mode 100644 webapp/python/Pipfile create mode 100644 webapp/python/Pipfile.lock create mode 100644 webapp/python/app.py create mode 100644 webapp/python/models.py diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml new file mode 100644 index 000000000..8df4f57cb --- /dev/null +++ b/.github/workflows/python.yml @@ -0,0 +1,88 @@ +name: Python CI +on: + push: + branches: [main] + paths: + - bench/**/* + - webapp/python/**/* + - webapp/sql/**/* + - webapp/pdns/**/* + - development/docker-compose-common.yml + - development/docker-compose-python.yml + - development/Makefile + - .github/workflows/python.yml + pull_request: + paths: + - bench/**/* + - webapp/python/**/* + - webapp/sql/**/* + - webapp/pdns/**/* + - development/docker-compose-common.yml + - development/docker-compose-python.yml + - development/Makefile + - .github/workflows/python.yml + workflow_dispatch: +jobs: + test: + strategy: + matrix: + go: + - 1.21.1 + name: Build + runs-on: [isucon13-ci-06] + steps: + - name: Setup Go + uses: actions/setup-go@v4 + with: + go-version: ${{ matrix.go }} + id: go + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + + - name: Setup corepack + run: corepack enable yarn + + # to avoid error: Deleting the contents of '/home/ubuntu/actions-runner/_work/isucon13/isucon13' + # Error: File was unable to be removed Error: EACCES: permission denied, rmdir + # https://github.com/actions/checkout/issues/211 + - name: chown workdir + run: sudo chown -R $USER:$USER $GITHUB_WORKSPACE + + - name: Check out code into the Go module directory + uses: actions/checkout@v3 + + # containers + - name: "setup containers" + working-directory: ./development + run: | + make down/python + make up/python + + - name: "[frontend] build" + working-directory: ./frontend + run: | + make + + # bench + - name: "[bench] Get deps" + working-directory: ./bench + env: + TZ: Asia/Tokyo + run: | + go get -v -t -d ./... + + - name: "[bench] Test" + working-directory: ./bench + env: + TZ: Asia/Tokyo + run: | + go clean -testcache + go test -p=1 -v ./... + + - name: "run bench" + working-directory: ./bench + run: | + make bench diff --git a/development/docker-compose-python.yml b/development/docker-compose-python.yml new file mode 100644 index 000000000..251893db7 --- /dev/null +++ b/development/docker-compose-python.yml @@ -0,0 +1,27 @@ +version: "3.0" + +services: + webapp: + cpus: 2 + mem_limit: 4g + build: + context: ../webapp/python + init: true + working_dir: /home/isucon/webapp/python + container_name: webapp + volumes: + - ../webapp/sql:/home/isucon/webapp/sql + - ../webapp/pdns:/home/isucon/webapp/pdns + - ../provisioning/ansible/roles/powerdns/files/pdns.conf:/etc/powerdns/pdns.conf:ro + - ../provisioning/ansible/roles/powerdns/files/pdns.d/docker.conf:/etc/powerdns/pdns.d/docker.conf:ro + - ../webapp/img:/home/isucon/webapp/img + environment: + ISUCON13_MYSQL_DIALCONFIG_ADDRESS: mysql + ISUCON13_POWERDNS_HOST: powerdns + ISUCON13_POWERDNS_SUBDOMAIN_ADDRESS: 127.0.0.1 + ISUCON13_POWERDNS_DISABLED: false + ports: + - "127.0.0.1:8080:8080" + depends_on: + mysql: + condition: service_healthy diff --git a/webapp/python/Dockerfile b/webapp/python/Dockerfile new file mode 100644 index 000000000..64271522f --- /dev/null +++ b/webapp/python/Dockerfile @@ -0,0 +1,33 @@ +FROM python:3.12-bookworm + +WORKDIR /tmp +ENV DEBIAN_FRONTEND=noninteractive +RUN apt-get update && \ + apt-get -y upgrade && \ + apt-get install -y curl wget gcc g++ make sqlite3 locales locales-all && \ + wget -q https://dev.mysql.com/get/mysql-apt-config_0.8.22-1_all.deb && \ + apt-get -y install ./mysql-apt-config_0.8.22-1_all.deb && \ + apt-get -y update && \ + apt-get -y install default-mysql-client pdns-server pdns-backend-mysql && \ + pip install pipenv +RUN locale-gen en_US.UTF-8 +RUN useradd --uid=1001 --create-home isucon +USER isucon + +RUN mkdir -p /home/isucon/webapp/python +WORKDIR /home/isucon/webapp/python +COPY --chown=isucon:isucon Pipfile /home/isucon/webapp/python/ +COPY --chown=isucon:isucon Pipfile.lock /home/isucon/webapp/python/ +RUN pipenv install +COPY --chown=isucon:isucon models.py /home/isucon/webapp/python/ +COPY --chown=isucon:isucon app.py /home/isucon/webapp/python/ + +# ENV GOPATH=/home/isucon/tmp/go +# ENV GOCACHE=/home/isucon/tmp/go/.cache + +ENV LANG en_US.UTF-8 +ENV LANGUAGE en_US:en +ENV LC_ALL en_US.UTF-8 + +EXPOSE 8080 +CMD ["pipenv", "run", "python", "app.py"] diff --git a/webapp/python/Pipfile b/webapp/python/Pipfile new file mode 100644 index 000000000..dd30e4533 --- /dev/null +++ b/webapp/python/Pipfile @@ -0,0 +1,20 @@ +[[source]] +url = "https://pypi.org/simple" +verify_ssl = true +name = "pypi" + +[packages] +flask = "*" +pymysql = "*" +bcrypt = "*" +sqlalchemy = "<2.0.0" +mysql-connector-python = "*" + +[dev-packages] +flake8 = "*" +isort = "*" +black = "*" + +[requires] +python_version = "3.12" +python_full_version = "3.12.0" diff --git a/webapp/python/Pipfile.lock b/webapp/python/Pipfile.lock new file mode 100644 index 000000000..4b235605d --- /dev/null +++ b/webapp/python/Pipfile.lock @@ -0,0 +1,422 @@ +{ + "_meta": { + "hash": { + "sha256": "ab763d0385539a1e5e756bf133aa3e1459cefd7d9f354cb74e0184d3c1ef5a86" + }, + "pipfile-spec": 6, + "requires": { + "python_full_version": "3.12.0", + "python_version": "3.12" + }, + "sources": [ + { + "name": "pypi", + "url": "https://pypi.org/simple", + "verify_ssl": true + } + ] + }, + "default": { + "bcrypt": { + "hashes": [ + "sha256:089098effa1bc35dc055366740a067a2fc76987e8ec75349eb9484061c54f535", + "sha256:08d2947c490093a11416df18043c27abe3921558d2c03e2076ccb28a116cb6d0", + "sha256:0eaa47d4661c326bfc9d08d16debbc4edf78778e6aaba29c1bc7ce67214d4410", + "sha256:27d375903ac8261cfe4047f6709d16f7d18d39b1ec92aaf72af989552a650ebd", + "sha256:2b3ac11cf45161628f1f3733263e63194f22664bf4d0c0f3ab34099c02134665", + "sha256:2caffdae059e06ac23fce178d31b4a702f2a3264c20bfb5ff541b338194d8fab", + "sha256:3100851841186c25f127731b9fa11909ab7b1df6fc4b9f8353f4f1fd952fbf71", + "sha256:5ad4d32a28b80c5fa6671ccfb43676e8c1cc232887759d1cd7b6f56ea4355215", + "sha256:67a97e1c405b24f19d08890e7ae0c4f7ce1e56a712a016746c8b2d7732d65d4b", + "sha256:705b2cea8a9ed3d55b4491887ceadb0106acf7c6387699fca771af56b1cdeeda", + "sha256:8a68f4341daf7522fe8d73874de8906f3a339048ba406be6ddc1b3ccb16fc0d9", + "sha256:a522427293d77e1c29e303fc282e2d71864579527a04ddcfda6d4f8396c6c36a", + "sha256:ae88eca3024bb34bb3430f964beab71226e761f51b912de5133470b649d82344", + "sha256:b1023030aec778185a6c16cf70f359cbb6e0c289fd564a7cfa29e727a1c38f8f", + "sha256:b3b85202d95dd568efcb35b53936c5e3b3600c7cdcc6115ba461df3a8e89f38d", + "sha256:b57adba8a1444faf784394de3436233728a1ecaeb6e07e8c22c8848f179b893c", + "sha256:bf4fa8b2ca74381bb5442c089350f09a3f17797829d958fad058d6e44d9eb83c", + "sha256:ca3204d00d3cb2dfed07f2d74a25f12fc12f73e606fcaa6975d1f7ae69cacbb2", + "sha256:cbb03eec97496166b704ed663a53680ab57c5084b2fc98ef23291987b525cb7d", + "sha256:e9a51bbfe7e9802b5f3508687758b564069ba937748ad7b9e890086290d2f79e", + "sha256:fbdaec13c5105f0c4e5c52614d04f0bca5f5af007910daa8b6b12095edaa67b3" + ], + "index": "pypi", + "version": "==4.0.1" + }, + "blinker": { + "hashes": [ + "sha256:c3f865d4d54db7abc53758a01601cf343fe55b84c1de4e3fa910e420b438d5b9", + "sha256:e6820ff6fa4e4d1d8e2747c2283749c3f547e4fee112b98555cdcdae32996182" + ], + "markers": "python_version >= '3.8'", + "version": "==1.7.0" + }, + "click": { + "hashes": [ + "sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28", + "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de" + ], + "markers": "python_version >= '3.7'", + "version": "==8.1.7" + }, + "flask": { + "hashes": [ + "sha256:21128f47e4e3b9d597a3e8521a329bf56909b690fcc3fa3e477725aa81367638", + "sha256:cfadcdb638b609361d29ec22360d6070a77d7463dcb3ab08d2c2f2f168845f58" + ], + "index": "pypi", + "version": "==3.0.0" + }, + "greenlet": { + "hashes": [ + "sha256:0a02d259510b3630f330c86557331a3b0e0c79dac3d166e449a39363beaae174", + "sha256:0b6f9f8ca7093fd4433472fd99b5650f8a26dcd8ba410e14094c1e44cd3ceddd", + "sha256:100f78a29707ca1525ea47388cec8a049405147719f47ebf3895e7509c6446aa", + "sha256:1757936efea16e3f03db20efd0cd50a1c86b06734f9f7338a90c4ba85ec2ad5a", + "sha256:19075157a10055759066854a973b3d1325d964d498a805bb68a1f9af4aaef8ec", + "sha256:19bbdf1cce0346ef7341705d71e2ecf6f41a35c311137f29b8a2dc2341374565", + "sha256:20107edf7c2c3644c67c12205dc60b1bb11d26b2610b276f97d666110d1b511d", + "sha256:22f79120a24aeeae2b4471c711dcf4f8c736a2bb2fabad2a67ac9a55ea72523c", + "sha256:2847e5d7beedb8d614186962c3d774d40d3374d580d2cbdab7f184580a39d234", + "sha256:28e89e232c7593d33cac35425b58950789962011cc274aa43ef8865f2e11f46d", + "sha256:329c5a2e5a0ee942f2992c5e3ff40be03e75f745f48847f118a3cfece7a28546", + "sha256:337322096d92808f76ad26061a8f5fccb22b0809bea39212cd6c406f6a7060d2", + "sha256:3fcc780ae8edbb1d050d920ab44790201f027d59fdbd21362340a85c79066a74", + "sha256:41bdeeb552d814bcd7fb52172b304898a35818107cc8778b5101423c9017b3de", + "sha256:4eddd98afc726f8aee1948858aed9e6feeb1758889dfd869072d4465973f6bfd", + "sha256:52e93b28db27ae7d208748f45d2db8a7b6a380e0d703f099c949d0f0d80b70e9", + "sha256:55d62807f1c5a1682075c62436702aaba941daa316e9161e4b6ccebbbf38bda3", + "sha256:5805e71e5b570d490938d55552f5a9e10f477c19400c38bf1d5190d760691846", + "sha256:599daf06ea59bfedbec564b1692b0166a0045f32b6f0933b0dd4df59a854caf2", + "sha256:60d5772e8195f4e9ebf74046a9121bbb90090f6550f81d8956a05387ba139353", + "sha256:696d8e7d82398e810f2b3622b24e87906763b6ebfd90e361e88eb85b0e554dc8", + "sha256:6e6061bf1e9565c29002e3c601cf68569c450be7fc3f7336671af7ddb4657166", + "sha256:80ac992f25d10aaebe1ee15df45ca0d7571d0f70b645c08ec68733fb7a020206", + "sha256:816bd9488a94cba78d93e1abb58000e8266fa9cc2aa9ccdd6eb0696acb24005b", + "sha256:85d2b77e7c9382f004b41d9c72c85537fac834fb141b0296942d52bf03fe4a3d", + "sha256:87c8ceb0cf8a5a51b8008b643844b7f4a8264a2c13fcbcd8a8316161725383fe", + "sha256:89ee2e967bd7ff85d84a2de09df10e021c9b38c7d91dead95b406ed6350c6997", + "sha256:8bef097455dea90ffe855286926ae02d8faa335ed8e4067326257cb571fc1445", + "sha256:8d11ebbd679e927593978aa44c10fc2092bc454b7d13fdc958d3e9d508aba7d0", + "sha256:91e6c7db42638dc45cf2e13c73be16bf83179f7859b07cfc139518941320be96", + "sha256:97e7ac860d64e2dcba5c5944cfc8fa9ea185cd84061c623536154d5a89237884", + "sha256:990066bff27c4fcf3b69382b86f4c99b3652bab2a7e685d968cd4d0cfc6f67c6", + "sha256:9fbc5b8f3dfe24784cee8ce0be3da2d8a79e46a276593db6868382d9c50d97b1", + "sha256:ac4a39d1abae48184d420aa8e5e63efd1b75c8444dd95daa3e03f6c6310e9619", + "sha256:b2c02d2ad98116e914d4f3155ffc905fd0c025d901ead3f6ed07385e19122c94", + "sha256:b2d3337dcfaa99698aa2377c81c9ca72fcd89c07e7eb62ece3f23a3fe89b2ce4", + "sha256:b489c36d1327868d207002391f662a1d163bdc8daf10ab2e5f6e41b9b96de3b1", + "sha256:b641161c302efbb860ae6b081f406839a8b7d5573f20a455539823802c655f63", + "sha256:b8ba29306c5de7717b5761b9ea74f9c72b9e2b834e24aa984da99cbfc70157fd", + "sha256:b9934adbd0f6e476f0ecff3c94626529f344f57b38c9a541f87098710b18af0a", + "sha256:ce85c43ae54845272f6f9cd8320d034d7a946e9773c693b27d620edec825e376", + "sha256:cf868e08690cb89360eebc73ba4be7fb461cfbc6168dd88e2fbbe6f31812cd57", + "sha256:d2905ce1df400360463c772b55d8e2518d0e488a87cdea13dd2c71dcb2a1fa16", + "sha256:d57e20ba591727da0c230ab2c3f200ac9d6d333860d85348816e1dca4cc4792e", + "sha256:d6a8c9d4f8692917a3dc7eb25a6fb337bff86909febe2f793ec1928cd97bedfc", + "sha256:d923ff276f1c1f9680d32832f8d6c040fe9306cbfb5d161b0911e9634be9ef0a", + "sha256:daa7197b43c707462f06d2c693ffdbb5991cbb8b80b5b984007de431493a319c", + "sha256:dbd4c177afb8a8d9ba348d925b0b67246147af806f0b104af4d24f144d461cd5", + "sha256:dc4d815b794fd8868c4d67602692c21bf5293a75e4b607bb92a11e821e2b859a", + "sha256:e9d21aaa84557d64209af04ff48e0ad5e28c5cca67ce43444e939579d085da72", + "sha256:ea6b8aa9e08eea388c5f7a276fabb1d4b6b9d6e4ceb12cc477c3d352001768a9", + "sha256:eabe7090db68c981fca689299c2d116400b553f4b713266b130cfc9e2aa9c5a9", + "sha256:f2f6d303f3dee132b322a14cd8765287b8f86cdc10d2cb6a6fae234ea488888e", + "sha256:f33f3258aae89da191c6ebaa3bc517c6c4cbc9b9f689e5d8452f7aedbb913fa8", + "sha256:f7bfb769f7efa0eefcd039dd19d843a4fbfbac52f1878b1da2ed5793ec9b1a65", + "sha256:f89e21afe925fcfa655965ca8ea10f24773a1791400989ff32f467badfe4a064", + "sha256:fa24255ae3c0ab67e613556375a4341af04a084bd58764731972bcbc8baeba36" + ], + "markers": "python_version >= '3' and platform_machine == 'aarch64' or (platform_machine == 'ppc64le' or (platform_machine == 'x86_64' or (platform_machine == 'amd64' or (platform_machine == 'AMD64' or (platform_machine == 'win32' or platform_machine == 'WIN32')))))", + "version": "==3.0.1" + }, + "itsdangerous": { + "hashes": [ + "sha256:2c2349112351b88699d8d4b6b075022c0808887cb7ad10069318a8b0bc88db44", + "sha256:5dbbc68b317e5e42f327f9021763545dc3fc3bfe22e6deb96aaf1fc38874156a" + ], + "markers": "python_version >= '3.7'", + "version": "==2.1.2" + }, + "jinja2": { + "hashes": [ + "sha256:31351a702a408a9e7595a8fc6150fc3f43bb6bf7e319770cbc0db9df9437e852", + "sha256:6088930bfe239f0e6710546ab9c19c9ef35e29792895fed6e6e31a023a182a61" + ], + "markers": "python_version >= '3.7'", + "version": "==3.1.2" + }, + "markupsafe": { + "hashes": [ + "sha256:05fb21170423db021895e1ea1e1f3ab3adb85d1c2333cbc2310f2a26bc77272e", + "sha256:0a4e4a1aff6c7ac4cd55792abf96c915634c2b97e3cc1c7129578aa68ebd754e", + "sha256:10bbfe99883db80bdbaff2dcf681dfc6533a614f700da1287707e8a5d78a8431", + "sha256:134da1eca9ec0ae528110ccc9e48041e0828d79f24121a1a146161103c76e686", + "sha256:14ff806850827afd6b07a5f32bd917fb7f45b046ba40c57abdb636674a8b559c", + "sha256:1577735524cdad32f9f694208aa75e422adba74f1baee7551620e43a3141f559", + "sha256:1b40069d487e7edb2676d3fbdb2b0829ffa2cd63a2ec26c4938b2d34391b4ecc", + "sha256:1b8dd8c3fd14349433c79fa8abeb573a55fc0fdd769133baac1f5e07abf54aeb", + "sha256:1f67c7038d560d92149c060157d623c542173016c4babc0c1913cca0564b9939", + "sha256:282c2cb35b5b673bbcadb33a585408104df04f14b2d9b01d4c345a3b92861c2c", + "sha256:2c1b19b3aaacc6e57b7e25710ff571c24d6c3613a45e905b1fde04d691b98ee0", + "sha256:2ef12179d3a291be237280175b542c07a36e7f60718296278d8593d21ca937d4", + "sha256:338ae27d6b8745585f87218a3f23f1512dbf52c26c28e322dbe54bcede54ccb9", + "sha256:3c0fae6c3be832a0a0473ac912810b2877c8cb9d76ca48de1ed31e1c68386575", + "sha256:3fd4abcb888d15a94f32b75d8fd18ee162ca0c064f35b11134be77050296d6ba", + "sha256:42de32b22b6b804f42c5d98be4f7e5e977ecdd9ee9b660fda1a3edf03b11792d", + "sha256:47d4f1c5f80fc62fdd7777d0d40a2e9dda0a05883ab11374334f6c4de38adffd", + "sha256:504b320cd4b7eff6f968eddf81127112db685e81f7e36e75f9f84f0df46041c3", + "sha256:525808b8019e36eb524b8c68acdd63a37e75714eac50e988180b169d64480a00", + "sha256:56d9f2ecac662ca1611d183feb03a3fa4406469dafe241673d521dd5ae92a155", + "sha256:5bbe06f8eeafd38e5d0a4894ffec89378b6c6a625ff57e3028921f8ff59318ac", + "sha256:65c1a9bcdadc6c28eecee2c119465aebff8f7a584dd719facdd9e825ec61ab52", + "sha256:68e78619a61ecf91e76aa3e6e8e33fc4894a2bebe93410754bd28fce0a8a4f9f", + "sha256:69c0f17e9f5a7afdf2cc9fb2d1ce6aabdb3bafb7f38017c0b77862bcec2bbad8", + "sha256:6b2b56950d93e41f33b4223ead100ea0fe11f8e6ee5f641eb753ce4b77a7042b", + "sha256:715d3562f79d540f251b99ebd6d8baa547118974341db04f5ad06d5ea3eb8007", + "sha256:787003c0ddb00500e49a10f2844fac87aa6ce977b90b0feaaf9de23c22508b24", + "sha256:7ef3cb2ebbf91e330e3bb937efada0edd9003683db6b57bb108c4001f37a02ea", + "sha256:8023faf4e01efadfa183e863fefde0046de576c6f14659e8782065bcece22198", + "sha256:8758846a7e80910096950b67071243da3e5a20ed2546e6392603c096778d48e0", + "sha256:8afafd99945ead6e075b973fefa56379c5b5c53fd8937dad92c662da5d8fd5ee", + "sha256:8c41976a29d078bb235fea9b2ecd3da465df42a562910f9022f1a03107bd02be", + "sha256:8e254ae696c88d98da6555f5ace2279cf7cd5b3f52be2b5cf97feafe883b58d2", + "sha256:8f9293864fe09b8149f0cc42ce56e3f0e54de883a9de90cd427f191c346eb2e1", + "sha256:9402b03f1a1b4dc4c19845e5c749e3ab82d5078d16a2a4c2cd2df62d57bb0707", + "sha256:962f82a3086483f5e5f64dbad880d31038b698494799b097bc59c2edf392fce6", + "sha256:9aad3c1755095ce347e26488214ef77e0485a3c34a50c5a5e2471dff60b9dd9c", + "sha256:9dcdfd0eaf283af041973bff14a2e143b8bd64e069f4c383416ecd79a81aab58", + "sha256:aa57bd9cf8ae831a362185ee444e15a93ecb2e344c8e52e4d721ea3ab6ef1823", + "sha256:aa7bd130efab1c280bed0f45501b7c8795f9fdbeb02e965371bbef3523627779", + "sha256:ab4a0df41e7c16a1392727727e7998a467472d0ad65f3ad5e6e765015df08636", + "sha256:ad9e82fb8f09ade1c3e1b996a6337afac2b8b9e365f926f5a61aacc71adc5b3c", + "sha256:af598ed32d6ae86f1b747b82783958b1a4ab8f617b06fe68795c7f026abbdcad", + "sha256:b076b6226fb84157e3f7c971a47ff3a679d837cf338547532ab866c57930dbee", + "sha256:b7ff0f54cb4ff66dd38bebd335a38e2c22c41a8ee45aa608efc890ac3e3931bc", + "sha256:bfce63a9e7834b12b87c64d6b155fdd9b3b96191b6bd334bf37db7ff1fe457f2", + "sha256:c011a4149cfbcf9f03994ec2edffcb8b1dc2d2aede7ca243746df97a5d41ce48", + "sha256:c9c804664ebe8f83a211cace637506669e7890fec1b4195b505c214e50dd4eb7", + "sha256:ca379055a47383d02a5400cb0d110cef0a776fc644cda797db0c5696cfd7e18e", + "sha256:cb0932dc158471523c9637e807d9bfb93e06a95cbf010f1a38b98623b929ef2b", + "sha256:cd0f502fe016460680cd20aaa5a76d241d6f35a1c3350c474bac1273803893fa", + "sha256:ceb01949af7121f9fc39f7d27f91be8546f3fb112c608bc4029aef0bab86a2a5", + "sha256:d080e0a5eb2529460b30190fcfcc4199bd7f827663f858a226a81bc27beaa97e", + "sha256:dd15ff04ffd7e05ffcb7fe79f1b98041b8ea30ae9234aed2a9168b5797c3effb", + "sha256:df0be2b576a7abbf737b1575f048c23fb1d769f267ec4358296f31c2479db8f9", + "sha256:e09031c87a1e51556fdcb46e5bd4f59dfb743061cf93c4d6831bf894f125eb57", + "sha256:e4dd52d80b8c83fdce44e12478ad2e85c64ea965e75d66dbeafb0a3e77308fcc", + "sha256:f698de3fd0c4e6972b92290a45bd9b1536bffe8c6759c62471efaa8acb4c37bc", + "sha256:fec21693218efe39aa7f8599346e90c705afa52c5b31ae019b2e57e8f6542bb2", + "sha256:ffcc3f7c66b5f5b7931a5aa68fc9cecc51e685ef90282f4a82f0f5e9b704ad11" + ], + "markers": "python_version >= '3.7'", + "version": "==2.1.3" + }, + "mysql-connector-python": { + "hashes": [ + "sha256:230a34df2f3c4f36acf426361914b8552f129538a5e2256d489dca2b39f2f031", + "sha256:26e19a4469276870ccc0a04db30c534519f0f774a5949370ff0dc03e7cfc071c", + "sha256:37ca26d7b10580b836f038d42f21ba9e6c88542868d50f55defdbd2dc8e0c0e6", + "sha256:4828a08b738174cacb0985df01120e0a2f0ba534c9d2f67d6613b0930a0fe3cd", + "sha256:4b2de9dd56de4874c30364023b59991399222cf73ec744da590cf9eef2623c26", + "sha256:539300944c36566e91d131e106dbf0a90cde697ba88247a820f5af9caea2e5c2", + "sha256:55a58e57824b03f31befdd460b1dd173a05605bfac25278cd9845f3927c94399", + "sha256:59d4ea8253edbca7cbd1ac25ed524fcf5d8e34ee7ef5fb1be9e3026852b88126", + "sha256:5d1e1399a9feb45fc8caca6168ff35d31a8124693f24391153764bccc61d15ad", + "sha256:66d755b94f547d6fcdee9f2256805a4534103363e35d185d3800bfc5274e1f4f", + "sha256:6ac2c0d9c0248df8a62a736ae9024e1934acdbdf9ce3b2ab57b2a99c1da5f028", + "sha256:717472cab0c4d5000cf60797be4d453b60d9ec98ec446c1e3c399fdd43941cb9", + "sha256:81ac2e409b604bcf2ae6e18bc477f8f6e509ea5004c8dba291afe3d2591f0a3f", + "sha256:858490bb450b6ae45f415d2205d65a12e84e3445c7b9736e1d1552b685bf237a", + "sha256:86e30370b6c38a9aaeae042eade5eea95000f6eb93b3802a8a215750a29a48c1", + "sha256:877076f2d71d268fb1b334c85a20ef1d42096ceb6580b25229be4510ebf5a0c5", + "sha256:884eba07b4c97edf552a03f5fdca145e0ab4afc3d8677cca20276effca1bea54", + "sha256:8c3ed071e19981e8e4ae64c1e3ded050571637a8d519669c03be5e1029c04ef7", + "sha256:98606e893bc2343ccc9254f248e4bd5bae18cb03bf4931f3b1657900cd647718", + "sha256:9d598cc854d1b61eabad7cbf003cfe59970aae80384f5ff18a5cc3fba7becdcc", + "sha256:a0308455462d4078baf516255662a46611eb12fc8d83d40dea38df3032d2566d", + "sha256:b3f64b1fd2de2e8ed5c9ddd388efce8c6804a8633e18dbb45563a6ae61fbc45b", + "sha256:df033ca9c76f3a7c3500baea109127bc872c79431e9de691ffce4c2878af2828", + "sha256:e0b8af0d8c56619875dee4168b1bf77e17a4c1c5e1df935623d264729df70227", + "sha256:e33677bcf6c2bdee7f25f3b38da7a204ab761e3b6763cbb2a413a5300c151059", + "sha256:e9eacdc3fa5f61276c1549ca7c471a6fde576692881a70dcbac8258314015347" + ], + "index": "pypi", + "version": "==8.2.0" + }, + "protobuf": { + "hashes": [ + "sha256:1f22ac0ca65bb70a876060d96d914dae09ac98d114294f77584b0d2644fa9c30", + "sha256:237216c3326d46808a9f7c26fd1bd4b20015fb6867dc5d263a493ef9a539293b", + "sha256:27f4d15021da6d2b706ddc3860fac0a5ddaba34ab679dc182b60a8bb4e1121cc", + "sha256:299ea899484ee6f44604deb71f424234f654606b983cb496ea2a53e3c63ab791", + "sha256:3d164928ff0727d97022957c2b849250ca0e64777ee31efd7d6de2e07c494717", + "sha256:6ab80df09e3208f742c98443b6166bcb70d65f52cfeb67357d52032ea1ae9bec", + "sha256:78a28c9fa223998472886c77042e9b9afb6fe4242bd2a2a5aced88e3f4422aa7", + "sha256:7cd532c4566d0e6feafecc1059d04c7915aec8e182d1cf7adee8b24ef1e2e6ab", + "sha256:89f9149e4a0169cddfc44c74f230d7743002e3aa0b9472d8c28f0388102fc4c2", + "sha256:a53fd3f03e578553623272dc46ac2f189de23862e68565e83dde203d41b76fc5", + "sha256:b135410244ebe777db80298297a97fbb4c862c881b4403b71bac9d4107d61fd1", + "sha256:b98d0148f84e3a3c569e19f52103ca1feacdac0d2df8d6533cf983d1fda28462", + "sha256:d1736130bce8cf131ac7957fa26880ca19227d4ad68b4888b3be0dea1f95df97", + "sha256:f45460f9ee70a0ec1b6694c6e4e348ad2019275680bd68a1d9314b8c7e01e574" + ], + "markers": "python_version >= '3.7'", + "version": "==4.21.12" + }, + "pymysql": { + "hashes": [ + "sha256:4f13a7df8bf36a51e81dd9f3605fede45a4878fe02f9236349fd82a3f0612f96", + "sha256:8969ec6d763c856f7073c4c64662882675702efcb114b4bcbb955aea3a069fa7" + ], + "index": "pypi", + "version": "==1.1.0" + }, + "sqlalchemy": { + "hashes": [ + "sha256:0b7dbe6369677a2bea68fe9812c6e4bbca06ebfa4b5cde257b2b0bf208709131", + "sha256:128a948bd40780667114b0297e2cc6d657b71effa942e0a368d8cc24293febb3", + "sha256:14b0cacdc8a4759a1e1bd47dc3ee3f5db997129eb091330beda1da5a0e9e5bd7", + "sha256:1fb9cb60e0f33040e4f4681e6658a7eb03b5cb4643284172f91410d8c493dace", + "sha256:273505fcad22e58cc67329cefab2e436006fc68e3c5423056ee0513e6523268a", + "sha256:2e70e0673d7d12fa6cd363453a0d22dac0d9978500aa6b46aa96e22690a55eab", + "sha256:34e1c5d9cd3e6bf3d1ce56971c62a40c06bfc02861728f368dcfec8aeedb2814", + "sha256:3b97ddf509fc21e10b09403b5219b06c5b558b27fc2453150274fa4e70707dbf", + "sha256:3f6997da81114daef9203d30aabfa6b218a577fc2bd797c795c9c88c9eb78d49", + "sha256:82dd4131d88395df7c318eeeef367ec768c2a6fe5bd69423f7720c4edb79473c", + "sha256:85292ff52ddf85a39367057c3d7968a12ee1fb84565331a36a8fead346f08796", + "sha256:8a7a66297e46f85a04d68981917c75723e377d2e0599d15fbe7a56abed5e2d75", + "sha256:8b881ac07d15fb3e4f68c5a67aa5cdaf9eb8f09eb5545aaf4b0a5f5f4659be18", + "sha256:a3257a6e09626d32b28a0c5b4f1a97bced585e319cfa90b417f9ab0f6145c33c", + "sha256:a9bddb60566dc45c57fd0a5e14dd2d9e5f106d2241e0a2dc0c1da144f9444516", + "sha256:bdb77e1789e7596b77fd48d99ec1d2108c3349abd20227eea0d48d3f8cf398d9", + "sha256:c1db0221cb26d66294f4ca18c533e427211673ab86c1fbaca8d6d9ff78654293", + "sha256:c4cb501d585aa74a0f86d0ea6263b9c5e1d1463f8f9071392477fd401bd3c7cc", + "sha256:d00665725063692c42badfd521d0c4392e83c6c826795d38eb88fb108e5660e5", + "sha256:d0fed0f791d78e7767c2db28d34068649dfeea027b83ed18c45a423f741425cb", + "sha256:d69738d582e3a24125f0c246ed8d712b03bd21e148268421e4a4d09c34f521a5", + "sha256:db4db3c08ffbb18582f856545f058a7a5e4ab6f17f75795ca90b3c38ee0a8ba4", + "sha256:f1fcee5a2c859eecb4ed179edac5ffbc7c84ab09a5420219078ccc6edda45436", + "sha256:f2d526aeea1bd6a442abc7c9b4b00386fd70253b80d54a0930c0a216230a35be", + "sha256:fbaf6643a604aa17e7a7afd74f665f9db882df5c297bdd86c38368f2c471f37d" + ], + "index": "pypi", + "version": "==1.4.50" + }, + "werkzeug": { + "hashes": [ + "sha256:507e811ecea72b18a404947aded4b3390e1db8f826b494d76550ef45bb3b1dcc", + "sha256:90a285dc0e42ad56b34e696398b8122ee4c681833fb35b8334a095d82c56da10" + ], + "markers": "python_version >= '3.8'", + "version": "==3.0.1" + } + }, + "develop": { + "black": { + "hashes": [ + "sha256:250d7e60f323fcfc8ea6c800d5eba12f7967400eb6c2d21ae85ad31c204fb1f4", + "sha256:2a9acad1451632021ee0d146c8765782a0c3846e0e0ea46659d7c4f89d9b212b", + "sha256:412f56bab20ac85927f3a959230331de5614aecda1ede14b373083f62ec24e6f", + "sha256:421f3e44aa67138ab1b9bfbc22ee3780b22fa5b291e4db8ab7eee95200726b07", + "sha256:45aa1d4675964946e53ab81aeec7a37613c1cb71647b5394779e6efb79d6d187", + "sha256:4c44b7211a3a0570cc097e81135faa5f261264f4dfaa22bd5ee2875a4e773bd6", + "sha256:4c68855825ff432d197229846f971bc4d6666ce90492e5b02013bcaca4d9ab05", + "sha256:5133f5507007ba08d8b7b263c7aa0f931af5ba88a29beacc4b2dc23fcefe9c06", + "sha256:54caaa703227c6e0c87b76326d0862184729a69b73d3b7305b6288e1d830067e", + "sha256:58e5f4d08a205b11800332920e285bd25e1a75c54953e05502052738fe16b3b5", + "sha256:698c1e0d5c43354ec5d6f4d914d0d553a9ada56c85415700b81dc90125aac244", + "sha256:6c1cac07e64433f646a9a838cdc00c9768b3c362805afc3fce341af0e6a9ae9f", + "sha256:760415ccc20f9e8747084169110ef75d545f3b0932ee21368f63ac0fee86b221", + "sha256:7f622b6822f02bfaf2a5cd31fdb7cd86fcf33dab6ced5185c35f5db98260b055", + "sha256:cf57719e581cfd48c4efe28543fea3d139c6b6f1238b3f0102a9c73992cbb479", + "sha256:d136ef5b418c81660ad847efe0e55c58c8208b77a57a28a503a5f345ccf01394", + "sha256:dbea0bb8575c6b6303cc65017b46351dc5953eea5c0a59d7b7e3a2d2f433a911", + "sha256:fc7f6a44d52747e65a02558e1d807c82df1d66ffa80a601862040a43ec2e3142" + ], + "index": "pypi", + "version": "==23.11.0" + }, + "click": { + "hashes": [ + "sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28", + "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de" + ], + "markers": "python_version >= '3.7'", + "version": "==8.1.7" + }, + "flake8": { + "hashes": [ + "sha256:d5b3857f07c030bdb5bf41c7f53799571d75c4491748a3adcd47de929e34cd23", + "sha256:ffdfce58ea94c6580c77888a86506937f9a1a227dfcd15f245d694ae20a6b6e5" + ], + "index": "pypi", + "version": "==6.1.0" + }, + "isort": { + "hashes": [ + "sha256:8bef7dde241278824a6d83f44a544709b065191b95b6e50894bdc722fcba0504", + "sha256:f84c2818376e66cf843d497486ea8fed8700b340f308f076c6fb1229dff318b6" + ], + "index": "pypi", + "version": "==5.12.0" + }, + "mccabe": { + "hashes": [ + "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325", + "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e" + ], + "markers": "python_version >= '3.6'", + "version": "==0.7.0" + }, + "mypy-extensions": { + "hashes": [ + "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d", + "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782" + ], + "markers": "python_version >= '3.5'", + "version": "==1.0.0" + }, + "packaging": { + "hashes": [ + "sha256:048fb0e9405036518eaaf48a55953c750c11e1a1b68e0dd1a9d62ed0c092cfc5", + "sha256:8c491190033a9af7e1d931d0b5dacc2ef47509b34dd0de67ed209b5203fc88c7" + ], + "markers": "python_version >= '3.7'", + "version": "==23.2" + }, + "pathspec": { + "hashes": [ + "sha256:1d6ed233af05e679efb96b1851550ea95bbb64b7c490b0f5aa52996c11e92a20", + "sha256:e0d8d0ac2f12da61956eb2306b69f9469b42f4deb0f3cb6ed47b9cce9996ced3" + ], + "markers": "python_version >= '3.7'", + "version": "==0.11.2" + }, + "platformdirs": { + "hashes": [ + "sha256:118c954d7e949b35437270383a3f2531e99dd93cf7ce4dc8340d3356d30f173b", + "sha256:cb633b2bcf10c51af60beb0ab06d2f1d69064b43abf4c185ca6b28865f3f9731" + ], + "markers": "python_version >= '3.7'", + "version": "==4.0.0" + }, + "pycodestyle": { + "hashes": [ + "sha256:41ba0e7afc9752dfb53ced5489e89f8186be00e599e712660695b7a75ff2663f", + "sha256:44fe31000b2d866f2e41841b18528a505fbd7fef9017b04eff4e2648a0fadc67" + ], + "markers": "python_version >= '3.8'", + "version": "==2.11.1" + }, + "pyflakes": { + "hashes": [ + "sha256:4132f6d49cb4dae6819e5379898f2b8cce3c5f23994194c24b77d5da2e36f774", + "sha256:a0aae034c444db0071aa077972ba4768d40c830d9539fd45bf4cd3f8f6992efc" + ], + "markers": "python_version >= '3.8'", + "version": "==3.1.0" + } + } +} diff --git a/webapp/python/app.py b/webapp/python/app.py new file mode 100644 index 000000000..b26a294d7 --- /dev/null +++ b/webapp/python/app.py @@ -0,0 +1,1688 @@ +#!/usr/bin/env python + +import hashlib +import io +import json +import os +import subprocess +import sys +import uuid +from base64 import b64decode +from dataclasses import asdict +from datetime import datetime, timedelta, timezone +from http.client import ( + BAD_REQUEST, + CREATED, + FORBIDDEN, + INTERNAL_SERVER_ERROR, + NOT_FOUND, + OK, + UNAUTHORIZED, +) +from typing import Any + +import bcrypt +import models +import mysql.connector +from flask import Flask, Response, request, send_file, session +from mysql.connector.errors import DatabaseError +from sqlalchemy.pool import QueuePool + + +class Settings(object): + LISTEN_PORT = 8080 + + DB_HOST = os.getenv("ISUCON13_MYSQL_DIALCONFIG_ADDRESS", "127.0.0.1") + DB_PORT = int(os.getenv("ISUCON13_MYSQL_DIALCONFIG_PORT", 3306)) + DB_USER = os.getenv("ISUCON13_MYSQL_DIALCONFIG_USER", "isucon") + DB_PASSWORD = os.getenv("ISUCON13_MYSQL_DIALCONFIG_PASSWORD", "isucon") + DB_NAME = os.getenv("ISUCON13_MYSQL_DIALCONFIG_DATABASE", "isupipe") + + POWERDNS_ENABLED = os.getenv("ISUCON13_POWERDNS_DISABLED") != "true" + POWERDNS_SUBDOMAIN_ADDRESS = os.getenv("ISUCON13_POWERDNS_SUBDOMAIN_ADDRESS") + + SESSION_COOKIE_DOMAIN = "u.isucon.dev" + SESSION_COOKIE_PATH = "/" + SESSION_SECRET_KEY = os.getenv( + "ISUCON13_SESSION_SECRETKEY", "isucon13_session_cookiestore_defaultsecret" + ) + PERMANENT_SESSION_LIFETIME = 600 # 10min + + DEFAULT_SESSION_ID_KEY = "SESSIONID" + DEFAULT_SESSION_EXPIRES_KEY = "EXPIRES" + DEFAULT_USER_ID_KEY = "USERID" + DEFAULT_USER_NAME_KEY = "USERNAME" + + FALLBACK_IMAGE = "../img/NoImage.jpg" + BCRYPT_DEFAULT_COST = 4 + + +app = Flask(__name__) + + +# 初期化 +@app.route("/api/initialize", methods=["POST"]) +def initialize_handler() -> tuple[dict[str, Any], int]: + # app.logger.info("start initialize") + result = subprocess.run(["../sql/init.sh"], capture_output=True, text=True) + if result.returncode != 0: + app.logger.error( + 'init.sh failed with status=%d err="%s"', (result.returncode, result.stdout) + ) + raise HttpException(result.stderr, INTERNAL_SERVER_ERROR) + + return {"advertise_level": 1, "language": "python"}, OK + + +# top +@app.route("/api/tag", methods=["GET"]) +def get_tag_handler() -> tuple[dict[str, Any], int]: + conn = cnxpool.connect() + try: + conn.start_transaction() + c = conn.cursor(dictionary=True) + sql = "SELECT * FROM tags" + c.execute(sql) + rows = c.fetchall() + if rows is None: + raise HttpException("failed to get tags", INTERNAL_SERVER_ERROR) + + tags = models.Tags(tags=[models.Tag(**row) for row in rows]) + + conn.commit() + except DatabaseError as err: + conn.rollback() + raise err + finally: + conn.close() + return asdict(tags), OK + + +# 配信者のテーマ取得API +@app.route("/api/user//theme", methods=["GET"]) +def get_streamer_theme_handler(username: str) -> tuple[dict[str, Any], int]: + verify_user_session() + + conn = cnxpool.connect() + try: + conn.start_transaction() + + c = conn.cursor(dictionary=True) + sql = "SELECT id FROM users WHERE name = %s" + c.execute(sql, [username]) + row = c.fetchone() + if row is None: + raise HttpException("not found", NOT_FOUND) + + sql = "SELECT * FROM themes WHERE user_id = %s" + c.execute(sql, [row["id"]]) + row = c.fetchone() + if row is None: + raise HttpException("not found", NOT_FOUND) + theme_model = models.ThemeModel(**row) + theme = models.Theme(id=theme_model.id, dark_mode=theme_model.dark_mode) + except DatabaseError as err: + conn.rollback() + raise err + finally: + conn.commit() + conn.close() + return asdict(theme), OK + + +# livestream +# reserve livestream +@app.route("/api/livestream/reservation", methods=["POST"]) +def reserve_livestream_handler() -> tuple[dict[str, Any], int]: + verify_user_session() + + user_id = session.get(Settings.DEFAULT_USER_ID_KEY) + if not user_id: + raise HttpException("unauthorized", UNAUTHORIZED) + + req = get_request_json() + + conn = cnxpool.connect() + try: + conn.start_transaction() + c = conn.cursor(dictionary=True) + # 2024/04/01からの1年間の期間内であるかチェック + term_start_at = datetime(2023, 11, 25, 1, 0, 0, tzinfo=timezone.utc) + term_end_at = datetime(2024, 11, 25, 1, 0, 0, tzinfo=timezone.utc) + reserve_start_at = datetime.fromtimestamp( + float(req["start_at"]), tz=timezone.utc + ) + reserve_end_at = datetime.fromtimestamp(float(req["end_at"]), tz=timezone.utc) + + if reserve_start_at >= term_end_at or reserve_end_at <= term_start_at: + raise HttpException("bad reservation time range", BAD_REQUEST) + + # 予約枠をみて、予約が可能か調べる + sql = "SELECT * FROM reservation_slots WHERE start_at >= %s AND end_at <= %s FOR UPDATE" + c.execute(sql, [int(req["start_at"]), int(req["end_at"])]) + slots = c.fetchall() + if slots is None: + app.logger.error("予約枠一覧取得でエラー発生") + raise HttpException( + "failed to get reservation_slots", + INTERNAL_SERVER_ERROR, + ) + for slot in slots: + slot_start_at = int(slot["start_at"]) + slot_end_at = int(slot["end_at"]) + + sql = ( + "SELECT slot FROM reservation_slots WHERE start_at = %s AND end_at = %s" + ) + c.execute(sql, [slot_start_at, slot_end_at]) + count = c.fetchone() + if not count: + raise HttpException( + "failed to get reservation_slots", + INTERNAL_SERVER_ERROR, + ) + count = count["slot"] + + # app.logger.info(f"{slot_start_at}~{slot_end_at}予約枠の残数 = {count}") + if count < 1: + raise HttpException( + f"予約期間 {term_start_at.timestamp()} ~ {term_end_at.timestamp()}に対して、予約区間 {req['start_at']} ~ {req['end_at']}が予約できません", + BAD_REQUEST, + ) + + livestream_model = models.LiveStreamModel( + id=0, # 未設定 + user_id=user_id, + title=req["title"], + description=req["description"], + playlist_url=req["playlist_url"], + thumbnail_url=req["thumbnail_url"], + start_at=int(req["start_at"]), + end_at=int(req["end_at"]), + ) + + sql = "UPDATE reservation_slots SET slot = slot - 1 WHERE start_at >= %s AND end_at <= %s" + c.execute(sql, [int(req["start_at"]), int(req["end_at"])]) + + sql = "INSERT INTO livestreams (user_id, title, description, playlist_url, thumbnail_url, start_at, end_at) VALUES(%s, %s, %s, %s, %s, %s, %s)" + c.execute( + sql, + [ + livestream_model.user_id, + livestream_model.title, + livestream_model.description, + livestream_model.playlist_url, + livestream_model.thumbnail_url, + livestream_model.start_at, + livestream_model.end_at, + ], + ) + + livestream_model.id = c.lastrowid + + # タグ追加 + if "tags" in req and req["tags"]: + for tag_id in req["tags"]: + sql = "INSERT INTO livestream_tags (livestream_id, tag_id) VALUES (%s, %s)" + c.execute(sql, [livestream_model.id, tag_id]) + + livestream = fill_livestream_response(c, livestream_model) + if not livestream: + raise HttpException("failed to fill livestream", INTERNAL_SERVER_ERROR) + + return asdict(livestream), CREATED + except DatabaseError as err: + conn.rollback() + raise err + finally: + conn.commit() + conn.close() + + +# list livestream +@app.route("/api/livestream/search", methods=["GET"]) +def search_livestreams_handler() -> tuple[list[dict[str, Any]], int]: + key_tag_name = request.args.get("tag") + limit_str = request.args.get("limit") + + conn = cnxpool.connect() + try: + conn.start_transaction() + c = conn.cursor(dictionary=True) + livestream_models = [] + if key_tag_name: + # タグによる取得 + sql = "SELECT id FROM tags WHERE name = %s" + c.execute(sql, [key_tag_name]) + rows = c.fetchall() + if not rows: + raise HttpException("failed to get tags", INTERNAL_SERVER_ERROR) + tag_ids = [row["id"] for row in rows] + + sql = "SELECT * FROM livestream_tags WHERE tag_id IN (%s) ORDER BY livestream_id DESC" # idかtag_idか要確認 + in_formats = ",".join(["%s"] * len(tag_ids)) + sql = sql % in_formats + c.execute(sql, tag_ids) + key_tagged_livestreams = c.fetchall() + + for key_tagged_livestream in key_tagged_livestreams: + sql = "SELECT * FROM livestreams WHERE id = %s" + c.execute(sql, [key_tagged_livestream["livestream_id"]]) + row = c.fetchone() + if row is None: + raise HttpException( + "failed to get livestream", INTERNAL_SERVER_ERROR + ) + + livestream_model = models.LiveStreamModel(**row) + + livestream_models.append(livestream_model) + + else: + # 検索条件なし + sql = "SELECT * FROM livestreams ORDER BY id DESC" + args = [] + if limit_str: + sql += " LIMIT %s" + args.append(int(limit_str)) + + c.execute(sql, args) + rows = c.fetchall() + livestream_models = [models.LiveStreamModel(**row) for row in rows] + + livestreams = [] + for livestream_model in livestream_models: + livestream = fill_livestream_response(c, livestream_model) + if not livestream: + raise HttpException("error", INTERNAL_SERVER_ERROR) + + # HTTPレスポンスに使うのでasdictしてからリストに突っ込む + livestreams.append(asdict(livestream)) + + return livestreams, OK + except DatabaseError as err: + conn.rollback() + raise err + finally: + conn.commit() + conn.close() + + +@app.route("/api/livestream", methods=["GET"]) +def get_my_livestreams_handler() -> tuple[list[dict[str, Any]], int]: + verify_user_session() + + user_id = session.get(Settings.DEFAULT_USER_ID_KEY) + if not user_id: + raise HttpException("unauthorized", UNAUTHORIZED) + + conn = cnxpool.connect() + try: + conn.start_transaction() + c = conn.cursor(dictionary=True) + sql = "SELECT * FROM livestreams WHERE user_id = %s" + c.execute(sql, [user_id]) + rows = c.fetchall() + if rows is None: + raise HttpException( + "failed to get livestreams", + INTERNAL_SERVER_ERROR, + ) + if len(rows) == 0: + rows = [] + livestream_models = [models.LiveStreamModel(**row) for row in rows] + + livestreams = [] + for livestream_model in livestream_models: + livestream = fill_livestream_response(c, livestream_model) + if not livestream: + raise HttpException( + "failed to fill livestream", + INTERNAL_SERVER_ERROR, + ) + livestreams.append(asdict(livestream)) + return livestreams, OK + except DatabaseError as err: + conn.rollback() + raise err + finally: + conn.commit() + conn.close() + + +@app.route("/api/user//livestream", methods=["GET"]) +def get_user_livestreams_handler(username: str) -> tuple[list[dict[str, Any]], int]: + verify_user_session() + + conn = cnxpool.connect() + try: + conn.start_transaction() + c = conn.cursor(dictionary=True) + sql = "SELECT * FROM users WHERE name = %s" + c.execute(sql, [username]) + row = c.fetchone() + if row is None: + raise HttpException("user not found", NOT_FOUND) + user = models.UserModel(**row) + + sql = "SELECT * FROM livestreams WHERE user_id = %s" + c.execute(sql, [user.id]) + rows = c.fetchall() + if rows is None: + raise HttpException( + "failed to get livestreams", + INTERNAL_SERVER_ERROR, + ) + + livestream_models = [models.LiveStreamModel(**row) for row in rows] + + livestreams = [] + for livestream_model in livestream_models: + livestream = fill_livestream_response(c, livestream_model) + if not livestream: + raise HttpException( + "failed to fill livestream", + INTERNAL_SERVER_ERROR, + ) + livestreams.append(asdict(livestream)) + return livestreams, OK + except DatabaseError as err: + conn.rollback() + raise err + finally: + conn.commit() + conn.close() + + +# get livestream +@app.route("/api/livestream/", methods=["GET"]) +def get_livestream_handler(livestream_id: int) -> tuple[dict[str, Any], int]: + verify_user_session() + + conn = cnxpool.connect() + try: + conn.start_transaction() + c = conn.cursor(dictionary=True) + sql = "SELECT * FROM livestreams WHERE id = %s" + c.execute(sql, [livestream_id]) + row = c.fetchone() + if row is None: + raise HttpException("not found", NOT_FOUND) + livestream_model = models.LiveStreamModel(**row) + + livestream = fill_livestream_response(c, livestream_model) + return asdict(livestream), OK + except DatabaseError as err: + conn.rollback() + raise err + finally: + conn.commit() + conn.close() + + +# get polling livecomment timeline +@app.route("/api/livestream//livecomment", methods=["GET"]) +def get_livecomments_handler(livestream_id: int) -> tuple[list[dict[str, Any]], int]: + verify_user_session() + + conn = cnxpool.connect() + try: + conn.start_transaction() + c = conn.cursor(dictionary=True) + sql = "SELECT * FROM livecomments WHERE livestream_id = %s ORDER BY created_at DESC" + args = [livestream_id] + limit_str = request.args.get("limit") + if limit_str: + sql += " LIMIT %s" + args.append(int(limit_str)) + c.execute(sql, args) + + rows = c.fetchall() + if rows is None: + raise HttpException("not found", NOT_FOUND) + if len(rows) == 0: + rows = [] + + livecomment_models = [models.LiveCommentModel(**row) for row in rows] + + livecomments: list[dict[str, Any]] = [] + for livecomment_model in livecomment_models: + livecomment = fill_livecomment_response(c, livecomment_model) + livecomments.append(asdict(livecomment)) + + return livecomments, OK + except DatabaseError as err: + conn.rollback() + raise err + finally: + conn.commit() + conn.close() + + +# ライブコメント投稿 +@app.route("/api/livestream//livecomment", methods=["POST"]) +def post_livecomment_handler(livestream_id: int) -> tuple[dict[str, Any], int]: + verify_user_session() + + user_id = session.get(Settings.DEFAULT_USER_ID_KEY) + if not user_id: + raise HttpException("unauthorized", UNAUTHORIZED) + + req = get_request_json() + + conn = cnxpool.connect() + try: + conn.start_transaction() + c = conn.cursor(dictionary=True) + + sql = "SELECT * FROM livestreams WHERE id = %s" + c.execute(sql, [livestream_id]) + row = c.fetchone() + if row is None: + raise HttpException("livestream not found", NOT_FOUND) + livestream_model = models.LiveStreamModel(**row) + + # スパム判定 + sql = "SELECT id, user_id, livestream_id, word FROM ng_words WHERE user_id = %s AND livestream_id = %s" + c.execute(sql, [livestream_model.user_id, livestream_model.id]) + ng_words = c.fetchall() + if ng_words is None: + raise HttpException("failed to get NG words", INTERNAL_SERVER_ERROR) + + for ng_word in ng_words: + sql = """ + SELECT COUNT(*) + FROM + (SELECT %s AS text) AS texts + INNER JOIN + (SELECT CONCAT('%%', %s, '%%') AS pattern) AS patterns + ON texts.text LIKE patterns.pattern; + """ + c.execute(sql, [req["comment"], ng_word["word"]]) + hit_spam = c.fetchone() + if not hit_spam: + raise HttpException("failed to get hitspam", INTERNAL_SERVER_ERROR) + hit_spam = hit_spam["COUNT(*)"] + # app.logger.info(f"[hitSpam={hit_spam}] comment = {req['comment']}") + if hit_spam >= 1: + raise HttpException("このコメントがスパム判定されました", BAD_REQUEST) + + now = int(datetime.now().timestamp()) + + sql = "INSERT INTO livecomments (user_id, livestream_id, comment, tip, created_at) VALUES (%s, %s, %s, %s, %s)" + c.execute(sql, [user_id, livestream_id, req["comment"], req["tip"], now]) + + livecomment_id = c.lastrowid + livecomment_model = models.LiveCommentModel( + id=livecomment_id, + user_id=user_id, + livestream_id=livestream_id, + comment=req["comment"], + tip=req["tip"], + created_at=now, + ) + app.logger.info(livecomment_model) + livecomment = fill_livecomment_response(c, livecomment_model) + return asdict(livecomment), CREATED + except DatabaseError as err: + conn.rollback() + raise err + finally: + conn.commit() + conn.close() + + +@app.route("/api/livestream//reaction", methods=["POST"]) +def post_reaction_handler(livestream_id: int) -> tuple[dict[str, Any], int]: + verify_user_session() + + user_id = session.get(Settings.DEFAULT_USER_ID_KEY) + if not user_id: + raise HttpException("unauthorized", UNAUTHORIZED) + + req = get_request_json() + + conn = cnxpool.connect() + try: + conn.start_transaction() + c = conn.cursor(dictionary=True) + now = int(datetime.now().timestamp()) + sql = "INSERT INTO reactions (user_id, livestream_id, emoji_name, created_at) VALUES (%s, %s, %s, %s)" + c.execute(sql, [user_id, livestream_id, req["emoji_name"], now]) + + reaction_id = c.lastrowid + reaction_model = models.ReactionModel( + id=reaction_id, + user_id=user_id, + livestream_id=livestream_id, + emoji_name=req["emoji_name"], + created_at=now, + ) + + reaction = fill_reaction_response(c, reaction_model) + return asdict(reaction), CREATED + except DatabaseError as err: + conn.rollback() + raise err + finally: + conn.commit() + conn.close() + + +@app.route("/api/livestream//reaction", methods=["GET"]) +def get_reactions_handler( + livestream_id: int, +) -> tuple[list[dict[str, Any]] | dict[str, Any], int]: + verify_user_session() + + limit_str = request.args.get("limit") + + conn = cnxpool.connect() + try: + conn.start_transaction() + c = conn.cursor(dictionary=True) + sql = ( + "SELECT * FROM reactions WHERE livestream_id = %s ORDER BY created_at DESC" + ) + args = [livestream_id] + if limit_str: + sql += " LIMIT %s" + args.append(int(limit_str)) + + c.execute(sql, args) + rows = c.fetchall() + if rows is None: + # app.logger.info("reaction_models") + raise HttpException("failed to get reactions", INTERNAL_SERVER_ERROR) + reaction_models = [models.ReactionModel(**row) for row in rows] + + reactions = [] + for reaction_model in reaction_models: + reaction = fill_reaction_response(c, reaction_model) + reactions.append(asdict(reaction)) + + return reactions, OK + except DatabaseError as err: + conn.rollback() + raise err + finally: + conn.commit() + conn.close() + + +# (配信者向け)ライブコメントの報告一覧取得API +@app.route("/api/livestream//report", methods=["GET"]) +def get_livecomment_reports_handler( + livestream_id: int, +) -> tuple[list[dict[str, Any]], int]: + verify_user_session() + + conn = cnxpool.connect() + try: + conn.start_transaction() + c = conn.cursor(dictionary=True) + + sql = "SELECT * FROM livestreams WHERE id = %s" + c.execute(sql, [livestream_id]) + row = c.fetchone() + if not row: + raise HttpException("failed to get livestream", INTERNAL_SERVER_ERROR) + livestream_model = models.LiveStreamModel(**row) + + user_id = session.get(Settings.DEFAULT_USER_ID_KEY) + if not user_id: + raise HttpException("unauthorized", UNAUTHORIZED) + + if livestream_model.user_id != user_id: + raise HttpException( + "can't get other streamer's livecomment reports", + FORBIDDEN, + ) + + sql = "SELECT * FROM livecomment_reports WHERE livestream_id = %s" + c.execute(sql, [livestream_id]) + report_models = c.fetchall() + if report_models is None: + # app.logger.info("report_model") + raise HttpException( + "failed to get livecomment reports", + INTERNAL_SERVER_ERROR, + ) + + reports = [] + for report_model in report_models: + report = fill_livecomment_report_response(c, report_model) + if not report: + # app.logger.info("failed to fill livecomment report") + raise HttpException( + "failed to fill livecomment report", + INTERNAL_SERVER_ERROR, + ) + reports.append(asdict(report)) + + return reports, OK + except DatabaseError as err: + conn.rollback() + raise err + finally: + conn.commit() + conn.close() + + +@app.route("/api/livestream//ngwords", methods=["GET"]) +def get_ngwords(livestream_id: int) -> tuple[list[dict[str, Any]], int]: + verify_user_session() + + user_id = session.get(Settings.DEFAULT_USER_ID_KEY) + if not user_id: + raise HttpException("unauthorized", UNAUTHORIZED) + + conn = cnxpool.connect() + try: + conn.start_transaction() + c = conn.cursor(dictionary=True) + sql = "SELECT * FROM ng_words WHERE user_id = %s AND livestream_id = %s ORDER BY created_at DESC" + c.execute(sql, [user_id, livestream_id]) + rows = c.fetchall() + if rows is None: + app.logger.error("failed to get ngwords") + raise HttpException("error", INTERNAL_SERVER_ERROR) + if len(rows) == 0: + return [], OK + ngwords = [asdict(models.NGWord(**row)) for row in rows] + return ngwords, OK + except DatabaseError as err: + conn.rollback() + raise err + finally: + conn.commit() + conn.close() + + +# ライブコメント報告 +@app.route( + "/api/livestream//livecomment//report", + methods=["POST"], +) +def report_livecomment_handler( + livestream_id: int, livecomment_id: int +) -> tuple[dict[str, Any], int]: + verify_user_session() + + user_id = session.get(Settings.DEFAULT_USER_ID_KEY) + if not user_id: + raise HttpException("failed to find user-id from session", UNAUTHORIZED) + + conn = cnxpool.connect() + try: + conn.start_transaction() + c = conn.cursor(dictionary=True) + + sql = "SELECT * FROM livestreams WHERE id = %s" + c.execute(sql, [livestream_id]) + row = c.fetchone() + if not row: + raise HttpException("livestream not found", NOT_FOUND) + + now = int(datetime.now().timestamp()) + sql = "INSERT INTO livecomment_reports (user_id, livestream_id, livecomment_id, created_at) VALUES (%s, %s, %s, %s)" + c.execute(sql, [user_id, livestream_id, livecomment_id, now]) + + report_id = c.lastrowid + + report_model = models.LiveCommentReportModel( + id=report_id, + user_id=user_id, + livestream_id=livestream_id, + livecomment_id=livecomment_id, + created_at=now, + ) + + report = fill_livecomment_report_response(c, report_model) + + return asdict(report), CREATED + except DatabaseError as err: + conn.rollback() + raise err + finally: + conn.commit() + conn.close() + + +# 配信者によるモデレーション (NGワード登録) +@app.route("/api/livestream//moderate", methods=["POST"]) +def moderate_handler(livestream_id: int) -> tuple[dict[str, Any], int]: + verify_user_session() + + user_id = session.get(Settings.DEFAULT_USER_ID_KEY) + if not user_id: + raise HttpException("failed to find user-id from session", UNAUTHORIZED) + + req = get_request_json() + if not req or "ng_word" not in req: + raise HttpException( + "failed to decode the request body as json", + BAD_REQUEST, + ) + + conn = cnxpool.connect() + try: + conn.start_transaction() + c = conn.cursor(dictionary=True) + # 配信者自身の配信に対するmoderateなのかを検証 + sql = "SELECT * FROM livestreams WHERE id = %s AND user_id = %s" + c.execute(sql, [livestream_id, user_id]) + owned_livestreams = c.fetchall() + if owned_livestreams is None or len(owned_livestreams) == 0: + raise HttpException( + "A streamer can't moderate livestreams that other streamers own", + BAD_REQUEST, + ) + + sql = "INSERT INTO ng_words(user_id, livestream_id, word, created_at) VALUES (%s, %s, %s, %s)" + c.execute( + sql, + [ + user_id, + livestream_id, + req["ng_word"], + datetime.now().timestamp(), + ], + ) + + word_id = c.lastrowid + # app.logger.info(f"word_id: {word_id}, word: {req['ng_word']}") + + sql = "SELECT * FROM ng_words WHERE livestream_id = %s" + c.execute(sql, [livestream_id]) + ngwords = c.fetchall() + + # NGワードにヒットする過去の投稿も全削除する + for ngword in ngwords: + sql = "SELECT * FROM livecomments" + c.execute(sql) + livecomments = c.fetchall() + if livecomments is None: + app.logger.warn("failed to get livecomments") + raise HttpException( + "failed to get livecomments", + INTERNAL_SERVER_ERROR, + ) + + for livecomment in livecomments: + # app.logger.info(f"delete: {livecomment}") + sql = """ + DELETE FROM livecomments + WHERE + id = %s AND + (SELECT COUNT(*) + FROM + (SELECT %s AS text) AS texts + INNER JOIN + (SELECT CONCAT('%%', %s, '%%') AS pattern) AS patterns + ON texts.text LIKE patterns.pattern) >= 1; + """ + c.execute( + sql, [livecomment["id"], livecomment["comment"], ngword["word"]] + ) + return {"word_id": word_id}, CREATED + except DatabaseError as err: + conn.rollback() + raise err + finally: + conn.commit() + conn.close() + + +# livestream_viewersにINSERTするため必要 +# ユーザ視聴開始 (viewer) +@app.route("/api/livestream//enter", methods=["POST"]) +def enter_livestream_handler(livestream_id: int) -> tuple[str, int]: + verify_user_session() + + user_id = session.get(Settings.DEFAULT_USER_ID_KEY) + if not user_id: + raise HttpException("failed to find user-id from session", UNAUTHORIZED) + + conn = cnxpool.connect() + try: + conn.start_transaction() + c = conn.cursor(dictionary=True) + sql = "INSERT INTO livestream_viewers_history (user_id, livestream_id, created_at) VALUES(%s, %s, %s)" + c.execute(sql, [user_id, livestream_id, int(datetime.now().timestamp())]) + return "", OK + except DatabaseError as err: + conn.rollback() + raise err + finally: + conn.commit() + conn.close() + + +# ユーザ視聴終了 (viewer) +@app.route("/api/livestream//exit", methods=["DELETE"]) +def exit_livestream_handler(livestream_id: int) -> tuple[str, int]: + verify_user_session() + + user_id = session.get(Settings.DEFAULT_USER_ID_KEY) + if not user_id: + raise HttpException("failed to find user-id from session", UNAUTHORIZED) + + conn = cnxpool.connect() + try: + conn.start_transaction() + c = conn.cursor(dictionary=True) + sql = "DELETE FROM livestream_viewers_history WHERE user_id = %s AND livestream_id = %s" + c.execute(sql, [user_id, livestream_id]) + return "", OK + except DatabaseError as err: + conn.rollback() + raise err + finally: + conn.commit() + conn.close() + + +# user +@app.route("/api/register", methods=["POST"]) +def register_handler() -> tuple[dict[str, Any], int]: + req = get_request_json() + if not req: + raise HttpException( + "failed to decode the request body as json", + BAD_REQUEST, + ) + + if not all( + key in req + for key in ["name", "password", "display_name", "description", "theme"] + ): + raise HttpException( + "failed to decode the request body as json", + BAD_REQUEST, + ) + + if req["name"] == "pipe": + raise HttpException("the username 'pipe' is reserved", BAD_REQUEST) + + hashed_password = bcrypt.hashpw( + req["password"].encode(), bcrypt.gensalt(rounds=Settings.BCRYPT_DEFAULT_COST) + ) + + user_model = models.UserModel( + id=0, # INSERT後に確定する + name=req["name"], + display_name=req["display_name"], + description=req["description"], + password=hashed_password.decode(), + ) + + conn = cnxpool.connect() + try: + conn.start_transaction() + c = conn.cursor(dictionary=True) + sql = "INSERT INTO users (name, display_name, description, password) VALUES (%s,%s,%s,%s)" + c.execute( + sql, + [ + user_model.name, + user_model.display_name, + user_model.description, + user_model.password, + ], + ) + user_id = c.lastrowid + user_model.id = user_id + + sql = "INSERT INTO themes (user_id, dark_mode) VALUES (%s,%s)" + c.execute(sql, [user_id, req["theme"]["dark_mode"]]) + + if Settings.POWERDNS_ENABLED and Settings.POWERDNS_SUBDOMAIN_ADDRESS: + # app.logger.info("add-record") + result = subprocess.run( + [ + "pdnsutil", + "add-record", + "u.isucon.dev", + req["name"], + "A", + "0", + Settings.POWERDNS_SUBDOMAIN_ADDRESS, + ], + capture_output=True, + text=True, + ) + if result.returncode != 0: + raise HttpException(result.stdout, INTERNAL_SERVER_ERROR) + + user = fill_user_response(c, user_model) + return asdict(user), CREATED + except DatabaseError as err: + conn.rollback() + app.logger.warn("failed to insert user: %s", err) + raise HttpException("failed to insert user", INTERNAL_SERVER_ERROR) + finally: + conn.commit() + conn.close() + + +@app.route("/api/login", methods=["POST"]) +def login_handler() -> tuple[str, int]: + req = get_request_json() + if not req: + raise HttpException( + "failed to decode the request body as json", + BAD_REQUEST, + ) + + conn = cnxpool.connect() + try: + conn.start_transaction() + c = conn.cursor(dictionary=True) + sql = "SELECT * FROM users WHERE name = %s" + c.execute(sql, [req["username"]]) + row = c.fetchone() + if row is None: + raise HttpException("invalid username or password", UNAUTHORIZED) + user = models.UserModel(**row) + + if not bcrypt.checkpw(req["password"].encode(), user.password.encode()): + raise HttpException("invalid username or password", UNAUTHORIZED) + + session_end_at = datetime.now() + timedelta(hours=1) + session_id = str(uuid.uuid4()) + + session[Settings.DEFAULT_SESSION_ID_KEY] = session_id + # FIXME: ユーザ名 + session[Settings.DEFAULT_USER_ID_KEY] = user.id + session[Settings.DEFAULT_USER_NAME_KEY] = user.name + session[Settings.DEFAULT_SESSION_EXPIRES_KEY] = int(session_end_at.timestamp()) + return "", OK + except DatabaseError as err: + conn.rollback() + raise err + finally: + conn.commit() + conn.close() + + +@app.route("/api/user/me", methods=["GET"]) +def get_me_handler() -> tuple[dict[str, Any], int]: + verify_user_session() + + user_id = session.get(Settings.DEFAULT_USER_ID_KEY) + if not user_id: + raise HttpException("unauthorized", UNAUTHORIZED) + + conn = cnxpool.connect() + try: + conn.start_transaction() + c = conn.cursor(dictionary=True) + sql = "SELECT * FROM users WHERE id = %s" + c.execute(sql, [user_id]) + row = c.fetchone() + if not row: + raise HttpException("failed to get user", INTERNAL_SERVER_ERROR) + user_model = models.UserModel(**row) + user = fill_user_response(c, user_model) + return asdict(user), OK + except DatabaseError as err: + conn.rollback() + raise err + finally: + conn.commit() + conn.close() + + +# フロントエンドで、配信予約のコラボレーターを指定する際に必要 +@app.route("/api/user/", methods=["GET"]) +def get_user_handler(username: str) -> tuple[dict[str, Any], int]: + verify_user_session() + + conn = cnxpool.connect() + try: + conn.start_transaction() + c = conn.cursor(dictionary=True) + sql = "SELECT * FROM users WHERE name = %s" + c.execute(sql, [username]) + row = c.fetchone() + if row is None: + raise HttpException("not found", NOT_FOUND) + user_model = models.UserModel(**row) + + user = fill_user_response(c, user_model) + return asdict(user), OK + except DatabaseError as err: + conn.rollback() + raise err + finally: + conn.commit() + conn.close() + + +@app.route("/api/user//statistics", methods=["GET"]) +def get_user_statistics_handler(username: str) -> tuple[dict[str, Any], int]: + verify_user_session() + + # ユーザごとに、紐づく配信について、累計リアクション数、累計ライブコメント数、累計売上金額を算出 + # また、現在の合計視聴者数もだす + + conn = cnxpool.connect() + try: + conn.start_transaction() + c = conn.cursor(dictionary=True) + sql = "SELECT * FROM users WHERE name = %s" + c.execute(sql, [username]) + row = c.fetchone() + if row is None: + raise HttpException("not found user that has the given username", NOT_FOUND) + + # ランク算出 + sql = "SELECT * FROM users" + c.execute(sql) + rows = c.fetchall() + if rows is None: + raise HttpException("failed to get users", INTERNAL_SERVER_ERROR) + users = [models.UserModel(**row) for row in rows] + + ranking = [] + for user in users: + sql = """ + SELECT COUNT(*) FROM users u + INNER JOIN livestreams l ON l.user_id = u.id + INNER JOIN reactions r ON r.livestream_id = l.id + WHERE u.id = %s + """ + c.execute(sql, [user.id]) + reactions = c.fetchone() + if not reactions: + raise HttpException( + "failed to count reactions", + INTERNAL_SERVER_ERROR, + ) + + sql = """ + SELECT IFNULL(SUM(l2.tip), 0) FROM users u + INNER JOIN livestreams l ON l.user_id = u.id + INNER JOIN livecomments l2 ON l2.livestream_id = l.id + WHERE u.id = %s + """ + c.execute(sql, [user.id]) + tips = c.fetchone() + if not tips: + raise HttpException( + "failed to count tips", + INTERNAL_SERVER_ERROR, + ) + + score = reactions["COUNT(*)"] + tips["IFNULL(SUM(l2.tip), 0)"] + ranking.append(models.UserRankingEntry(username=user.name, score=score)) + ranking = sorted(ranking, key=lambda x: x.score) + + rank = 1 + i = len(ranking) - 1 + while i >= 0: + entry = ranking[i] + if entry.username == username: + break + rank += 1 + i -= 1 + + # リアクション数 + sql = """ + SELECT COUNT(*) FROM users u + INNER JOIN livestreams l ON l.user_id = u.id + INNER JOIN reactions r ON r.livestream_id = l.id + WHERE u.name = %s + """ + c.execute(sql, [username]) + total_reactions = c.fetchone() + if not total_reactions: + raise HttpException( + "failed to count total reactions", + INTERNAL_SERVER_ERROR, + ) + total_reactions = total_reactions["COUNT(*)"] + + # ライブコメント数、チップ合計 + total_livecomments = 0 + total_tip = 0 + for user in users: + sql = "SELECT * FROM livestreams WHERE user_id = %s" + c.execute(sql, [user.id]) + rows = c.fetchall() + if rows is None: + app.logger.error("livestreams livecomments") + raise HttpException( + "failed to get livestreams", + INTERNAL_SERVER_ERROR, + ) + livestreams = [models.LiveStreamModel(**row) for row in rows] + + for livestream in livestreams: + sql = "SELECT * FROM livecomments WHERE livestream_id = %s" + c.execute(sql, [livestream.id]) + livecomments = c.fetchall() + if livecomments is None: + app.logger.error("livecomments") + raise HttpException( + "failed to get livecomments", + INTERNAL_SERVER_ERROR, + ) + for livecomment in livecomments: + total_tip += livecomment["tip"] + total_livecomments += 1 + + # 合計視聴者数 + viewers_count = 0 + for user in users: + sql = "SELECT * FROM livestreams WHERE user_id = %s" + c.execute(sql, [user.id]) + livestreams = c.fetchall() + if livestreams is None: + app.logger.error("viewers_count") + raise HttpException( + "failed to get livestreams", + INTERNAL_SERVER_ERROR, + ) + + for livestream in livestreams: + sql = "SELECT COUNT(*) FROM livestream_viewers_history WHERE livestream_id = %s" + c.execute(sql, [livestream["id"]]) + cnt = c.fetchone() + if not cnt: + raise HttpException( + "failed to get livestream_view_history", + INTERNAL_SERVER_ERROR, + ) + viewers_count += cnt["COUNT(*)"] + + # お気に入り絵文字 + sql = """ + SELECT r.emoji_name + FROM users u + INNER JOIN livestreams l ON l.user_id = u.id + INNER JOIN reactions r ON r.livestream_id = l.id + WHERE u.name = %s + GROUP BY emoji_name + ORDER BY COUNT(*) DESC + LIMIT 1 + """ + c.execute(sql, [username]) + favorite_emoji = c.fetchone() + if not favorite_emoji: + favorite_emoji = "" + else: + favorite_emoji = favorite_emoji["emoji_name"] + + statistics = models.UserStatistics( + rank=rank, + viewers_count=viewers_count, + total_reactions=total_reactions, + total_livecomments=total_livecomments, + total_tip=total_tip, + favorite_emoji=favorite_emoji, + ) + + return asdict(statistics), OK + except DatabaseError as err: + conn.rollback() + raise err + finally: + conn.commit() + conn.close() + + +@app.route("/api/user//icon", methods=["GET"]) +def get_icon_handler(username: str) -> Response: + verify_user_session() + + conn = cnxpool.connect() + try: + conn.start_transaction() + c = conn.cursor(dictionary=True) + sql = "SELECT * FROM users WHERE name = %s" + c.execute(sql, [username]) + + row = c.fetchone() + if row is None: + raise HttpException("user not found", INTERNAL_SERVER_ERROR) + user = models.UserModel(**row) + + sql = "SELECT image FROM icons WHERE user_id = %s" + c.execute(sql, [user.id]) + + image = c.fetchone() + except DatabaseError as err: + conn.rollback() + raise err + finally: + conn.commit() + conn.close() + + if not image: + return send_file( + Settings.FALLBACK_IMAGE, mimetype="image/jpeg", as_attachment=True + ) + return send_file( + io.BytesIO(image["image"]), + mimetype="image/jpeg", + as_attachment=True, + download_name="icon.jpg", + ) + + +@app.route("/api/icon", methods=["POST"]) +def post_icon_handler() -> tuple[dict[str, Any], int]: + verify_user_session() + + user_id = session.get(Settings.DEFAULT_USER_ID_KEY) + if not user_id: + raise HttpException("unauthorized", UNAUTHORIZED) + + req = get_request_json() + if not req or "image" not in req: + raise HttpException( + "failed to decode the request body as json", + BAD_REQUEST, + ) + new_icon = b64decode(req["image"]) + + conn = cnxpool.connect() + try: + conn.start_transaction() + c = conn.cursor(dictionary=True) + sql = "DELETE FROM icons WHERE user_id = %s" + c.execute(sql, [user_id]) + + sql = "INSERT INTO icons (user_id, image) VALUES (%s, %s)" + c.execute(sql, [user_id, new_icon]) + + icon_id = c.lastrowid + return {"id": icon_id}, CREATED + except DatabaseError as err: + conn.rollback() + raise err + finally: + conn.commit() + conn.close() + + +# stats +# ライブコメント統計情報 +@app.route("/api/livestream//statistics", methods=["GET"]) +def get_livestream_statistics_handler(livestream_id: int) -> tuple[dict[str, Any], int]: + verify_user_session() + + conn = cnxpool.connect() + try: + conn.start_transaction() + c = conn.cursor(dictionary=True) + + sql = "SELECT * FROM livestreams WHERE id = %s" + c.execute(sql, [livestream_id]) + row = c.fetchone() + if row is None: + raise HttpException("cannot get stats of not found livestream", BAD_REQUEST) + + sql = "SELECT * FROM livestreams" + c.execute(sql) + livestreams = c.fetchall() + if livestreams is None: + raise HttpException("failed to get livestreams", INTERNAL_SERVER_ERROR) + + # ランク算出 + ranking = [] + for livestream in livestreams: + sql = "SELECT COUNT(*) FROM livestreams l INNER JOIN reactions r ON l.id = r.livestream_id WHERE l.id = %s" + c.execute(sql, [livestream_id]) + reactions = c.fetchone() + if reactions is None: + raise HttpException( + "failed to get livestream", + INTERNAL_SERVER_ERROR, + ) + reactions = reactions["COUNT(*)"] + + sql = "SELECT IFNULL(SUM(l2.tip), 0) FROM livestreams l INNER JOIN livecomments l2 ON l.id = l2.livestream_id WHERE l.id = %s" + c.execute(sql, [livestream_id]) + total_tips = c.fetchone() + if total_tips is None: + raise HttpException("failed to count tips", INTERNAL_SERVER_ERROR) + total_tips = total_tips["IFNULL(SUM(l2.tip), 0)"] + + score = reactions + total_tips + ranking.append( + asdict( + models.LiveStreamRankingEntry( + livestream_id=livestream["id"], + score=score, + ) + ) + ) + ranking = sorted(ranking, key=lambda x: x["score"]) + + rank = 1 + i = len(ranking) - 1 + while i >= 0: + entry = ranking[i] + if entry["livestream_id"] == livestream_id: + break + rank += 1 + i -= 1 + + # 視聴者数算出 + sql = "SELECT COUNT(*) FROM livestreams l INNER JOIN livestream_viewers_history h ON h.livestream_id = l.id WHERE l.id = %s" + c.execute(sql, [livestream_id]) + viewers_count = c.fetchone() + if viewers_count is None: + raise HttpException( + "failed to get viewers_count", + INTERNAL_SERVER_ERROR, + ) + viewers_count = viewers_count["COUNT(*)"] + + # 最大チップ額 + sql = "SELECT IFNULL(MAX(tip), 0) FROM livestreams l INNER JOIN livecomments l2 ON l2.livestream_id = l.id WHERE l.id = %s" + c.execute(sql, [livestream_id]) + max_tip = c.fetchone() + if max_tip is None: + raise HttpException( + "failed to get max_tip", + INTERNAL_SERVER_ERROR, + ) + max_tip = max_tip["IFNULL(MAX(tip), 0)"] + + # リアクション数 + sql = "SELECT COUNT(*) FROM livestreams l INNER JOIN reactions r ON r.livestream_id = l.id WHERE l.id = %s" + c.execute(sql, [livestream_id]) + total_reactions = c.fetchone() + if total_reactions is None: + raise HttpException( + "failed to get total_reactions", + INTERNAL_SERVER_ERROR, + ) + total_reactions = total_reactions["COUNT(*)"] + + # スパム報告数 + sql = "SELECT COUNT(*) FROM livestreams l INNER JOIN livecomment_reports r ON r.livestream_id = l.id WHERE l.id = %s" + c.execute(sql, [livestream_id]) + total_reports = c.fetchone() + if total_reports is None: + raise HttpException( + "failed to get total_reports", + INTERNAL_SERVER_ERROR, + ) + total_reports = total_reports["COUNT(*)"] + + user_statistics = models.LiveStreamStatistics( + rank=rank, + viewers_count=viewers_count, + total_reactions=total_reactions, + total_reports=total_reports, + max_tip=max_tip, + ) + return asdict(user_statistics), OK + except DatabaseError as err: + conn.rollback() + raise err + finally: + conn.commit() + conn.close() + + +# 課金情報 +@app.route("/api/payment", methods=["GET"]) +def get_payment_result() -> tuple[dict[str, Any], int]: + conn = cnxpool.connect() + try: + conn.start_transaction() + c = conn.cursor(dictionary=True) + sql = "SELECT IFNULL(SUM(tip), 0) AS sum_tip FROM livecomments" + c.execute(sql) + total_tip = c.fetchone() + if not total_tip: + raise HttpException("failed to count total tip", INTERNAL_SERVER_ERROR) + return {"total_tip": total_tip}, OK + except DatabaseError as err: + conn.rollback() + raise err + finally: + conn.commit() + conn.close() + + +def verify_user_session() -> None: + sess = session.get(Settings.DEFAULT_SESSION_ID_KEY) + if not sess: + raise HttpException("invalid session", INTERNAL_SERVER_ERROR) + + session_expires = session.get(Settings.DEFAULT_SESSION_EXPIRES_KEY) + if not session_expires: + raise HttpException("forbidden", FORBIDDEN) + + now = datetime.now() + if int(now.timestamp()) > session_expires: + raise HttpException("session has expired", UNAUTHORIZED) + + return + + +def fill_livecomment_response( + c: mysql.connector.cursor.MySQLCursorDict, + livecomment_model: models.LiveCommentModel, +) -> models.LiveComment: + sql = "SELECT * FROM users WHERE id = %s" + c.execute(sql, [livecomment_model.user_id]) + row = c.fetchone() + if not row: + app.logger.error("failed to get comment_owner_model") + raise HttpException("failed to get comment_owner_model", INTERNAL_SERVER_ERROR) + comment_owner_model = models.UserModel(**row) + + comment_owner = fill_user_response(c, comment_owner_model) + + sql = "SELECT * FROM livestreams WHERE id = %s" + c.execute(sql, [livecomment_model.livestream_id]) + row = c.fetchone() + if not row: + app.logger.error("failed to get livestream_model") + raise HttpException("failed to get livestream_model", INTERNAL_SERVER_ERROR) + + livestream_model = models.LiveStreamModel(**row) + + livestream = fill_livestream_response(c, livestream_model) + + return models.LiveComment( + id=livecomment_model.id, + user=comment_owner, + livestream=livestream, + comment=livecomment_model.comment, + tip=livecomment_model.tip, + created_at=livecomment_model.created_at, + ) + + +def fill_reaction_response( + c: mysql.connector.cursor.MySQLCursorDict, + reaction_model: models.ReactionModel, +) -> models.Reaction: + sql = "SELECT * FROM users WHERE id = %s" + c.execute(sql, [reaction_model.user_id]) + row = c.fetchone() + if not row: + app.logger.error("failed to get user_model") + raise HttpException("failed to get user_model", INTERNAL_SERVER_ERROR) + user_model = models.UserModel(**row) + + user = fill_user_response(c, user_model) + + sql = "SELECT * FROM livestreams WHERE id = %s" + c.execute(sql, [reaction_model.livestream_id]) + row = c.fetchone() + if not row: + app.logger.error("failed to get livestream_model") + raise HttpException("livestream_model", INTERNAL_SERVER_ERROR) + livestream_model = models.LiveStreamModel(**row) + + livestream = fill_livestream_response(c, livestream_model) + + return models.Reaction( + id=reaction_model.id, + user=user, + livestream=livestream, + emoji_name=reaction_model.emoji_name, + created_at=reaction_model.created_at, + ) + + +def fill_livecomment_report_response( + c: mysql.connector.cursor.MySQLCursorDict, + report_model: models.LiveCommentReportModel, +) -> models.LiveCommentReport: + sql = "SELECT * FROM users WHERE id = %s" + c.execute(sql, [report_model.user_id]) + row = c.fetchone() + if not row: + raise HttpException("failed to get reporter user", INTERNAL_SERVER_ERROR) + reporter_model = models.UserModel(**row) + + reporter = fill_user_response(c, reporter_model) + + sql = "SELECT * FROM livecomments WHERE id = %s" + c.execute(sql, [report_model.livecomment_id]) + row = c.fetchone() + if row is None: + raise HttpException("failed to get livecomment", INTERNAL_SERVER_ERROR) + livecomment_model = models.LiveCommentModel(**row) + + livecomment = fill_livecomment_response(c, livecomment_model) + + return models.LiveCommentReport( + id=report_model.id, + reporter=reporter, + livecomment=livecomment, + created_at=report_model.created_at, + ) + + +def fill_livestream_response( + c: mysql.connector.cursor.MySQLCursorDict, + livestream_model: models.LiveStreamModel, +) -> models.LiveStream: + sql = "SELECT * FROM users WHERE id = %s" + c.execute(sql, [livestream_model.user_id]) + row = c.fetchone() + if not row: + raise HttpException("failed to get owner_model", INTERNAL_SERVER_ERROR) + owner_model = models.UserModel(**row) + + owner = fill_user_response(c, owner_model) + + sql = "SELECT * FROM livestream_tags WHERE livestream_id = %s" + c.execute(sql, [livestream_model.id]) + rows = c.fetchall() + + tags = [] + for row in rows: + livestream_tag = models.LiveStreamTagModel(**row) + sql = "SELECT * FROM tags WHERE id = %s" + c.execute(sql, [livestream_tag.tag_id]) + tag_row = c.fetchone() + if not tag_row: + raise HttpException("failed to get tags", INTERNAL_SERVER_ERROR) + tag = models.Tag(**tag_row) + tags.append(tag) + + livestream = models.LiveStream( + id=livestream_model.id, + owner=owner, + title=livestream_model.title, + tags=tags, + description=livestream_model.description, + playlist_url=livestream_model.playlist_url, + thumbnail_url=livestream_model.thumbnail_url, + start_at=livestream_model.start_at, + end_at=livestream_model.end_at, + ) + return livestream + + +def fill_user_response( + c: mysql.connector.cursor.MySQLCursorDict, user_model: models.UserModel +) -> models.User: + sql = "SELECT * FROM themes WHERE user_id = %s" + c.execute(sql, [user_model.id]) + row = c.fetchone() + if row is None: + raise HttpException("not found", NOT_FOUND) + theme_model = models.ThemeModel(**row) + + sql = "SELECT image FROM icons WHERE user_id = %s" + c.execute(sql, [user_model.id]) + image_row = c.fetchone() + if not image_row: + image = open(Settings.FALLBACK_IMAGE, "rb").read() + else: + image = io.BytesIO(image_row["image"]).getvalue() + icon_hash = hashlib.sha256(image).hexdigest() + + user = models.User( + id=user_model.id, + name=user_model.name, + display_name=user_model.display_name, + description=user_model.description, + theme=models.Theme(id=theme_model.id, dark_mode=theme_model.dark_mode), + icon_hash=icon_hash, + ) + + return user + + +# Content-Type付けてこないクライアントからのJSONリクエストボディを +# いい感じに受け取れるようにするやつ +def get_request_json() -> Any: + return json.loads(request.get_data().decode()) + + +class HttpException(Exception): + status_code = 500 + + def __init__(self, message: str, status_code: int): + Exception.__init__(self) + self.message = message + self.status_code = status_code + + def get_response(self) -> tuple[dict[str, Any], int]: + return { + "error": f"code={self.status_code}, message={self.message}", + "message": self.message, + }, self.status_code + + +@app.errorhandler(HttpException) +def handle_http_exception(error: Any) -> Any: + return error.get_response() + + +if __name__ == "__main__": + if not Settings.POWERDNS_SUBDOMAIN_ADDRESS: + app.logger.critical("environ POWERDNS_SUBDOMAIN_ADDRESS must be provided") + sys.exit(1) + + global cnxpool + cnxpool = QueuePool( + lambda: mysql.connector.connect( + host=Settings.DB_HOST, + port=Settings.DB_PORT, + user=Settings.DB_USER, + password=Settings.DB_PASSWORD, + database=Settings.DB_NAME, + ), + pool_size=100, + ) + app.secret_key = Settings.SESSION_SECRET_KEY + app.config["SESSION_COOKIE_DOMAIN"] = Settings.SESSION_COOKIE_DOMAIN + app.config["SESSION_COOKIE_PATH"] = Settings.SESSION_COOKIE_PATH + app.config["PERMANENT_SESSION_LIFETIME"] = Settings.PERMANENT_SESSION_LIFETIME + app.run(host="0.0.0.0", port=Settings.LISTEN_PORT, debug=True, threaded=True) diff --git a/webapp/python/models.py b/webapp/python/models.py new file mode 100644 index 000000000..fe266281f --- /dev/null +++ b/webapp/python/models.py @@ -0,0 +1,196 @@ +from dataclasses import dataclass + + +@dataclass +class Theme: + id: int + dark_mode: bool + + +@dataclass +class ThemeModel: + id: int + user_id: int + dark_mode: bool + + def __init__(self, id: int, user_id: int, dark_mode: int) -> None: + self.id = id + self.user_id = user_id + self.dark_mode = dark_mode == 1 + + +@dataclass +class UserModel: + id: int + name: str + display_name: str + description: str + password: str # hashed + + +@dataclass +class User: + id: int + name: str + display_name: str + description: str + theme: Theme + icon_hash: str + + +@dataclass +class Tag: + id: int + name: str + + +@dataclass +class Tags: + tags: list[Tag] + + +@dataclass +class LiveStreamModel: + id: int + user_id: int + title: str + description: str + playlist_url: str + thumbnail_url: str + start_at: int + end_at: int + + def __init__( + self, + id: int, + user_id: int, + title: str, + description: str, + playlist_url: str, + thumbnail_url: str, + start_at: int | str, + end_at: int | str, + ) -> None: + self.id = id + self.user_id = user_id + self.title = title + self.description = description + self.playlist_url = playlist_url + self.thumbnail_url = thumbnail_url + self.start_at = int(start_at) + self.end_at = int(end_at) + + +@dataclass +class LiveStream: + id: int + owner: User + title: str + description: str + playlist_url: str + thumbnail_url: str + tags: list[Tag] + start_at: int + end_at: int + + +@dataclass +class LiveStreamTagModel: + id: int + livestream_id: int + tag_id: int + + +@dataclass +class LiveCommentModel: + id: int + user_id: int + livestream_id: int + comment: str + tip: int + created_at: int + + +@dataclass +class LiveComment: + id: int + user: User + livestream: LiveStream + comment: str + tip: int + created_at: int + + +@dataclass +class ReactionModel: + id: int + emoji_name: str + user_id: int + livestream_id: int + created_at: int + + +@dataclass +class Reaction: + id: int + emoji_name: str + user: User + livestream: LiveStream + created_at: int + + +@dataclass +class LiveCommentReportModel: + id: int + user_id: int + livestream_id: int + livecomment_id: int + created_at: int + + +@dataclass +class LiveCommentReport: + id: int + reporter: User + livecomment: LiveComment + created_at: int + + +@dataclass +class LiveStreamStatistics: + rank: int + viewers_count: int + total_reactions: int + total_reports: int + max_tip: int + + +@dataclass +class LiveStreamRankingEntry: + livestream_id: int + score: int + + +@dataclass +class UserStatistics: + rank: int + viewers_count: int + total_reactions: int + total_livecomments: int + total_tip: int + favorite_emoji: str + + +@dataclass +class UserRankingEntry: + username: str + score: int + + +@dataclass +class NGWord: + id: int + user_id: int + livestream_id: int + word: str + created_at: int