├── var └── .keep ├── .dockerignore ├── src ├── util │ ├── mod.rs │ └── vec.rs ├── external │ ├── fixtures │ │ ├── webhook-secret.txt │ │ ├── webhook-url.txt │ │ └── webhook-sig.txt │ ├── google.rs │ ├── reddit.rs │ ├── discord.rs │ └── mod.rs ├── bin │ └── server.rs ├── handlers │ ├── user_agent.rs │ ├── player.rs │ ├── settings.rs │ ├── push.rs │ ├── stats.rs │ ├── index.rs │ ├── webhook.rs │ ├── heya.rs │ └── mod.rs ├── data │ ├── award.rs │ └── mod.rs └── lib.rs ├── .prettierignore ├── .nova ├── Artwork ├── Configuration.json └── Tasks │ └── Cargo.json ├── sql ├── 20191105-external-link.sql ├── blank.sqlite ├── 20191106-pick-rikishi-id.sql ├── 20191108-player-name-nocase.sql ├── 20200210-yurikat.sql ├── 20191102-basho-score.sql ├── 20230605-player_rank.sql ├── 20191103-player-google.sql ├── 20230226-player-push-subscriptions.sql ├── merge-players.sql ├── 20210103-kyujo.sql ├── 20240725-heya.sql ├── 20191104-player-info.sql ├── 20191106-player-reddit.sql ├── 20191129-basho-result.sql └── 20191026-init.sql ├── art ├── 2021-BG.pxd ├── 2021-Layout-example.png ├── Player-Rankings layers.pxd ├── 2021 site icon scaled 512.pxd └── dark mode images │ └── 2021-Rules-and-Banzuke-Picks-Dark.png ├── README.md ├── scripts ├── log.sh ├── backup.sh ├── docker-build ├── Dockerfile ├── kachiclash.service ├── dev.sh.example ├── kachiclash-levelone.service ├── kachiclash-beta.service ├── install.sh ├── Sumo Banzuke Process J.sublime-macro └── Sumo Banzuke Process M.sublime-macro ├── public ├── img │ ├── banner-1.jpg │ ├── banner-2.jpg │ ├── favicon.png │ ├── header-bg.jpg │ ├── header-logo.png │ ├── hoshi-black.png │ ├── hoshi-dash.png │ ├── hoshi-white.png │ ├── tegatastore.png │ ├── oicho-silhouette.png │ ├── banner-levelone-1.jpg │ ├── background-gray-wave.jpg │ └── social │ │ ├── google.svg │ │ ├── reddit.svg │ │ └── discord.svg ├── img2 │ ├── 2021-BG.jpg │ ├── 2021-BG.webp │ ├── banzuke │ │ ├── Rope.png │ │ ├── Gunbai.png │ │ ├── Gunbai.webp │ │ ├── Rope.webp │ │ ├── Rules.png │ │ ├── Rules.webp │ │ ├── Number-1.png │ │ ├── Number-2.png │ │ ├── Number-3.png │ │ ├── Wood-BG.webp │ │ ├── Banzuke-Picks.png │ │ ├── Banzuke-Picks.webp │ │ ├── Number-1-dark.png │ │ ├── Number-2-dark.png │ │ ├── Number-3-dark.png │ │ └── Wood-BG-dark.webp │ ├── 2021-BG-dark.webp │ ├── 2021-Basho-Champion.png │ ├── 2021-Top-Banner-BG.png │ ├── 2021-Top-Banner-Logo.png │ ├── 2021-Site-Icon-200x200.png │ ├── 2021-Basho-Champion-Dark.png │ ├── 2021-Current-Tournament.png │ ├── 2021-Kachi-Clash-Banner-2.png │ ├── 2021-Top-Banner-BG-dark.png │ ├── player-ranking │ │ ├── dark-bg.webp │ │ ├── dark-mask.webp │ │ ├── light-bg.webp │ │ └── light-mask.webp │ ├── 2021-Kachi-Clash-Banner-2.webp │ ├── 2021-Site-Icon-200x200-dark.png │ ├── 2021-Top-Banner-Image-1899x56.png │ └── 2021-Kachi-Clash-Banner-3-Dark.webp ├── icon │ ├── icon-512.png │ └── icon-512.webp ├── scss │ ├── _media.scss │ ├── admin.scss │ ├── stats.scss │ ├── heya.scss │ ├── settings.scss │ ├── player.scss │ └── index.scss ├── fonts │ ├── exo2-black-webfont.woff │ ├── exo2-bold-webfont.woff │ ├── exo2-bold-webfont.woff2 │ ├── exo2-light-webfont.woff │ ├── exo2-black-webfont.woff2 │ ├── exo2-italic-webfont.woff │ ├── exo2-italic-webfont.woff2 │ ├── exo2-light-webfont.woff2 │ ├── exo2-medium-webfont.woff │ ├── exo2-medium-webfont.woff2 │ ├── exo2-regular-webfont.woff │ ├── exo2-bolditalic-webfont.woff │ ├── exo2-extrabold-webfont.woff │ ├── exo2-extrabold-webfont.woff2 │ ├── exo2-extralight-webfont.woff │ ├── exo2-regular-webfont.woff2 │ ├── exo2-blackitalic-webfont.woff │ ├── exo2-blackitalic-webfont.woff2 │ ├── exo2-bolditalic-webfont.woff2 │ ├── exo2-extralight-webfont.woff2 │ ├── exo2-lightitalic-webfont.woff │ ├── exo2-lightitalic-webfont.woff2 │ ├── exo2-mediumitalic-webfont.woff │ ├── exo2-extrabolditalic-webfont.woff │ ├── exo2-mediumitalic-webfont.woff2 │ ├── exo2-extrabolditalic-webfont.woff2 │ ├── exo2-extralightitalic-webfont.woff │ ├── exo2-extralightitalic-webfont.woff2 │ └── stylesheet.css ├── levelone │ └── 2021-banner-levelone.png ├── app.webmanifest └── ts │ ├── heya.ts │ ├── admin.ts │ ├── push.ts │ ├── service-worker.ts │ ├── basho.ts │ ├── torikumi.ts │ ├── edit_basho.ts │ ├── basho-admin.ts │ ├── login.ts │ ├── base.ts │ ├── settings.ts │ └── service-client.ts ├── .prettierrc.yml ├── .git-blame-ignore-revs ├── kachiclash.sublime-project ├── .idea ├── misc.xml ├── vcs.xml ├── dictionaries │ └── daniel.xml ├── modules.xml ├── sqldialects.xml └── kachiclash.iml ├── .gitignore ├── tsconfig.json ├── .zed └── settings.json ├── eslint.config.js ├── templates ├── player_listing.html ├── list_players.html ├── torikumi.html ├── admin_page.html ├── edit_basho.html ├── heya_list.html ├── login.html ├── settings.html ├── stats.html ├── heya.html ├── base.html ├── index.html └── player.html ├── .github ├── dependabot.yml └── workflows │ └── build.yml ├── package.json ├── LICENSE └── Cargo.toml /var/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | target 2 | -------------------------------------------------------------------------------- /src/util/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod vec; 2 | pub use vec::GroupRuns; 3 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | /*.sublime-project 2 | /.nova/ 3 | /scripts/ 4 | fixtures/ 5 | -------------------------------------------------------------------------------- /src/external/fixtures/webhook-secret.txt: -------------------------------------------------------------------------------- 1 | this_is_the_webhook_secret_for_beta 2 | -------------------------------------------------------------------------------- /.nova/Artwork: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danieldickison/kachiclash/HEAD/.nova/Artwork -------------------------------------------------------------------------------- /sql/20191105-external-link.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE basho 2 | ADD COLUMN external_link TEXT; 3 | -------------------------------------------------------------------------------- /src/external/fixtures/webhook-url.txt: -------------------------------------------------------------------------------- 1 | https://beta.kachiclash.com/webhook/sumo_api 2 | -------------------------------------------------------------------------------- /art/2021-BG.pxd: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danieldickison/kachiclash/HEAD/art/2021-BG.pxd -------------------------------------------------------------------------------- /sql/blank.sqlite: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danieldickison/kachiclash/HEAD/sql/blank.sqlite -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Kachi Clash 2 | 3 | A Grand Sumo prediction game. 4 | 5 | https://kachiclash.com 6 | -------------------------------------------------------------------------------- /scripts/log.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | journalctl -u kachiclash -f --lines=1000 -o cat | less -S 4 | -------------------------------------------------------------------------------- /public/img/banner-1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danieldickison/kachiclash/HEAD/public/img/banner-1.jpg -------------------------------------------------------------------------------- /public/img/banner-2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danieldickison/kachiclash/HEAD/public/img/banner-2.jpg -------------------------------------------------------------------------------- /public/img/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danieldickison/kachiclash/HEAD/public/img/favicon.png -------------------------------------------------------------------------------- /public/img2/2021-BG.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danieldickison/kachiclash/HEAD/public/img2/2021-BG.jpg -------------------------------------------------------------------------------- /sql/20191106-pick-rikishi-id.sql: -------------------------------------------------------------------------------- 1 | CREATE INDEX pick__rikishi_id_basho_id ON pick (rikishi_id, basho_id); 2 | -------------------------------------------------------------------------------- /src/external/fixtures/webhook-sig.txt: -------------------------------------------------------------------------------- 1 | 7bbaa2036a1df64bfd5ece496d9bce0a71bb591ee61befaf8ce1c2ad3fe0f555 2 | -------------------------------------------------------------------------------- /public/icon/icon-512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danieldickison/kachiclash/HEAD/public/icon/icon-512.png -------------------------------------------------------------------------------- /public/icon/icon-512.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danieldickison/kachiclash/HEAD/public/icon/icon-512.webp -------------------------------------------------------------------------------- /public/img/header-bg.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danieldickison/kachiclash/HEAD/public/img/header-bg.jpg -------------------------------------------------------------------------------- /public/img/header-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danieldickison/kachiclash/HEAD/public/img/header-logo.png -------------------------------------------------------------------------------- /public/img/hoshi-black.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danieldickison/kachiclash/HEAD/public/img/hoshi-black.png -------------------------------------------------------------------------------- /public/img/hoshi-dash.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danieldickison/kachiclash/HEAD/public/img/hoshi-dash.png -------------------------------------------------------------------------------- /public/img/hoshi-white.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danieldickison/kachiclash/HEAD/public/img/hoshi-white.png -------------------------------------------------------------------------------- /public/img/tegatastore.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danieldickison/kachiclash/HEAD/public/img/tegatastore.png -------------------------------------------------------------------------------- /public/img2/2021-BG.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danieldickison/kachiclash/HEAD/public/img2/2021-BG.webp -------------------------------------------------------------------------------- /art/2021-Layout-example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danieldickison/kachiclash/HEAD/art/2021-Layout-example.png -------------------------------------------------------------------------------- /public/img2/banzuke/Rope.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danieldickison/kachiclash/HEAD/public/img2/banzuke/Rope.png -------------------------------------------------------------------------------- /art/Player-Rankings layers.pxd: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danieldickison/kachiclash/HEAD/art/Player-Rankings layers.pxd -------------------------------------------------------------------------------- /public/img/oicho-silhouette.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danieldickison/kachiclash/HEAD/public/img/oicho-silhouette.png -------------------------------------------------------------------------------- /public/img2/2021-BG-dark.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danieldickison/kachiclash/HEAD/public/img2/2021-BG-dark.webp -------------------------------------------------------------------------------- /public/img2/banzuke/Gunbai.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danieldickison/kachiclash/HEAD/public/img2/banzuke/Gunbai.png -------------------------------------------------------------------------------- /public/img2/banzuke/Gunbai.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danieldickison/kachiclash/HEAD/public/img2/banzuke/Gunbai.webp -------------------------------------------------------------------------------- /public/img2/banzuke/Rope.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danieldickison/kachiclash/HEAD/public/img2/banzuke/Rope.webp -------------------------------------------------------------------------------- /public/img2/banzuke/Rules.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danieldickison/kachiclash/HEAD/public/img2/banzuke/Rules.png -------------------------------------------------------------------------------- /public/img2/banzuke/Rules.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danieldickison/kachiclash/HEAD/public/img2/banzuke/Rules.webp -------------------------------------------------------------------------------- /art/2021 site icon scaled 512.pxd: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danieldickison/kachiclash/HEAD/art/2021 site icon scaled 512.pxd -------------------------------------------------------------------------------- /public/img/banner-levelone-1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danieldickison/kachiclash/HEAD/public/img/banner-levelone-1.jpg -------------------------------------------------------------------------------- /public/img2/banzuke/Number-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danieldickison/kachiclash/HEAD/public/img2/banzuke/Number-1.png -------------------------------------------------------------------------------- /public/img2/banzuke/Number-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danieldickison/kachiclash/HEAD/public/img2/banzuke/Number-2.png -------------------------------------------------------------------------------- /public/img2/banzuke/Number-3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danieldickison/kachiclash/HEAD/public/img2/banzuke/Number-3.png -------------------------------------------------------------------------------- /public/img2/banzuke/Wood-BG.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danieldickison/kachiclash/HEAD/public/img2/banzuke/Wood-BG.webp -------------------------------------------------------------------------------- /public/scss/_media.scss: -------------------------------------------------------------------------------- 1 | $narrow: 680px; 2 | $wide: 681px; 3 | $medium-width: 800px; 4 | $short: 750px; 5 | $tall: 751px; 6 | -------------------------------------------------------------------------------- /public/fonts/exo2-black-webfont.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danieldickison/kachiclash/HEAD/public/fonts/exo2-black-webfont.woff -------------------------------------------------------------------------------- /public/fonts/exo2-bold-webfont.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danieldickison/kachiclash/HEAD/public/fonts/exo2-bold-webfont.woff -------------------------------------------------------------------------------- /public/fonts/exo2-bold-webfont.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danieldickison/kachiclash/HEAD/public/fonts/exo2-bold-webfont.woff2 -------------------------------------------------------------------------------- /public/fonts/exo2-light-webfont.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danieldickison/kachiclash/HEAD/public/fonts/exo2-light-webfont.woff -------------------------------------------------------------------------------- /public/img/background-gray-wave.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danieldickison/kachiclash/HEAD/public/img/background-gray-wave.jpg -------------------------------------------------------------------------------- /public/img2/2021-Basho-Champion.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danieldickison/kachiclash/HEAD/public/img2/2021-Basho-Champion.png -------------------------------------------------------------------------------- /public/img2/2021-Top-Banner-BG.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danieldickison/kachiclash/HEAD/public/img2/2021-Top-Banner-BG.png -------------------------------------------------------------------------------- /public/img2/2021-Top-Banner-Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danieldickison/kachiclash/HEAD/public/img2/2021-Top-Banner-Logo.png -------------------------------------------------------------------------------- /public/fonts/exo2-black-webfont.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danieldickison/kachiclash/HEAD/public/fonts/exo2-black-webfont.woff2 -------------------------------------------------------------------------------- /public/fonts/exo2-italic-webfont.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danieldickison/kachiclash/HEAD/public/fonts/exo2-italic-webfont.woff -------------------------------------------------------------------------------- /public/fonts/exo2-italic-webfont.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danieldickison/kachiclash/HEAD/public/fonts/exo2-italic-webfont.woff2 -------------------------------------------------------------------------------- /public/fonts/exo2-light-webfont.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danieldickison/kachiclash/HEAD/public/fonts/exo2-light-webfont.woff2 -------------------------------------------------------------------------------- /public/fonts/exo2-medium-webfont.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danieldickison/kachiclash/HEAD/public/fonts/exo2-medium-webfont.woff -------------------------------------------------------------------------------- /public/fonts/exo2-medium-webfont.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danieldickison/kachiclash/HEAD/public/fonts/exo2-medium-webfont.woff2 -------------------------------------------------------------------------------- /public/fonts/exo2-regular-webfont.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danieldickison/kachiclash/HEAD/public/fonts/exo2-regular-webfont.woff -------------------------------------------------------------------------------- /public/img2/2021-Site-Icon-200x200.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danieldickison/kachiclash/HEAD/public/img2/2021-Site-Icon-200x200.png -------------------------------------------------------------------------------- /public/img2/banzuke/Banzuke-Picks.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danieldickison/kachiclash/HEAD/public/img2/banzuke/Banzuke-Picks.png -------------------------------------------------------------------------------- /public/img2/banzuke/Banzuke-Picks.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danieldickison/kachiclash/HEAD/public/img2/banzuke/Banzuke-Picks.webp -------------------------------------------------------------------------------- /public/img2/banzuke/Number-1-dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danieldickison/kachiclash/HEAD/public/img2/banzuke/Number-1-dark.png -------------------------------------------------------------------------------- /public/img2/banzuke/Number-2-dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danieldickison/kachiclash/HEAD/public/img2/banzuke/Number-2-dark.png -------------------------------------------------------------------------------- /public/img2/banzuke/Number-3-dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danieldickison/kachiclash/HEAD/public/img2/banzuke/Number-3-dark.png -------------------------------------------------------------------------------- /public/img2/banzuke/Wood-BG-dark.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danieldickison/kachiclash/HEAD/public/img2/banzuke/Wood-BG-dark.webp -------------------------------------------------------------------------------- /public/fonts/exo2-bolditalic-webfont.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danieldickison/kachiclash/HEAD/public/fonts/exo2-bolditalic-webfont.woff -------------------------------------------------------------------------------- /public/fonts/exo2-extrabold-webfont.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danieldickison/kachiclash/HEAD/public/fonts/exo2-extrabold-webfont.woff -------------------------------------------------------------------------------- /public/fonts/exo2-extrabold-webfont.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danieldickison/kachiclash/HEAD/public/fonts/exo2-extrabold-webfont.woff2 -------------------------------------------------------------------------------- /public/fonts/exo2-extralight-webfont.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danieldickison/kachiclash/HEAD/public/fonts/exo2-extralight-webfont.woff -------------------------------------------------------------------------------- /public/fonts/exo2-regular-webfont.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danieldickison/kachiclash/HEAD/public/fonts/exo2-regular-webfont.woff2 -------------------------------------------------------------------------------- /public/img2/2021-Basho-Champion-Dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danieldickison/kachiclash/HEAD/public/img2/2021-Basho-Champion-Dark.png -------------------------------------------------------------------------------- /public/img2/2021-Current-Tournament.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danieldickison/kachiclash/HEAD/public/img2/2021-Current-Tournament.png -------------------------------------------------------------------------------- /public/img2/2021-Kachi-Clash-Banner-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danieldickison/kachiclash/HEAD/public/img2/2021-Kachi-Clash-Banner-2.png -------------------------------------------------------------------------------- /public/img2/2021-Top-Banner-BG-dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danieldickison/kachiclash/HEAD/public/img2/2021-Top-Banner-BG-dark.png -------------------------------------------------------------------------------- /public/img2/player-ranking/dark-bg.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danieldickison/kachiclash/HEAD/public/img2/player-ranking/dark-bg.webp -------------------------------------------------------------------------------- /public/img2/player-ranking/dark-mask.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danieldickison/kachiclash/HEAD/public/img2/player-ranking/dark-mask.webp -------------------------------------------------------------------------------- /public/img2/player-ranking/light-bg.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danieldickison/kachiclash/HEAD/public/img2/player-ranking/light-bg.webp -------------------------------------------------------------------------------- /public/levelone/2021-banner-levelone.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danieldickison/kachiclash/HEAD/public/levelone/2021-banner-levelone.png -------------------------------------------------------------------------------- /sql/20191108-player-name-nocase.sql: -------------------------------------------------------------------------------- 1 | CREATE UNIQUE INDEX player__name_nocase ON player (name COLLATE NOCASE); 2 | DROP INDEX player__name; 3 | -------------------------------------------------------------------------------- /public/fonts/exo2-blackitalic-webfont.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danieldickison/kachiclash/HEAD/public/fonts/exo2-blackitalic-webfont.woff -------------------------------------------------------------------------------- /public/fonts/exo2-blackitalic-webfont.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danieldickison/kachiclash/HEAD/public/fonts/exo2-blackitalic-webfont.woff2 -------------------------------------------------------------------------------- /public/fonts/exo2-bolditalic-webfont.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danieldickison/kachiclash/HEAD/public/fonts/exo2-bolditalic-webfont.woff2 -------------------------------------------------------------------------------- /public/fonts/exo2-extralight-webfont.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danieldickison/kachiclash/HEAD/public/fonts/exo2-extralight-webfont.woff2 -------------------------------------------------------------------------------- /public/fonts/exo2-lightitalic-webfont.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danieldickison/kachiclash/HEAD/public/fonts/exo2-lightitalic-webfont.woff -------------------------------------------------------------------------------- /public/fonts/exo2-lightitalic-webfont.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danieldickison/kachiclash/HEAD/public/fonts/exo2-lightitalic-webfont.woff2 -------------------------------------------------------------------------------- /public/fonts/exo2-mediumitalic-webfont.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danieldickison/kachiclash/HEAD/public/fonts/exo2-mediumitalic-webfont.woff -------------------------------------------------------------------------------- /public/img2/2021-Kachi-Clash-Banner-2.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danieldickison/kachiclash/HEAD/public/img2/2021-Kachi-Clash-Banner-2.webp -------------------------------------------------------------------------------- /public/img2/2021-Site-Icon-200x200-dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danieldickison/kachiclash/HEAD/public/img2/2021-Site-Icon-200x200-dark.png -------------------------------------------------------------------------------- /public/img2/player-ranking/light-mask.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danieldickison/kachiclash/HEAD/public/img2/player-ranking/light-mask.webp -------------------------------------------------------------------------------- /public/fonts/exo2-extrabolditalic-webfont.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danieldickison/kachiclash/HEAD/public/fonts/exo2-extrabolditalic-webfont.woff -------------------------------------------------------------------------------- /public/fonts/exo2-mediumitalic-webfont.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danieldickison/kachiclash/HEAD/public/fonts/exo2-mediumitalic-webfont.woff2 -------------------------------------------------------------------------------- /public/img2/2021-Top-Banner-Image-1899x56.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danieldickison/kachiclash/HEAD/public/img2/2021-Top-Banner-Image-1899x56.png -------------------------------------------------------------------------------- /.prettierrc.yml: -------------------------------------------------------------------------------- 1 | plugins: 2 | - prettier-plugin-jinja-template 3 | overrides: 4 | - files: "*.html" 5 | options: 6 | parser: jinja-template 7 | -------------------------------------------------------------------------------- /public/fonts/exo2-extrabolditalic-webfont.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danieldickison/kachiclash/HEAD/public/fonts/exo2-extrabolditalic-webfont.woff2 -------------------------------------------------------------------------------- /public/fonts/exo2-extralightitalic-webfont.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danieldickison/kachiclash/HEAD/public/fonts/exo2-extralightitalic-webfont.woff -------------------------------------------------------------------------------- /public/fonts/exo2-extralightitalic-webfont.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danieldickison/kachiclash/HEAD/public/fonts/exo2-extralightitalic-webfont.woff2 -------------------------------------------------------------------------------- /public/img2/2021-Kachi-Clash-Banner-3-Dark.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danieldickison/kachiclash/HEAD/public/img2/2021-Kachi-Clash-Banner-3-Dark.webp -------------------------------------------------------------------------------- /art/dark mode images/2021-Rules-and-Banzuke-Picks-Dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danieldickison/kachiclash/HEAD/art/dark mode images/2021-Rules-and-Banzuke-Picks-Dark.png -------------------------------------------------------------------------------- /.git-blame-ignore-revs: -------------------------------------------------------------------------------- 1 | # prettier formatting 2 | 9634eaf9f05ff0d1864d6e7ca4cb211c9734bebd 3 | 152985cfdca5d9cefdc9d6764629c20565fa1af6 4 | 13f2fe72dcdd9ee9fed94ed38b20290f5c17a04a 5 | -------------------------------------------------------------------------------- /scripts/backup.sh: -------------------------------------------------------------------------------- 1 | set -e 2 | 3 | VAR=${KC_VAR:-/home/kachiclash/var} 4 | DATE=`date +%Y%m%d` 5 | sudo -u kachiclash tar --zstd -cf $VAR/backup/dbs-$DATE.tar.zstd $VAR/*.sqlite 6 | -------------------------------------------------------------------------------- /kachiclash.sublime-project: -------------------------------------------------------------------------------- 1 | { 2 | "folders": 3 | [ 4 | { 5 | "folder_exclude_patterns": 6 | [ 7 | "target" 8 | ], 9 | "path": "." 10 | } 11 | ] 12 | } 13 | -------------------------------------------------------------------------------- /.idea/misc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | *.sublime-workspace 3 | .DS_Store 4 | /var 5 | /run-dev-server 6 | /public/css 7 | /.idea/workspace.xml 8 | /node_modules/ 9 | /public/js/*.js 10 | /public/js/*.map 11 | *.pem 12 | -------------------------------------------------------------------------------- /.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /scripts/docker-build: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | docker build -t kachiclash \ 4 | -v cargo-git:/home/rust/.cargo/git \ 5 | -v cargo-registry:/home/rust/.cargo/registry \ 6 | -v target:/home/rust/src/target \ 7 | . 8 | -------------------------------------------------------------------------------- /src/bin/server.rs: -------------------------------------------------------------------------------- 1 | extern crate kachiclash; 2 | 3 | use tokio::try_join; 4 | 5 | #[tokio::main] 6 | async fn main() -> anyhow::Result<()> { 7 | let app_state = kachiclash::init_env()?; 8 | try_join!(kachiclash::run_server(&app_state)).map(|_| ()) 9 | } 10 | -------------------------------------------------------------------------------- /.nova/Configuration.json: -------------------------------------------------------------------------------- 1 | { 2 | "workspace.art_style" : 0, 3 | "workspace.color" : 2, 4 | "workspace.name" : "kachiclash", 5 | "workspace.preview_append_paths" : false, 6 | "workspace.preview_type" : "custom", 7 | "workspace.preview_url" : "http:\/\/localhost:8000" 8 | } 9 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "public/js", 4 | "target": "ES2021", 5 | "sourceMap": true, 6 | "strict": true 7 | }, 8 | "include": ["public/**/*.ts", "eslint.config.js"], 9 | "exclude": ["node_modules", "**/*.spec.ts"] 10 | } 11 | -------------------------------------------------------------------------------- /.idea/dictionaries/daniel.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | banzuke 5 | honbasho 6 | makuuchi 7 | rikishi 8 | torikumi 9 | 10 | 11 | -------------------------------------------------------------------------------- /.idea/modules.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /sql/20200210-yurikat.sql: -------------------------------------------------------------------------------- 1 | UPDATE external_basho_player SET name = 'YurikaT' WHERE name = 'Yurika'; 2 | UPDATE external_basho_player SET name = 'steenbless' WHERE name = 'Ryuden'; 3 | UPDATE external_basho_player SET name = 'steenbless' WHERE name = 'therecanonlybeonedragon'; 4 | 5 | UPDATE external_basho_player SET name = 'Hidoiokami' WHERE name = 'mdshields7'; 6 | -------------------------------------------------------------------------------- /sql/20191102-basho-score.sql: -------------------------------------------------------------------------------- 1 | CREATE VIEW basho_score (basho_id, player_id, wins) 2 | AS SELECT 3 | pick.basho_id, 4 | pick.player_id, 5 | COALESCE(SUM(torikumi.win), 0) AS wins 6 | FROM pick 7 | LEFT JOIN torikumi 8 | ON torikumi.rikishi_id = pick.rikishi_id 9 | AND torikumi.basho_id = pick.basho_id 10 | GROUP BY pick.basho_id, pick.player_id; 11 | -------------------------------------------------------------------------------- /.idea/sqldialects.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /.zed/settings.json: -------------------------------------------------------------------------------- 1 | // Folder-specific settings 2 | // 3 | // For a full list of overridable settings, and general information on folder-specific settings, 4 | // see the documentation: https://zed.dev/docs/configuring-zed#folder-specific-settings 5 | { 6 | "file_types": { 7 | "HTML-Jinja": ["html"] 8 | }, 9 | "languages": { 10 | "HTML-Jinja": { 11 | "tab_size": 2 12 | } 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /sql/20230605-player_rank.sql: -------------------------------------------------------------------------------- 1 | -- Denormalized player rank based on past 6 bashos for efficient display on various pages. 2 | CREATE TABLE player_rank ( 3 | before_basho_id INTEGER NOT NULL, 4 | player_id INTEGER NOT NULL REFERENCES player(id) ON DELETE CASCADE, 5 | rank TEXT NOT NULL, 6 | past_year_wins INTEGER NOT NULL, 7 | 8 | PRIMARY KEY (before_basho_id, player_id) 9 | ); 10 | -------------------------------------------------------------------------------- /sql/20191103-player-google.sql: -------------------------------------------------------------------------------- 1 | CREATE UNIQUE INDEX player__name ON player (name); 2 | 3 | CREATE TABLE player_google ( 4 | player_id INTEGER NOT NULL REFERENCES player(id) ON DELETE CASCADE, 5 | id TEXT NOT NULL UNIQUE, 6 | name TEXT, 7 | picture TEXT, 8 | mod_date TEXT NOT NULL 9 | ); 10 | 11 | CREATE INDEX player_google__player_id ON player_google (player_id); 12 | -------------------------------------------------------------------------------- /sql/20230226-player-push-subscriptions.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE player_push_subscriptions ( 2 | id INTEGER PRIMARY KEY AUTOINCREMENT, 3 | player_id INTEGER NOT NULL REFERENCES player(id) ON DELETE CASCADE, 4 | info_json TEXT NOT NULL UNIQUE, 5 | user_agent TEXT NOT NULL, 6 | opt_in_json TEXT NOT NULL, 7 | create_date INTEGER NOT NULL DEFAULT (cast(strftime('%s', 'now') AS INTEGER)) 8 | ); 9 | -------------------------------------------------------------------------------- /public/scss/admin.scss: -------------------------------------------------------------------------------- 1 | .g-admin-form { 2 | .footnote { 3 | font-size: 0.875rem; 4 | color: #999; 5 | margin: 0; 6 | } 7 | 8 | label { 9 | display: block; 10 | margin-bottom: 10px; 11 | 12 | > input:not([type="checkbox"]) { 13 | display: block; 14 | } 15 | 16 | > textarea { 17 | display: block; 18 | width: 100%; 19 | min-height: 100px; 20 | } 21 | } 22 | } 23 | 24 | #p-new-basho { 25 | #make-basho-form { 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import js from "@eslint/js"; 2 | import tseslint from "typescript-eslint"; 3 | import prettier from "eslint-config-prettier"; 4 | 5 | export default tseslint.config( 6 | js.configs.recommended, 7 | ...tseslint.configs.recommended, 8 | prettier, 9 | { 10 | languageOptions: { 11 | ecmaVersion: "latest", 12 | sourceType: "module", 13 | parserOptions: { 14 | project: "tsconfig.json", 15 | }, 16 | }, 17 | rules: {}, 18 | }, 19 | ); 20 | -------------------------------------------------------------------------------- /public/app.webmanifest: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/web-manifest-combined.json", 3 | "name": "Kachi Clash", 4 | "icons": [ 5 | { 6 | "src": "/static/icon/icon-512.webp", 7 | "type": "image/webp", 8 | "sizes": "512x512" 9 | }, 10 | { 11 | "src": "/static/icon/icon-512.png", 12 | "type": "image/png", 13 | "sizes": "512x512" 14 | } 15 | ], 16 | "start_url": "/pwa", 17 | "scope": "/", 18 | "display": "standalone" 19 | } 20 | -------------------------------------------------------------------------------- /.nova/Tasks/Cargo.json: -------------------------------------------------------------------------------- 1 | { 2 | "actions" : { 3 | "build" : { 4 | "enabled" : true, 5 | "script" : "#!\/bin\/sh\n\nset -e\n\n# export RUST_BACKTRACE=1\n\ncargo test\ncargo build\ncargo doc\n" 6 | }, 7 | "clean" : { 8 | "enabled" : true, 9 | "script" : "cargo clean" 10 | }, 11 | "run" : { 12 | "enabled" : true, 13 | "path" : "run-dev-server" 14 | } 15 | }, 16 | "environment" : { 17 | "RUST_LOG" : "info,kachiclash=trace,rusqlite=trace" 18 | }, 19 | "openLogOnRun" : "start" 20 | } 21 | -------------------------------------------------------------------------------- /templates/player_listing.html: -------------------------------------------------------------------------------- 1 | 2 | 5 | {{ name }} 6 | 7 | {%- match self.rank -%} 8 | {%- when Some(rank) -%} {{ rank }} 9 | {%- when None -%} 10 | {%- endmatch -%} 11 | 12 | {%- if self.has_emperors_cup() -%} 13 | {{ crate::data::award::Award::EmperorsCup.emoji() }} 16 | {%- endif -%} 17 | 18 | -------------------------------------------------------------------------------- /sql/merge-players.sql: -------------------------------------------------------------------------------- 1 | BEGIN; 2 | -- delete old basho data that conflict 3 | DELETE FROM basho_result WHERE player_id = 121 AND basho_id = 202305; 4 | DELETE FROM pick WHERE player_id = 121 AND basho_id = 202305; 5 | 6 | -- move the "from" player data to the "to" player data. 7 | UPDATE OR ROLLBACK pick SET player_id = 121 WHERE player_id = 2243 AND basho_id = 202305; 8 | UPDATE OR ROLLBACK award SET player_id = 121 WHERE player_id = 2243 AND basho_id = 202305; 9 | UPDATE OR ROLLBACK basho_result SET player_id = 121 WHERE player_id = 2243 AND basho_id = 202305; 10 | COMMIT; 11 | -------------------------------------------------------------------------------- /scripts/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM ekidd/rust-musl-builder:latest as builder 2 | ADD . ./ 3 | RUN curl --location -O https://github.com/sass/dart-sass/releases/download/1.22.7/dart-sass-1.22.7-linux-x64.tar.gz && \ 4 | tar xvzf dart-sass-1.22.7-linux-x64.tar.gz 5 | ENV PATH="/home/rust/src/dart-sass:${PATH}" 6 | RUN sudo chown -R rust:rust /home/rust 7 | RUN cargo build --release 8 | 9 | FROM alpine:latest 10 | RUN apk --no-cache add ca-certificates 11 | COPY --from=builder /home/rust/src/target/x86_64-unknown-linux-musl/release/server /usr/local/bin/kachiclash 12 | EXPOSE 8000 13 | CMD ["/usr/local/bin/kachiclash"] 14 | -------------------------------------------------------------------------------- /sql/20210103-kyujo.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE banzuke ADD COLUMN kyujyo INTEGER NOT NULL DEFAULT 0; 2 | 3 | # update banzuke set kyujyo = 1 where basho_id = 202101 and family_name = 'Hakuho'; 4 | # update banzuke set kyujyo = 1 where basho_id = 202101 and family_name = 'Wakatakakage'; 5 | # update banzuke set kyujyo = 1 where basho_id = 202101 and family_name = 'Chiyonokuni'; 6 | # update banzuke set kyujyo = 1 where basho_id = 202101 and family_name = 'Chiyotairyu'; 7 | # update banzuke set kyujyo = 1 where basho_id = 202101 and family_name = 'Chiyoshoma'; 8 | # update banzuke set kyujyo = 1 where basho_id = 202101 and family_name = 'Kaisei'; -------------------------------------------------------------------------------- /public/ts/heya.ts: -------------------------------------------------------------------------------- 1 | export default {}; 2 | 3 | const expelForms: NodeListOf = 4 | document.body.querySelectorAll("form.expel"); 5 | for (const form of expelForms) { 6 | form.addEventListener("submit", (event) => { 7 | const { member, heya } = form.dataset; 8 | if (heya === undefined) throw new Error("missing heya or member name"); 9 | 10 | const msg = 11 | member === undefined 12 | ? `Are you sure you want to leave ${heya}?` 13 | : `Are you sure you want to expel ${member} from ${heya}?`; 14 | if (!confirm(msg)) { 15 | event.preventDefault(); 16 | } 17 | }); 18 | } 19 | -------------------------------------------------------------------------------- /sql/20240725-heya.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE heya ( 2 | id INTEGER PRIMARY KEY, 3 | name TEXT NOT NULL, 4 | slug TEXT NOT NULL UNIQUE, 5 | oyakata_player_id INTEGER NOT NULL REFERENCES player(id) ON DELETE RESTRICT, 6 | create_date TEXT NOT NULL 7 | ); 8 | 9 | CREATE TABLE heya_player ( 10 | player_id INTEGER NOT NULL REFERENCES player(id) ON DELETE CASCADE, 11 | heya_id INTEGER NOT NULL REFERENCES heya(id) ON DELETE CASCADE, 12 | recruit_date TEXT NOT NULL, 13 | PRIMARY KEY (player_id, heya_id) ON CONFLICT IGNORE 14 | ); 15 | CREATE INDEX heya_player__heya_id on heya_player (heya_id); 16 | -------------------------------------------------------------------------------- /sql/20191104-player-info.sql: -------------------------------------------------------------------------------- 1 | -- DEPRECATED; see new version in 20191106 migration 2 | 3 | CREATE VIEW player_info ( 4 | id, name, join_date, admin_level, 5 | discord_user_id, discord_avatar, discord_discriminator, 6 | google_picture, 7 | emperors_cups 8 | ) 9 | AS SELECT 10 | p.id, p.name, p.join_date, p.admin_level, 11 | d.user_id, d.avatar, d.discriminator, 12 | g.picture, 13 | ( 14 | SELECT COUNT(*) 15 | FROM award AS a 16 | WHERE a.player_id = p.id AND type = 1 17 | ) AS emperors_cups 18 | FROM player AS p 19 | LEFT JOIN player_discord AS d ON d.player_id = p.id 20 | LEFT JOIN player_google AS g ON g.player_id = p.id; 21 | -------------------------------------------------------------------------------- /.idea/kachiclash.iml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /scripts/kachiclash.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=kachiclash webapp server 3 | Documentation=https://kachiclash.com 4 | After=network-online.target 5 | Wants=network-online.target 6 | 7 | [Service] 8 | Type=simple 9 | User=kachiclash 10 | Group=nogroup 11 | Environment=KACHI_ENV=prod 12 | Environment=KACHI_DB_PATH=/home/kachiclash/var/kachiclash.sqlite 13 | Environment=KACHI_STATIC_PATH=/home/kachiclash/public 14 | Environment=KACHI_HERO=/static/img2/2021-Kachi-Clash-Banner-2.png 15 | Environment=KACHI_PORT=8001 16 | Environment=RUST_BACKTRACE=1 17 | Environment=SUMO_API_DRY_RUN=0 18 | EnvironmentFile=/home/kachiclash/prod_secrets 19 | ExecStart=/home/kachiclash/server 20 | Restart=always 21 | 22 | [Install] 23 | WantedBy=multi-user.target 24 | -------------------------------------------------------------------------------- /scripts/dev.sh.example: -------------------------------------------------------------------------------- 1 | export RUST_BACKTRACE=1 2 | export RUST_LOG=info,kachiclash=trace 3 | export KACHI_HOST=localhost 4 | export KACHI_PORT=8000 5 | export SESSION_SECRET=sessionsecret-12345678901234567890123456789012345678901234567890 6 | export WEBHOOK_SECRET=0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef 7 | export DISCORD_CLIENT_ID= 8 | export DISCORD_CLIENT_SECRET= 9 | export REDDIT_CLIENT_ID= 10 | export REDDIT_CLIENT_SECRET= 11 | export GOOGLE_CLIENT_ID= 12 | export GOOGLE_CLIENT_SECRET= 13 | 14 | # Generate VAPID keys as with `npx web-push generate-vapid-keys` 15 | # These must be base64url format (unpadded and with url-safe chars) 16 | export VAPID_PUBLIC_KEY= 17 | export VAPID_PRIVATE_KEY= 18 | 19 | cargo run --verbose --bin server 20 | -------------------------------------------------------------------------------- /public/img/social/google.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /scripts/kachiclash-levelone.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=kachiclash-levelone webapp server 3 | Documentation=https://levelone.kachiclash.com 4 | After=network-online.target 5 | Wants=network-online.target 6 | 7 | [Service] 8 | Type=simple 9 | User=kachiclash 10 | Group=nogroup 11 | Environment=KACHI_ENV=prod 12 | Environment=KACHI_DB_PATH=/home/kachiclash/var/levelone.sqlite 13 | Environment=KACHI_STATIC_PATH=/home/kachiclash/public 14 | Environment=KACHI_HERO=/static/levelone/2021-banner-levelone.png 15 | Environment=KACHI_HOST=levelone.kachiclash.com 16 | Environment=KACHI_PORT=8002 17 | Environment=RUST_BACKTRACE=1 18 | EnvironmentFile=/home/kachiclash/prod_secrets-levelone 19 | ExecStart=/home/kachiclash/server 20 | Restart=always 21 | 22 | [Install] 23 | WantedBy=multi-user.target 24 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "cargo" 9 | directory: "/" 10 | groups: 11 | rust-minor: 12 | update-types: 13 | - minor 14 | - patch 15 | schedule: 16 | interval: "weekly" 17 | - package-ecosystem: "npm" 18 | directory: "/" 19 | schedule: 20 | interval: "weekly" 21 | groups: 22 | npm-minor: 23 | update-types: 24 | - minor 25 | - patch 26 | -------------------------------------------------------------------------------- /scripts/kachiclash-beta.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=kachiclash webapp beta server 3 | Documentation=https://kachiclash.com 4 | After=network-online.target 5 | Wants=network-online.target 6 | 7 | [Service] 8 | Type=simple 9 | User=kachiclash 10 | Group=nogroup 11 | Environment=KACHI_ENV=beta 12 | Environment=KACHI_DB_PATH=/home/kachiclash/var/kachiclash.sqlite 13 | Environment=KACHI_STATIC_PATH=/home/kachiclash/public-beta 14 | Environment=KACHI_HERO=/static/img2/2021-Kachi-Clash-Banner-2.png 15 | Environment=KACHI_HOST=beta.kachiclash.com 16 | Environment=KACHI_PORT=8004 17 | Environment=RUST_BACKTRACE=1 18 | Environment=RUST_LOG=info,kachiclash=trace,rusqlite=trace,actix-web=debug 19 | Environment=SUMO_API_DRY_RUN=1 20 | EnvironmentFile=/home/kachiclash/prod_secrets 21 | ExecStart=/home/kachiclash/server-beta 22 | Restart=always 23 | 24 | [Install] 25 | WantedBy=multi-user.target 26 | -------------------------------------------------------------------------------- /templates/list_players.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block main_id %}p-list-players{% endblock %} 4 | 5 | {% block subtitle %}Players List{% endblock %} 6 | 7 | {% block head %} 8 | 9 | {% endblock %} 10 | 11 | {% block main %} 12 |
13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | {% for player in players -%} 22 | 23 | 24 | 27 | 28 | {% endfor %} 29 | 30 |
Player
{{ player.render().unwrap()|safe }} 25 | 26 |
31 |
32 | {% endblock %} 33 | -------------------------------------------------------------------------------- /public/ts/admin.ts: -------------------------------------------------------------------------------- 1 | import { adminTrigger } from "./push.js"; 2 | 3 | const pushAnnouncementForm = document.getElementById( 4 | "push-announcement", 5 | ) as HTMLFormElement; 6 | pushAnnouncementForm.addEventListener("submit", (event) => { 7 | const messageInput = pushAnnouncementForm.elements.namedItem( 8 | "message", 9 | ) as HTMLInputElement; 10 | const delayInput = pushAnnouncementForm.elements.namedItem( 11 | "delay", 12 | ) as HTMLInputElement; 13 | const submitButton = pushAnnouncementForm.elements.namedItem( 14 | "submit", 15 | ) as HTMLButtonElement; 16 | submitButton.disabled = true; 17 | event.preventDefault(); 18 | if (!messageInput.value) return; 19 | setTimeout( 20 | async () => { 21 | await adminTrigger({ Announcement: messageInput.value }); 22 | submitButton.disabled = false; 23 | }, 24 | 1000 * parseFloat(delayInput.value), 25 | ); 26 | }); 27 | -------------------------------------------------------------------------------- /public/scss/stats.scss: -------------------------------------------------------------------------------- 1 | #p-stats { 2 | section { 3 | background: var(--color-bg); 4 | } 5 | 6 | table { 7 | margin: 0 auto; 8 | } 9 | 10 | #prev-winners { 11 | table { 12 | border-spacing: 0.25em 1em; 13 | } 14 | } 15 | 16 | #players { 17 | table { 18 | border-collapse: collapse; 19 | 20 | td, 21 | th { 22 | padding: 0.125rem 0.25rem; 23 | } 24 | 25 | .num { 26 | text-align: right; 27 | } 28 | 29 | .rank { 30 | text-align: right; 31 | padding-right: 0.5rem; 32 | } 33 | 34 | .border-left { 35 | border-left: solid 1px var(--color-border); 36 | } 37 | 38 | .sort-key { 39 | font-weight: bold; 40 | } 41 | 42 | #self-leader td.player { 43 | background: var(--color-table-highlight-bg); 44 | box-shadow: var(--table-highlight-shadow); 45 | } 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "kachiclash", 3 | "description": "A Grand Sumo prediction game.", 4 | "version": "1.0.0", 5 | "type": "module", 6 | "private": true, 7 | "scripts": { 8 | "type:check": "tsc --noEmit", 9 | "format": "prettier --write .", 10 | "format:check": "prettier --check ." 11 | }, 12 | "author": "Daniel Dickison", 13 | "license": "ISC", 14 | "devDependencies": { 15 | "@eslint/js": "^9.39.1", 16 | "@typescript-eslint/eslint-plugin": "^8.49.0", 17 | "@typescript-eslint/parser": "^8.49.0", 18 | "eslint": "^9.39.1", 19 | "eslint-config-prettier": "^10.1.8", 20 | "eslint-plugin-import": "^2.32.0", 21 | "eslint-plugin-n": "^17.23.1", 22 | "eslint-plugin-promise": "^7.2.1", 23 | "prettier": "^3.7.4", 24 | "prettier-plugin-jinja-template": "^2.1.0", 25 | "sass": "^1.94.2", 26 | "typescript": "^5.9.3", 27 | "typescript-eslint": "^8.49.0", 28 | "web-push": "^3.6.7" 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /sql/20191106-player-reddit.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE player_reddit ( 2 | player_id INTEGER NOT NULL REFERENCES player(id) ON DELETE CASCADE, 3 | id TEXT NOT NULL UNIQUE, 4 | name TEXT NOT NULL, 5 | icon_img TEXT, 6 | mod_date TEXT NOT NULL 7 | ); 8 | 9 | CREATE INDEX player_reddit__player_id ON player_reddit (player_id); 10 | 11 | DROP VIEW player_info; 12 | CREATE VIEW player_info ( 13 | id, name, join_date, admin_level, 14 | discord_user_id, discord_avatar, discord_discriminator, 15 | google_picture, 16 | reddit_icon, 17 | emperors_cups 18 | ) 19 | AS SELECT 20 | p.id, p.name, p.join_date, p.admin_level, 21 | d.user_id, d.avatar, d.discriminator, 22 | g.picture, 23 | r.icon_img, 24 | ( 25 | SELECT COUNT(*) 26 | FROM award AS a 27 | WHERE a.player_id = p.id AND type = 1 28 | ) AS emperors_cups 29 | FROM player AS p 30 | LEFT JOIN player_discord AS d ON d.player_id = p.id 31 | LEFT JOIN player_google AS g ON g.player_id = p.id 32 | LEFT JOIN player_reddit AS r ON r.player_id = p.id; 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Daniel Dickison 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /public/img/social/reddit.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /public/scss/heya.scss: -------------------------------------------------------------------------------- 1 | @use "media"; 2 | 3 | #p-heya, 4 | #p-heya-list { 5 | section { 6 | background: var(--color-light-yellow-bg); 7 | box-shadow: 0 5px 15px rgba(0, 0, 0, 0.3); 8 | -webkit-backdrop-filter: blur(3px); 9 | backdrop-filter: blur(3px); 10 | } 11 | 12 | p.error { 13 | padding: 1ex; 14 | font-style: italic; 15 | border: solid 2px var(--color-border-error); 16 | } 17 | 18 | table.members { 19 | border-collapse: collapse; 20 | margin: 0 auto; 21 | 22 | thead > tr { 23 | background: #00000011; 24 | } 25 | 26 | tbody > tr:nth-child(2n) { 27 | background: #00000011; 28 | } 29 | 30 | td, 31 | th { 32 | padding: 0.25em 0.5em; 33 | } 34 | 35 | .total { 36 | background: #ffffaa22; 37 | } 38 | 39 | .numeric { 40 | text-align: right; 41 | } 42 | 43 | button { 44 | margin: 0; 45 | } 46 | 47 | form.expel { 48 | display: inline; 49 | } 50 | 51 | @media (max-width: media.$narrow) { 52 | .wide-only { 53 | display: none; 54 | } 55 | } 56 | } 57 | } 58 | 59 | #p-heya-list { 60 | > section > table { 61 | border-spacing: 1em 1ex; 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /public/img/social/discord.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/handlers/user_agent.rs: -------------------------------------------------------------------------------- 1 | use result::ResultOptionExt; 2 | use std::fmt::Display; 3 | 4 | use actix_web::error::ParseError; 5 | use actix_web::http::header::{ 6 | Header, HeaderName, HeaderValue, InvalidHeaderValue, TryIntoHeaderValue, USER_AGENT, 7 | }; 8 | 9 | pub struct UserAgent(String); 10 | 11 | impl Default for UserAgent { 12 | fn default() -> Self { 13 | "unknown user agent".into() 14 | } 15 | } 16 | 17 | impl From<&str> for UserAgent { 18 | fn from(value: &str) -> Self { 19 | Self(value.to_string()) 20 | } 21 | } 22 | 23 | impl Display for UserAgent { 24 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 25 | self.0.fmt(f) 26 | } 27 | } 28 | 29 | impl TryIntoHeaderValue for UserAgent { 30 | type Error = InvalidHeaderValue; 31 | 32 | fn try_into_value(self) -> Result { 33 | HeaderValue::from_str(&self.0) 34 | } 35 | } 36 | 37 | impl Header for UserAgent { 38 | fn name() -> HeaderName { 39 | USER_AGENT 40 | } 41 | 42 | fn parse(msg: &M) -> Result { 43 | Ok(msg 44 | .headers() 45 | .get(Self::name()) 46 | .map(|h| h.to_str().map(Self::from).map_err(|_| ParseError::Header)) 47 | .invert()? 48 | .unwrap_or_default()) 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /scripts/install.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | KC_HOME=${KC_HOME:-/home/kachiclash} 6 | PUBLIC=$KC_HOME/public 7 | SERVER=$KC_HOME/server 8 | SERVICE=kachiclash 9 | 10 | while [[ $# -gt 0 ]]; do 11 | case $1 in 12 | --beta) 13 | shift 14 | PUBLIC=$KC_HOME/public-beta 15 | SERVER=$KC_HOME/server-beta 16 | SERVICE=kachiclash-beta 17 | ;; 18 | -r|--run) 19 | GH_RUN_ID="$2" 20 | shift 21 | shift 22 | ;; 23 | *) 24 | echo "Unknown option: $1" 25 | exit 3 26 | ;; 27 | esac 28 | done 29 | 30 | if [ -n "$GH_RUN_ID" ]; then 31 | echo "Using artifact from GH Action run ID: $GH_RUN_ID" 32 | mkdir -p var 33 | gh run download $GH_RUN_ID --name build-output --dir var/build-output 34 | cd var/build-output 35 | else 36 | echo "Building locally" 37 | cargo build --bin=server --release --locked 38 | fi 39 | 40 | sudo rsync -rv --checksum public/ $PUBLIC 41 | sudo chown -R kachiclash:nogroup $PUBLIC 42 | sudo chmod 0555 $PUBLIC 43 | # sudo chmod 0555 /storage/kachiclash.com/public/{css,img,js} 44 | sudo install -vb \ 45 | -o kachiclash -g nogroup -m 0555 \ 46 | target/release/server \ 47 | $SERVER 48 | 49 | sudo systemctl restart $SERVICE 50 | 51 | if [ -n "$GH_RUN_ID" ]; then 52 | cd .. 53 | rm -rf build-output 54 | fi 55 | -------------------------------------------------------------------------------- /templates/torikumi.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block main_id %}p-torikumi{% endblock %} 4 | 5 | {% block subtitle %}{{ basho_id }} day {{ day }}{% endblock %} 6 | 7 | {% block head %} 8 | 9 | 10 | {% endblock %} 11 | 12 | {% block main %} 13 |
14 |

Enter torikumi results for {{ basho_id }} day {{ day }}

15 | 31 | 32 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 |
winnerloser
46 |
47 | {% endblock %} 48 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "kachiclash" 3 | version = "1.0.0" 4 | authors = ["Daniel Dickison "] 5 | edition = "2021" 6 | publish = false 7 | 8 | [dependencies] 9 | actix-web = "4" 10 | actix-identity = "0.9.0" 11 | actix-files = "0.6" 12 | async-trait = "0.1.89" 13 | url = "2.5" 14 | serde = "1.0.228" 15 | serde_derive = "1.0.217" 16 | serde_json = "1.0.145" 17 | futures = "0.3" 18 | envconfig = "0.11.0" 19 | log = "0.4.29" 20 | pretty_env_logger = "0.5" 21 | chrono = "0.4" 22 | itertools = "0.14.0" 23 | result = "1.0" 24 | regex = "1.12" 25 | rand = "0.9" 26 | oauth2 = "5.0" 27 | anyhow = "1.0" 28 | num_cpus = "1.17" 29 | askama = "0.14.0" 30 | slug_intl = "1.0.0" 31 | hmac-sha256 = "1.1.12" 32 | base64 = "0.22.1" 33 | 34 | [dependencies.tokio] 35 | version = "1.48.0" 36 | features = ["full"] 37 | 38 | [dependencies.actix-session] 39 | version = "0.11.0" 40 | features = ["cookie-session"] 41 | 42 | [dependencies.askama_web] 43 | version = "0.14.6" 44 | features = ["actix-web-4"] 45 | 46 | [dependencies.rusqlite] 47 | version = "0.37.0" 48 | features = ["bundled", "chrono", "trace"] 49 | 50 | [dependencies.reqwest] 51 | version = "0.12.24" 52 | features = ["gzip", "json"] 53 | 54 | [dependencies.web-push] 55 | version = "0.11.0" 56 | default-features = false 57 | features = ["hyper-client"] 58 | 59 | [patch.crates-io] 60 | web-push = { git = "https://github.com/danieldickison/rust-web-push.git", branch = "declarative-push" } 61 | # path = "../rust-web-push" 62 | -------------------------------------------------------------------------------- /public/ts/push.ts: -------------------------------------------------------------------------------- 1 | // Keep types in sync with data/push.rs 2 | 3 | export type BashoId = string; 4 | export type PlayerId = number; 5 | export type Day = number; 6 | 7 | export type PushType = 8 | | { Test: undefined } 9 | | { Announcement: string } 10 | | { EntriesOpen: BashoId } 11 | | { BashoStartCountdown: BashoId } 12 | | { DayResult: [BashoId, PlayerId, Day] } 13 | | { BashoResult: [BashoId, PlayerId] }; 14 | 15 | export interface RikishiDayResult { 16 | name: string; 17 | win?: boolean; 18 | } 19 | 20 | export async function sendTestNotification(): Promise { 21 | await fetch("/push/test", { 22 | method: "POST", 23 | credentials: "same-origin", 24 | }); 25 | } 26 | 27 | interface SendStats { 28 | success: number; 29 | invalid: number; 30 | fail: number; 31 | players: number; 32 | } 33 | 34 | export async function adminTrigger(pushType: PushType): Promise { 35 | const res = await fetch("/push/trigger", { 36 | method: "POST", 37 | credentials: "same-origin", 38 | headers: { 39 | "Content-Type": "application/json", 40 | }, 41 | body: JSON.stringify(pushType), 42 | }); 43 | alertSendStats(await res.json()); 44 | } 45 | 46 | export function alertSendStats(res: unknown): void { 47 | const stats = res as SendStats; 48 | console.debug("push send stats", stats); 49 | alert(`Notified ${stats.players} players with devices: 50 | ${stats.success} success 51 | ${stats.invalid} invalid (unsusbscribed) 52 | ${stats.fail} fail`); 53 | } 54 | -------------------------------------------------------------------------------- /templates/admin_page.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block main_id %}p-admin-page{% endblock %} 4 | 5 | {% block subtitle %}admin{% endblock %} 6 | 7 | {% block head %} 8 | 9 | 10 | {% endblock %} 11 | 12 | {% block main %} 13 |
14 |

Webhook

15 |
16 | 17 |
18 |
19 | 20 |
21 |
22 | 28 | 29 |
30 |
31 |
32 |

Push Notifications

33 |
34 | 35 | 36 | 37 | 38 | 39 |
40 |
41 | {% endblock %} 42 | -------------------------------------------------------------------------------- /sql/20191129-basho-result.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE basho_result ( 2 | basho_id INTEGER NOT NULL REFERENCES basho(id) ON DELETE CASCADE, 3 | player_id INTEGER NOT NULL REFERENCES player(id) ON DELETE CASCADE, 4 | wins INTEGER NOT NULL, 5 | rank INTEGER NOT NULL, 6 | 7 | PRIMARY KEY (basho_id, player_id) 8 | ); 9 | 10 | CREATE INDEX basho_result__player_id ON basho_result (player_id); 11 | 12 | CREATE TABLE external_basho_result ( 13 | basho_id INTEGER PRIMARY KEY REFERENCES basho(id) ON DELETE CASCADE, 14 | url TEXT NOT NULL, 15 | players INTEGER NOT NULL, 16 | winning_score INTEGER 17 | ); 18 | 19 | UPDATE basho SET external_link = NULL; 20 | 21 | INSERT INTO external_basho_result (basho_id, url, players, winning_score) 22 | VALUES 23 | (201901, 'https://docs.google.com/spreadsheets/d/e/2PACX-1vQPpo_GmEGc9ExoV4ayt2esIdyLFY__gtlkAC22AbM7wjy0B0py9Fo3dhjCi67zzU_rFYcQT0f56tC3/pubhtml', 60, 49), 24 | (201903, 'https://docs.google.com/spreadsheets/d/e/2PACX-1vSDRWK98rhwv75SMLN2mGrFSHIMv2rVi2_-PpQz20C1uTqdoC2vZ_RSbWA7MqY8APKdV4gwsb7YZhDB/pubhtml', 100, 61), 25 | (201905, 'https://docs.google.com/spreadsheets/d/1XQcTNX3GWCxd6mueQUd8uGb7BoPV9Q9k5RLExr4uzlY/pubhtml', 122, 48), 26 | (201907, 'https://docs.google.com/spreadsheets/d/e/2PACX-1vR2w4tx6BYrwAGiBA9k45g9tfk_-IpwWNDI94luffWbi2dyGPs602TG80OGdkuKARJNsHTK-N7mu-hW/pubhtml', 108, 49), 27 | (201909, 'https://docs.google.com/spreadsheets/d/e/2PACX-1vThU4fGcK22acfjvMo5e2zq8Ycfm8GpaIN2y_DVuMvKD9KTT5TAJWloSVMVSV5CyFXNm-E16rqtxJnJ/pubhtml', 117, 48); 28 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: 4 | push: 5 | 6 | concurrency: 7 | group: ${{ github.workflow }}-${{ github.ref }} 8 | cancel-in-progress: true 9 | 10 | jobs: 11 | test: 12 | runs-on: ubuntu-22.04 13 | steps: 14 | - uses: actions/checkout@v4 15 | - uses: actions/setup-node@v4 16 | with: 17 | cache: npm 18 | - run: npm ci 19 | - uses: actions-rust-lang/setup-rust-toolchain@v1 20 | - name: Rust Tests 21 | run: cargo test --all-targets --locked ${{ runner.debug && '--verbose' || '' }} 22 | - name: TypeScript 23 | run: npm run type:check 24 | lint: 25 | runs-on: ubuntu-22.04 26 | steps: 27 | - uses: actions/checkout@v4 28 | - uses: actions/setup-node@v4 29 | with: 30 | cache: npm 31 | - run: npm ci 32 | - uses: actions-rust-lang/setup-rust-toolchain@v1 33 | - name: Clippy 34 | run: cargo clippy --no-deps --all-targets --locked ${{ runner.debug && '--verbose' || '' }} 35 | - name: Prettier 36 | run: npm run format:check 37 | build: 38 | runs-on: ubuntu-22.04 39 | steps: 40 | - uses: actions/checkout@v4 41 | - uses: actions/setup-node@v4 42 | with: 43 | cache: npm 44 | - run: npm ci 45 | - uses: actions-rust-lang/setup-rust-toolchain@v1 46 | - name: Build 47 | run: cargo build --bin=server --release --locked ${{ runner.debug && '--verbose' || '' }} 48 | - name: Upload build artifact 49 | uses: actions/upload-artifact@v4 50 | with: 51 | name: build-output 52 | path: | 53 | target/release/server 54 | public/ 55 | if-no-files-found: error 56 | -------------------------------------------------------------------------------- /templates/edit_basho.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block main_id %}p-edit-basho{% endblock %} 4 | 5 | {% block subtitle %}edit basho{% endblock %} 6 | 7 | {% block head %} 8 | 9 | 10 | {% endblock %} 11 | 12 | {% block main %} 13 |
14 |

Create new basho

15 | 19 | 32 | 49 | 50 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 |
ranknamekyujyo
65 |
66 | {% endblock %} 67 | -------------------------------------------------------------------------------- /src/handlers/player.rs: -------------------------------------------------------------------------------- 1 | use actix_identity::Identity; 2 | use actix_web::{get, web}; 3 | use askama::Template; 4 | use askama_web::WebTemplate; 5 | 6 | use super::{BaseTemplate, HandlerError, Result}; 7 | use crate::data::Heya; 8 | use crate::data::{player::BashoScore, Player}; 9 | use crate::handlers::IdentityExt; 10 | use crate::AppState; 11 | 12 | #[derive(Template, WebTemplate)] 13 | #[template(path = "player.html")] 14 | pub struct PlayerTemplate { 15 | base: BaseTemplate, 16 | player: Player, 17 | basho_scores: Vec, 18 | recruit_heyas: Vec, 19 | } 20 | 21 | #[get("/player/{player}")] 22 | pub async fn player_page( 23 | path: web::Path, 24 | state: web::Data, 25 | identity: Option, 26 | ) -> Result { 27 | let name = path.into_inner(); 28 | let db = state.db.lock().unwrap(); 29 | let base = BaseTemplate::new(&db, identity.as_ref(), &state)?; 30 | let player = Player::with_name(&db, name, base.current_or_next_basho_id)? 31 | .ok_or_else(|| HandlerError::NotFound("player".to_string()))?; 32 | let basho_scores = BashoScore::with_player_id(&db, player.id, &player.name)?; 33 | 34 | let recruit_heyas = identity 35 | .as_ref() 36 | .and_then(|i| i.player_id().ok()) 37 | .map(|user_player_id| Heya::for_player(&db, user_player_id)) 38 | .transpose()? 39 | .unwrap_or_default() 40 | .into_iter() 41 | .filter(|heya| heya.oyakata.id == identity.as_ref().unwrap().player_id().unwrap()) 42 | .filter(|hosted_heya| { 43 | !player 44 | .heyas 45 | .as_ref() 46 | .unwrap() 47 | .iter() 48 | .any(|member_heya| member_heya.id == hosted_heya.id) 49 | }) 50 | .collect(); 51 | 52 | Ok(PlayerTemplate { 53 | base, 54 | player, 55 | basho_scores, 56 | recruit_heyas, 57 | }) 58 | } 59 | 60 | impl PlayerTemplate { 61 | fn is_self(&self) -> bool { 62 | self.base 63 | .player 64 | .as_ref() 65 | .is_some_and(|p| p.id == self.player.id) 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /public/scss/settings.scss: -------------------------------------------------------------------------------- 1 | #p-settings { 2 | #settings-form { 3 | .messages { 4 | display: none; // overridden inline via js 5 | background: var(--color-light-yellow-bg); 6 | border: solid 2px var(--color-border-message); 7 | padding: var(--section-padding); 8 | font-size: 1.25rem; 9 | font-style: italic; 10 | 11 | &.error { 12 | border-color: var(--color-border-error); 13 | } 14 | } 15 | 16 | input[name="name"] { 17 | font-size: 1.5rem; 18 | padding: 0.25rem; 19 | margin: 0 0.5rem; 20 | border: solid 2px var(--color-border); 21 | } 22 | 23 | .hint { 24 | color: var(--color-fg-sub); 25 | display: inline-block; 26 | } 27 | 28 | fieldset { 29 | margin: 1rem 0; 30 | padding: var(--section-padding); 31 | background: var(--color-bg); 32 | border: solid 2px var(--color-border); 33 | 34 | > legend { 35 | background: var(--color-bg); 36 | border: solid 2px var(--color-border); 37 | padding: 0.25rem; 38 | } 39 | } 40 | 41 | fieldset[name="notifications"] { 42 | > p { 43 | display: none; 44 | margin: 0; 45 | &.warning { 46 | color: var(--color-fg-warning); 47 | font-style: italic; 48 | } 49 | } 50 | &[data-permission-state="granted"] > p.available, 51 | &[data-permission-state="prompt"] > p.available { 52 | display: block; 53 | } 54 | &[data-permission-state="denied"] > p.denied { 55 | display: block; 56 | } 57 | &[data-permission-state="unavailable"] > p.unavailable { 58 | display: block; 59 | } 60 | @media (display-mode: browser) { 61 | &.ios[data-permission-state="unavailable"] { 62 | > p.unavailable { 63 | display: none; 64 | } 65 | > p.unavailable-ios-pwa { 66 | display: block; 67 | } 68 | } 69 | } 70 | 71 | ul { 72 | list-style: none; 73 | padding-inline-start: 1rem; 74 | } 75 | } 76 | 77 | .save-button { 78 | margin-left: auto; 79 | padding-left: 2rem; 80 | padding-right: 2rem; 81 | } 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /templates/heya_list.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block main_id %}p-heya-list{% endblock %} 4 | 5 | {% block subtitle %} 6 | Heya Directory 7 | {% endblock %} 8 | 9 | {% block head %} 10 | 11 | {% endblock %} 12 | 13 | {% block main %} 14 |

Heya Directory

15 |
16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | {% for heya in heyas %} 27 | 28 | 31 | 32 | 33 | 34 | 35 | {% endfor %} 36 | 37 |
HeyaOyakataInauguratedMembers
29 | {{ heya.name }} 30 | {{ heya.oyakata.render().unwrap()|safe }}{{ heya.create_date.format("%Y-%m-%d") }}{{ heya.member_count }}
38 |
39 | 40 | {% if let Some(player) = base.player %} 41 |
42 |

Create Heya

43 |

44 | You can start a heya (部屋 or stable) where you can invite your 45 | friends to see each other’s relative rankings and progress during each 46 | basho. Great for IRL friend groups and sumo watch parties. 47 |

48 |

49 | You can start up to 3 heyas, and be a member of up to 5 heyas. Each heya 50 | can have up to 50 members. To become a member of a heya, the oyakata 51 | (founder) has to recruit you. 52 |

53 | {% if hosted >= HOST_MAX %} 54 |

55 | You are already hosting the maximum {{ HOST_MAX }} heyas. 56 |

57 | {% else if player.heyas.as_ref().unwrap().len() >= JOIN_MAX %} 58 |

59 | You are already a member of the maximum {{ JOIN_MAX }} heyas. 60 |

61 | {% else %} 62 |
63 | 64 | 65 |
66 | {% endif %} 67 |
68 | {% endif %} 69 | {% endblock %} 70 | -------------------------------------------------------------------------------- /src/util/vec.rs: -------------------------------------------------------------------------------- 1 | use core::mem; 2 | 3 | pub trait GroupRuns { 4 | type Item; 5 | 6 | fn group_runs(&self, by: B) -> GroupedRuns<'_, Self::Item, B> 7 | where 8 | B: FnMut(&Self::Item, &Self::Item) -> bool; 9 | 10 | fn group_runs_mut(&mut self, by: B) -> GroupedRunsMut<'_, Self::Item, B> 11 | where 12 | B: FnMut(&Self::Item, &Self::Item) -> bool; 13 | } 14 | 15 | impl GroupRuns for [I] { 16 | type Item = I; 17 | 18 | fn group_runs(&self, by: B) -> GroupedRuns<'_, Self::Item, B> 19 | where 20 | B: FnMut(&Self::Item, &Self::Item) -> bool, 21 | { 22 | GroupedRuns { slice: self, by } 23 | } 24 | 25 | fn group_runs_mut(&mut self, by: B) -> GroupedRunsMut<'_, Self::Item, B> 26 | where 27 | B: FnMut(&Self::Item, &Self::Item) -> bool, 28 | { 29 | GroupedRunsMut { slice: self, by } 30 | } 31 | } 32 | 33 | pub struct GroupedRuns<'a, I: 'a, B> { 34 | slice: &'a [I], 35 | by: B, 36 | } 37 | 38 | pub struct GroupedRunsMut<'a, I: 'a, B> { 39 | slice: &'a mut [I], 40 | by: B, 41 | } 42 | 43 | impl<'a, I: 'a, B> Iterator for GroupedRuns<'a, I, B> 44 | where 45 | B: FnMut(&I, &I) -> bool, 46 | { 47 | type Item = &'a [I]; 48 | 49 | fn next(&mut self) -> Option { 50 | if self.slice.is_empty() { 51 | return None; 52 | } 53 | 54 | let mut i = 1; 55 | while i < self.slice.len() && (self.by)(&self.slice[i - 1], &self.slice[i]) { 56 | i += 1; 57 | } 58 | let slice = mem::take(&mut self.slice); 59 | let (a, b): (Self::Item, Self::Item) = slice.split_at(i); 60 | self.slice = b; 61 | Some(a) 62 | } 63 | } 64 | 65 | impl<'a, I: 'a, B> Iterator for GroupedRunsMut<'a, I, B> 66 | where 67 | B: FnMut(&I, &I) -> bool, 68 | { 69 | type Item = &'a mut [I]; 70 | 71 | fn next(&mut self) -> Option { 72 | if self.slice.is_empty() { 73 | return None; 74 | } 75 | 76 | let mut i = 1; 77 | while i < self.slice.len() && (self.by)(&self.slice[i - 1], &self.slice[i]) { 78 | i += 1; 79 | } 80 | let slice = mem::take(&mut self.slice); 81 | let (a, b): (Self::Item, Self::Item) = slice.split_at_mut(i); 82 | self.slice = b; 83 | Some(a) 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /public/scss/player.scss: -------------------------------------------------------------------------------- 1 | @use "media"; 2 | 3 | #p-player { 4 | section { 5 | border: solid 1px var(--color-border); 6 | padding: var(--section-padding); 7 | margin: 2rem 0; 8 | background: var(--color-bg); 9 | } 10 | 11 | #profile { 12 | &::after { 13 | content: ""; 14 | clear: both; 15 | display: block; 16 | } 17 | 18 | > .img-wrapper { 19 | float: left; 20 | margin-right: 1rem; 21 | width: 8rem; 22 | height: 8rem; 23 | border-radius: 4rem; 24 | overflow: hidden; 25 | position: relative; 26 | 27 | &::after { 28 | content: ""; 29 | position: absolute; 30 | top: 0; 31 | bottom: 0; 32 | left: 0; 33 | right: 0; 34 | border-radius: 4rem; 35 | border: 1px solid rgba(0, 0, 0, 0.25); 36 | } 37 | 38 | img { 39 | position: absolute; 40 | width: 100%; 41 | height: 100%; 42 | } 43 | } 44 | 45 | > .name { 46 | font-size: 2rem; 47 | line-height: 3rem; 48 | margin-top: 2.5rem; 49 | } 50 | 51 | > .buttons { 52 | position: absolute; 53 | top: 1rem; 54 | left: 10rem; 55 | right: 1rem; 56 | display: flex; 57 | flex-flow: row wrap; 58 | } 59 | 60 | > .stats { 61 | clear: both; 62 | padding-top: 1rem; 63 | line-height: 2.25; 64 | 65 | form { 66 | display: inline; 67 | } 68 | 69 | .heyas a { 70 | margin-inline-start: 1ex; 71 | } 72 | } 73 | } 74 | 75 | #history { 76 | .basho-list { 77 | width: 100%; 78 | border-collapse: separate; 79 | border-spacing: 0; 80 | 81 | td, 82 | th { 83 | padding: 0.25rem 0.125rem; 84 | border-bottom: 1px solid var(--color-border); 85 | text-align: left; 86 | } 87 | 88 | .first-of-year td { 89 | border-bottom: 3px solid var(--color-border); 90 | } 91 | 92 | .numeric { 93 | text-align: right; 94 | } 95 | 96 | .win-loss { 97 | white-space: nowrap; 98 | } 99 | 100 | @media (max-width: media.$narrow) { 101 | .pick { 102 | display: none; 103 | } 104 | } 105 | } 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /public/ts/service-worker.ts: -------------------------------------------------------------------------------- 1 | // Hack to make TypeScript work in ServiceWorker env: 2 | // https://github.com/Microsoft/TypeScript/issues/14877 3 | // https://github.com/microsoft/TypeScript/issues/20595 4 | // 5 | // Alas, FireFox doesn't yet support module workers as of v111 so we can't do this yet. 6 | // declare const self: any 7 | // export default null 8 | // 9 | // Workaround by doing iife: 10 | (function (self: any) { 11 | const VERSION = 2; 12 | 13 | self.addEventListener("install", (e: any) => { 14 | console.debug(`Installing version ${VERSION}`); 15 | // kick out old workers immediately 16 | e.waitUntil(self.skipWaiting()); 17 | }); 18 | 19 | self.addEventListener("activate", (e: any) => { 20 | console.debug(`Activating version ${VERSION}`); 21 | // claim any pages that loaded without a worker so we can focus them on notification click 22 | e.waitUntil(self.clients.cliam()); 23 | }); 24 | 25 | self.addEventListener("push", (e: any) => { 26 | const data = e.data.json(); 27 | const { 28 | notification: { title, body, navigate }, 29 | } = data; 30 | console.debug("Received push notification with data", data); 31 | e.waitUntil(self.registration.showNotification(title, { body, navigate })); 32 | }); 33 | 34 | self.addEventListener("notificationclick", async (e: any) => { 35 | const notification = e.notification as Notification; 36 | notification.close(); 37 | e.waitUntil(openOrFocusClient(notification.data.navigate)); 38 | }); 39 | 40 | async function openOrFocusClient(url: string): Promise { 41 | const client = (await self.clients.matchAll())[0]; 42 | // todo: maybe match client url with deets from notification 43 | 44 | if (client !== undefined) { 45 | console.debug(`opening ${url} in existing client`, client); 46 | await client.focus(); 47 | await client.navigate(url); 48 | } else { 49 | console.debug(`opening ${url} in new window`); 50 | await self.clients.openWindow(url); 51 | } 52 | 53 | // close all notifications indiscriminately; probably no reason to keep any notifications around 54 | const oldNotifications: any[] = await self.registration.getNotifications(); 55 | console.debug( 56 | `closing ${oldNotifications.length} old notifications`, 57 | oldNotifications, 58 | ); 59 | for (const notification of oldNotifications) { 60 | notification.close(); 61 | } 62 | } 63 | })(self); 64 | -------------------------------------------------------------------------------- /public/ts/basho.ts: -------------------------------------------------------------------------------- 1 | const banzukeSection = document.getElementById("banzuke") as HTMLElement; 2 | const pickForm = document.getElementById( 3 | "banzuke-select-rikishi-form", 4 | ) as HTMLFormElement; 5 | const heyaSelect = document.getElementById( 6 | "heya-select", 7 | ) as HTMLSelectElement | null; 8 | 9 | for (const el of document.querySelectorAll(".select-radio")) { 10 | const radio = el as HTMLInputElement; 11 | radio.addEventListener("change", (_event) => { 12 | for (const otherRadio of document.getElementsByName(radio.name)) { 13 | const label = pickForm.querySelector( 14 | `label.click-target[for="${otherRadio.id}"]`, 15 | ) as HTMLElement; 16 | label.classList.toggle("is-player-pick", otherRadio === radio); 17 | } 18 | // savePicks(); 19 | }); 20 | } 21 | 22 | pickForm.addEventListener("submit", (event) => { 23 | event.preventDefault(); 24 | const formData = new FormData(pickForm); 25 | const url = pickForm.action; 26 | setSelectable(false); 27 | void (async function () { 28 | const success = await savePicks(formData, url); 29 | if (success) { 30 | location.reload(); 31 | } else { 32 | setSelectable(true); 33 | } 34 | })(); 35 | }); 36 | for (const button of document.querySelectorAll(".change-picks-button")) { 37 | button.addEventListener("click", (event) => { 38 | event.preventDefault(); 39 | setSelectable(true); 40 | }); 41 | } 42 | 43 | function setSelectable(selectable: boolean): void { 44 | banzukeSection.classList.toggle("selectable", selectable); 45 | for (const el of document.querySelectorAll(".select-radio")) { 46 | const button = el as HTMLInputElement; 47 | button.disabled = !selectable; 48 | } 49 | } 50 | 51 | async function savePicks(formData: FormData, url: string): Promise { 52 | const data = new URLSearchParams(formData as unknown as any); // https://github.com/microsoft/TypeScript-DOM-lib-generator/pull/880 53 | const response = await fetch(url, { 54 | method: "POST", 55 | body: data, 56 | credentials: "same-origin", 57 | }); 58 | if (response.ok) { 59 | alert("Your picks have been saved!"); 60 | return true; 61 | } else { 62 | const text = await response.text(); 63 | alert("Error saving your picks: " + text); 64 | return false; 65 | } 66 | } 67 | 68 | heyaSelect?.addEventListener("change", () => { 69 | // Remove heya from query params when selecting "everybody": 70 | if (heyaSelect.value === "everybody") { 71 | heyaSelect.name = ""; 72 | } 73 | 74 | heyaSelect.form?.submit(); 75 | }); 76 | 77 | export default {}; 78 | -------------------------------------------------------------------------------- /sql/20191026-init.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE basho ( 2 | id INTEGER PRIMARY KEY, 3 | start_date TEXT NOT NULL, 4 | venue TEXT NOT NULL 5 | ); 6 | 7 | CREATE TABLE rikishi ( 8 | id INTEGER PRIMARY KEY AUTOINCREMENT, 9 | family_name TEXT NOT NULL, 10 | given_name TEXT NOT NULL 11 | ); 12 | 13 | CREATE INDEX rikishi__family_name ON rikishi (family_name); 14 | 15 | CREATE TABLE banzuke ( 16 | rikishi_id INTEGER NOT NULL REFERENCES rikishi(id) ON DELETE CASCADE, 17 | basho_id INTEGER NOT NULL REFERENCES basho(id) ON DELETE CASCADE, 18 | family_name TEXT NOT NULL, 19 | given_name TEXT NOT NULL, 20 | rank TEXT NOT NULL, 21 | 22 | PRIMARY KEY (rikishi_id, basho_id) 23 | ); 24 | 25 | CREATE INDEX banzuke__basho_id ON banzuke (basho_id); 26 | 27 | CREATE TABLE torikumi ( 28 | basho_id INTEGER NOT NULL REFERENCES basho(id) ON DELETE CASCADE, 29 | day INTEGER NOT NULL, 30 | seq INTEGER NOT NULL, 31 | side TEXT NOT NULL, 32 | rikishi_id INTEGER NOT NULL, 33 | win INTEGER, 34 | 35 | PRIMARY KEY (basho_id, day, seq, side), 36 | FOREIGN KEY (rikishi_id, basho_id) REFERENCES banzuke(rikishi_id, basho_id) ON DELETE CASCADE 37 | ); 38 | 39 | CREATE UNIQUE INDEX torikumi__rikishi_day ON torikumi (rikishi_id, basho_id, day); 40 | 41 | CREATE TABLE player ( 42 | id INTEGER PRIMARY KEY AUTOINCREMENT, 43 | join_date TEXT NOT NULL, 44 | name TEXT NOT NULL, 45 | admin_level INTEGER NOT NULL DEFAULT 0 46 | ); 47 | 48 | CREATE TABLE player_discord ( 49 | player_id INTEGER NOT NULL REFERENCES player(id) ON DELETE CASCADE, 50 | user_id TEXT NOT NULL UNIQUE, 51 | username TEXT NOT NULL, 52 | avatar TEXT, 53 | discriminator TEXT NOT NULL, 54 | mod_date TEXT NOT NULL 55 | ); 56 | 57 | CREATE INDEX player_discord__player_id ON player_discord (player_id); 58 | 59 | CREATE TABLE pick ( 60 | player_id INTEGER NOT NULL REFERENCES player(id) ON DELETE CASCADE, 61 | basho_id INTEGER NOT NULL REFERENCES basho(id) ON DELETE CASCADE, 62 | rikishi_id INTEGER NOT NULL REFERENCES rikishi(id) ON DELETE CASCADE, 63 | 64 | PRIMARY KEY (player_id, basho_id, rikishi_id) 65 | ); 66 | 67 | CREATE INDEX pick__basho_id ON pick (basho_id); 68 | 69 | CREATE TABLE award ( 70 | basho_id INTEGER NOT NULL REFERENCES basho(id) ON DELETE CASCADE, 71 | type INTEGER NOT NULL, 72 | player_id INTEGER NOT NULL REFERENCES player(id) ON DELETE CASCADE, 73 | 74 | PRIMARY KEY (basho_id, type, player_id) 75 | ); 76 | 77 | CREATE INDEX award__player_id ON award (player_id); 78 | -------------------------------------------------------------------------------- /src/handlers/settings.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashSet; 2 | 3 | use actix_identity::Identity; 4 | use actix_web::{get, post, web, HttpResponse, Responder}; 5 | use anyhow::anyhow; 6 | use askama::Template; 7 | use askama_web::WebTemplate; 8 | 9 | use super::user_agent::UserAgent; 10 | use super::{BaseTemplate, HandlerError, Result}; 11 | use crate::data::player::{self, Player, PlayerId}; 12 | use crate::data::push::{PushTypeKey, Subscription}; 13 | use crate::data::DbConn; 14 | use crate::handlers::IdentityExt; 15 | use crate::AppState; 16 | 17 | #[derive(Template, WebTemplate)] 18 | #[template(path = "settings.html")] 19 | pub struct SettingsTemplate { 20 | base: BaseTemplate, 21 | } 22 | 23 | #[derive(Debug, Deserialize)] 24 | pub struct FormData { 25 | name: String, 26 | push_subscription: Option, 27 | notification_opt_in: HashSet, 28 | } 29 | 30 | #[get("/settings")] 31 | pub async fn settings_page( 32 | state: web::Data, 33 | identity: Identity, 34 | ) -> Result { 35 | let db = state.db.lock().unwrap(); 36 | let base = BaseTemplate::new(&db, Some(&identity), &state)?; 37 | if base.player.is_some() { 38 | Ok(SettingsTemplate { base }) 39 | } else { 40 | Err(HandlerError::MustBeLoggedIn) 41 | } 42 | } 43 | 44 | #[post("/settings")] 45 | pub async fn settings_post( 46 | form: web::Json, 47 | state: web::Data, 48 | user_agent: web::Header, 49 | identity: Identity, 50 | ) -> Result { 51 | let player_id = identity.player_id()?; 52 | match settings_post_inner(state.db.clone(), player_id, form.0, user_agent.0).await { 53 | Ok(_) => Ok(HttpResponse::Accepted().finish()), 54 | Err(e) => { 55 | warn!("settings_post fail: {:?}", e); 56 | Ok(HttpResponse::InternalServerError().body(e.to_string())) 57 | } 58 | } 59 | } 60 | 61 | async fn settings_post_inner( 62 | db_conn: DbConn, 63 | player_id: PlayerId, 64 | form: FormData, 65 | user_agent: UserAgent, 66 | ) -> anyhow::Result<()> { 67 | if !Player::name_is_valid(&form.name) { 68 | return Err(anyhow!("Invalid name: {}", form.name)); 69 | } 70 | 71 | { 72 | let mut db = db_conn.lock().unwrap(); 73 | let txn = db.transaction()?; 74 | 75 | Player::set_name(&txn, player_id, &form.name)?; 76 | 77 | if let Some(subscription) = form.push_subscription { 78 | Subscription::register( 79 | &txn, 80 | player_id, 81 | &subscription, 82 | &HashSet::from_iter(form.notification_opt_in), 83 | &user_agent.to_string(), 84 | )?; 85 | } 86 | 87 | txn.commit()?; 88 | } 89 | Ok(()) 90 | } 91 | -------------------------------------------------------------------------------- /public/ts/torikumi.ts: -------------------------------------------------------------------------------- 1 | import { alertSendStats } from "./push.js"; 2 | 3 | const torikumiForm = document.getElementById( 4 | "torikumi-form", 5 | ) as HTMLFormElement; 6 | 7 | declare global { 8 | interface HTMLFormControlsCollection extends HTMLCollectionBase { 9 | // [item: string]: HTMLElement | RadioNodeList 10 | torikumi: HTMLInputElement; 11 | notify: HTMLInputElement; 12 | } 13 | } 14 | 15 | let parsedTorikumi: Torikumi[]; 16 | torikumiForm.elements.torikumi.addEventListener("input", torikumiFormInput); 17 | 18 | function torikumiFormInput(): void { 19 | parsedTorikumi = parseTorikumi(torikumiForm.elements.torikumi.value); 20 | const tbody = torikumiForm.querySelector( 21 | ".parsed-torikumi tbody", 22 | ) as HTMLTableSectionElement; 23 | tbody.innerHTML = ""; 24 | parsedTorikumi.forEach((torikumi) => { 25 | const tr = document.createElement("tr"); 26 | tbody.appendChild(tr); 27 | 28 | const winner = document.createElement("td"); 29 | winner.innerText = torikumi.winner; 30 | tr.appendChild(winner); 31 | 32 | const loser = document.createElement("td"); 33 | loser.innerText = torikumi.loser; 34 | tr.appendChild(loser); 35 | }); 36 | } 37 | 38 | // Matches rank, name, record, kimarite, rank, name, record 39 | const TORIKUMI_REGEX = 40 | /^ *[a-z]{1,2}\d{1,3}[ew] +([a-z]+) +\(\d+(?:-\d+){1,2}\) +[a-z]+ *[a-z]{1,2}\d{1,3}[ew] +([a-z]+) +\(\d+(?:-\d+){1,2}\) *$/gim; 41 | 42 | interface Torikumi { 43 | winner: string; 44 | loser: string; 45 | } 46 | 47 | function parseTorikumi(str: string): Torikumi[] { 48 | console.log("parsing torikumi"); 49 | const torikumi: Torikumi[] = []; 50 | let match: RegExpExecArray | null; 51 | while ((match = TORIKUMI_REGEX.exec(str)) !== null) { 52 | torikumi.push({ 53 | winner: match[1], 54 | loser: match[2], 55 | }); 56 | } 57 | return torikumi; 58 | } 59 | 60 | torikumiForm.addEventListener("submit", (event) => { 61 | event.preventDefault(); 62 | const data = { 63 | torikumi: parsedTorikumi, 64 | notify: torikumiForm.elements.notify.checked, 65 | }; 66 | const postURL = location.href; 67 | const bashoURL = postURL.replace(/\/day\/.*$/i, ""); 68 | fetch(postURL, { 69 | method: "POST", 70 | body: JSON.stringify(data), 71 | headers: new Headers({ 72 | "Content-Type": "application/json", 73 | }), 74 | credentials: "same-origin", 75 | }) 76 | .then(async (response) => { 77 | if (response.ok) { 78 | alertSendStats(await response.json()); 79 | window.location.href = bashoURL; 80 | } else { 81 | return await response.text().then((msg) => { 82 | throw new Error(msg); 83 | }); 84 | } 85 | }) 86 | .catch((err: Error) => { 87 | alert("error updating torikumi: " + err.toString()); 88 | }); 89 | }); 90 | 91 | torikumiFormInput(); 92 | -------------------------------------------------------------------------------- /src/handlers/push.rs: -------------------------------------------------------------------------------- 1 | use crate::data::push::{PushType, SendStats, Subscription}; 2 | use crate::data::{BashoInfo, Player}; 3 | use crate::handlers::HandlerError; 4 | use crate::AppState; 5 | use actix_identity::Identity; 6 | use actix_web::{post, web, HttpResponse, Responder}; 7 | use web_push::SubscriptionInfo; 8 | 9 | use super::{IdentityExt, Result}; 10 | 11 | #[post("/check")] 12 | pub async fn check( 13 | subscription: web::Json, 14 | state: web::Data, 15 | identity: Identity, 16 | ) -> Result { 17 | let player_id = identity.player_id()?; 18 | let db = state.db.lock().unwrap(); 19 | for sub in Subscription::for_player(&db, player_id)? { 20 | if sub.info == subscription.0 { 21 | debug!("Matched player {} subscription {}", player_id, sub.id); 22 | return Ok(web::Json(sub)); 23 | } 24 | } 25 | Err(HandlerError::NotFound("subscription".to_string())) 26 | } 27 | 28 | #[post("/test")] 29 | pub async fn test(state: web::Data, identity: Identity) -> Result { 30 | let player_id = identity.player_id()?; 31 | let push_type = PushType::Test; 32 | let payload; 33 | let subs; 34 | { 35 | let db = state.db.lock().unwrap(); 36 | subs = Subscription::for_player(&db, player_id)?; 37 | if subs.is_empty() { 38 | return Err(super::HandlerError::NotFound( 39 | "push subscription".to_owned(), 40 | )); 41 | } 42 | payload = push_type.build_payload(&state.config.url(), &db)?; 43 | } 44 | 45 | state 46 | .push 47 | .clone() 48 | .send(payload, push_type.ttl(), &subs, &state.db) 49 | .await?; 50 | 51 | Ok(HttpResponse::Ok().finish()) 52 | } 53 | 54 | #[post("/trigger")] 55 | pub async fn trigger( 56 | state: web::Data, 57 | identity: Identity, 58 | data: web::Json, 59 | ) -> Result { 60 | let player_id = identity.player_id()?; 61 | let payload; 62 | let subscriptions; 63 | let ttl; 64 | { 65 | let db = state.db.lock().unwrap(); 66 | let current_or_next_basho = BashoInfo::current_or_next_basho_id(&db)?; 67 | let player = Player::with_id(&db, player_id, current_or_next_basho)?; 68 | if !player.is_some_and(|p| p.is_admin()) { 69 | return Err(HandlerError::MustBeLoggedIn); 70 | } 71 | payload = data.build_payload(&state.config.url(), &db)?; 72 | subscriptions = data.subscriptions(&db)?; 73 | ttl = data.ttl(); 74 | } 75 | 76 | let results = state 77 | .push 78 | .clone() 79 | .send(payload, ttl, &subscriptions, &state.db) 80 | .await?; 81 | let stats = SendStats::from_results(&results, &subscriptions); 82 | info!("{:?}", stats); 83 | Ok(web::Json(stats)) 84 | } 85 | -------------------------------------------------------------------------------- /src/handlers/stats.rs: -------------------------------------------------------------------------------- 1 | use std::ops::Range; 2 | 3 | use super::{BaseTemplate, IdentityExt, Result}; 4 | use crate::data::leaders::HistoricLeader; 5 | use crate::data::{BashoId, BashoInfo}; 6 | use crate::AppState; 7 | use actix_identity::Identity; 8 | use actix_web::{get, web}; 9 | use askama::Template; 10 | use askama_web::WebTemplate; 11 | 12 | #[derive(Template, WebTemplate)] 13 | #[template(path = "stats.html")] 14 | pub struct StatsTemplate { 15 | base: BaseTemplate, 16 | basho_list: Vec, 17 | leader_basho_count: usize, 18 | leader_basho_count_options: Vec, 19 | leaders: Vec, 20 | self_leader_index: Option, 21 | } 22 | 23 | impl StatsTemplate { 24 | fn self_leader(&self) -> Option<&HistoricLeader> { 25 | self.self_leader_index.and_then(|i| self.leaders.get(i)) 26 | } 27 | 28 | fn is_self(&self, leader: &HistoricLeader) -> bool { 29 | if let Some(self_leader) = self.self_leader() { 30 | self_leader.player.id == leader.player.id 31 | } else { 32 | false 33 | } 34 | } 35 | } 36 | 37 | #[derive(Deserialize)] 38 | pub struct QueryParams { 39 | b: Option, 40 | } 41 | 42 | const LEADER_BASHO_COUNT_OPTIONS: [usize; 3] = [6, 3, 2]; 43 | const LEADERS_LIMIT: u32 = 5000; 44 | 45 | #[get("/stats")] 46 | pub async fn stats_page( 47 | query: web::Query, 48 | state: web::Data, 49 | identity: Option, 50 | ) -> Result { 51 | let db = state.db.lock().unwrap(); 52 | let basho_list = BashoInfo::list_all(&db)?; 53 | let leader_basho_count = query.b.unwrap_or(6); 54 | let basho_range = n_completed_basho(&basho_list, leader_basho_count); 55 | let leaders = HistoricLeader::with_basho_range(&db, &basho_range, LEADERS_LIMIT)?; 56 | let self_leader_index = match identity.as_ref() { 57 | Some(id) => { 58 | let player_id = id.player_id()?; 59 | leaders.iter().position(|l| l.player.id == player_id) 60 | } 61 | None => None, 62 | }; 63 | Ok(StatsTemplate { 64 | base: BaseTemplate::new(&db, identity.as_ref(), &state)?, 65 | basho_list, 66 | leader_basho_count, 67 | leader_basho_count_options: LEADER_BASHO_COUNT_OPTIONS 68 | .iter() 69 | .copied() 70 | .filter(|c| *c != leader_basho_count) 71 | .collect(), 72 | leaders, 73 | self_leader_index, 74 | }) 75 | } 76 | 77 | fn n_completed_basho(basho_list: &[BashoInfo], n: usize) -> Range { 78 | if basho_list.is_empty() { 79 | return Range { 80 | start: "201901".parse().unwrap(), 81 | end: "202001".parse().unwrap(), 82 | }; 83 | } 84 | 85 | let first = basho_list.first().unwrap(); 86 | let end = if first.winners.is_empty() { 87 | first.id 88 | } else { 89 | first.id.incr(1) 90 | }; 91 | Range { 92 | end, 93 | start: end.incr(-(n as isize)), 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /public/ts/edit_basho.ts: -------------------------------------------------------------------------------- 1 | import { alertSendStats } from "./push.js"; 2 | 3 | const bashoForm = document.getElementById("make-basho-form") as HTMLFormElement; 4 | 5 | declare global { 6 | interface HTMLFormControlsCollection extends HTMLCollectionBase { 7 | // [item: string]: HTMLElement | RadioNodeList 8 | banzuke: HTMLInputElement; 9 | venue: HTMLInputElement; 10 | start_date: HTMLInputElement; 11 | notify_kyujyo: HTMLInputElement; 12 | } 13 | } 14 | 15 | let parsedBanzuke: Rikishi[]; 16 | bashoForm.elements.banzuke.addEventListener("input", bashoFormInput); 17 | 18 | function bashoFormInput(): void { 19 | parsedBanzuke = parseBanzuke(bashoForm.elements.banzuke.value); 20 | const tbody = bashoForm.querySelector( 21 | ".parsed-banzuke tbody", 22 | ) as HTMLTableSectionElement; 23 | tbody.innerHTML = ""; 24 | parsedBanzuke.forEach((rikishi) => { 25 | const tr = document.createElement("tr"); 26 | tbody.appendChild(tr); 27 | 28 | const rank = document.createElement("td"); 29 | rank.innerText = rikishi.rank; 30 | tr.appendChild(rank); 31 | 32 | const name = document.createElement("td"); 33 | name.innerText = rikishi.name; 34 | tr.appendChild(name); 35 | 36 | const kyujyo = document.createElement("td"); 37 | kyujyo.innerText = rikishi.is_kyujyo ? "㊡" : ""; 38 | tr.appendChild(kyujyo); 39 | }); 40 | } 41 | 42 | // Maches rank and name 43 | const BANZUKE_REGEX = /^ *(\w{1,2}\d{1,3}[ew]) *(\w+).*?( x)?$/gm; 44 | 45 | interface Rikishi { 46 | rank: string; 47 | name: string; 48 | is_kyujyo: boolean; 49 | } 50 | 51 | function parseBanzuke(str: string): Rikishi[] { 52 | const rikishi: Rikishi[] = []; 53 | let match: any[] | null; 54 | while ((match = BANZUKE_REGEX.exec(str)) !== null) { 55 | rikishi.push({ 56 | rank: match[1] as string, 57 | name: match[2] as string, 58 | is_kyujyo: match[3] !== undefined, 59 | }); 60 | } 61 | return rikishi; 62 | } 63 | 64 | bashoForm.addEventListener("submit", (event) => { 65 | event.preventDefault(); 66 | const data = { 67 | venue: bashoForm.elements.venue.value, 68 | start_date: bashoForm.elements.start_date.value, 69 | banzuke: parsedBanzuke, 70 | notify_kyujyo: bashoForm.elements.notify_kyujyo.checked, 71 | }; 72 | const url = location.href; 73 | fetch(url, { 74 | method: "POST", 75 | body: JSON.stringify(data), 76 | headers: new Headers({ 77 | "Content-Type": "application/json", 78 | }), 79 | credentials: "same-origin", 80 | }) 81 | .then(async (response) => { 82 | if (response.ok) { 83 | return await response.json(); 84 | } else { 85 | return await response.text().then((msg) => { 86 | throw new Error(msg); 87 | }); 88 | } 89 | }) 90 | .then((json) => { 91 | console.log("json:", json); 92 | alertSendStats(json.notification_stats); 93 | window.location = json.basho_url; 94 | }) 95 | .catch((err: Error) => { 96 | alert(`error saving basho: ${err.toString()}`); 97 | }); 98 | }); 99 | 100 | bashoFormInput(); 101 | -------------------------------------------------------------------------------- /src/handlers/index.rs: -------------------------------------------------------------------------------- 1 | use super::{BaseTemplate, IdentityExt, Result}; 2 | use crate::data::leaders::PlayerRanking; 3 | use crate::data::{BashoId, BashoInfo, Rank}; 4 | use crate::util::GroupRuns; 5 | use crate::AppState; 6 | use actix_identity::Identity; 7 | use actix_web::http::header::LOCATION; 8 | use actix_web::{get, route, web, HttpResponse}; 9 | use askama::Template; 10 | use askama_web::WebTemplate; 11 | 12 | #[derive(Template, WebTemplate)] 13 | #[template(path = "index.html")] 14 | pub struct IndexTemplate { 15 | base: BaseTemplate, 16 | leaders: Vec, 17 | self_leader_index: Option, 18 | current_basho: Option, 19 | prev_basho: Option, 20 | next_basho_id: BashoId, 21 | hero_img_src: String, 22 | } 23 | 24 | impl IndexTemplate { 25 | fn leaders_by_rank(&self) -> Vec<(Rank, usize, u32, &[PlayerRanking])> { 26 | self.leaders 27 | .group_runs(|a, b| a.rank == b.rank) 28 | .map(|group| { 29 | let first = group.first().unwrap(); 30 | (first.rank, first.ord, first.wins, group) 31 | }) 32 | .collect() 33 | } 34 | 35 | fn self_leader(&self) -> Option<&PlayerRanking> { 36 | self.self_leader_index.and_then(|i| self.leaders.get(i)) 37 | } 38 | 39 | fn is_self(&self, leader: &PlayerRanking) -> bool { 40 | if let Some(self_leader) = self.self_leader() { 41 | self_leader.player.id == leader.player.id 42 | } else { 43 | false 44 | } 45 | } 46 | } 47 | 48 | #[route("/", method = "GET", method = "HEAD")] 49 | pub async fn index( 50 | state: web::Data, 51 | identity: Option, 52 | ) -> Result { 53 | let db = state.db.lock().unwrap(); 54 | let (current_basho, prev_basho) = BashoInfo::current_and_previous(&db)?; 55 | let next_basho_id = prev_basho 56 | .as_ref() 57 | .map(|basho| basho.id.next()) 58 | .unwrap_or_else(|| "201911".parse().unwrap()); 59 | let leaders = PlayerRanking::for_home_page(&db, next_basho_id)?; 60 | let self_leader_index = match identity.as_ref() { 61 | Some(id) => { 62 | let player_id = id.player_id()?; 63 | leaders.iter().position(|l| l.player.id == player_id) 64 | } 65 | None => None, 66 | }; 67 | Ok(IndexTemplate { 68 | base: BaseTemplate::new(&db, identity.as_ref(), &state)?, 69 | leaders, 70 | self_leader_index, 71 | current_basho, 72 | prev_basho, 73 | next_basho_id, 74 | hero_img_src: state.config.hero_img_src.to_owned(), 75 | }) 76 | } 77 | 78 | #[get("/pwa")] 79 | pub async fn pwa( 80 | state: web::Data, 81 | query: web::Query>, 82 | ) -> Result { 83 | let db = state.db.lock().unwrap(); 84 | let (current_basho, _) = BashoInfo::current_and_previous(&db)?; 85 | 86 | let mut page = state.config.url(); 87 | page.query_pairs_mut().extend_pairs(query.iter()); 88 | if let Some(basho) = current_basho { 89 | page.set_path(&basho.link_url()); 90 | } 91 | 92 | Ok(HttpResponse::TemporaryRedirect() 93 | .insert_header((LOCATION, page.to_string())) 94 | .finish()) 95 | } 96 | -------------------------------------------------------------------------------- /templates/login.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block main_id %}p-login{% endblock %} 4 | {% block subtitle %}login{% endblock %} 5 | 6 | {% block head %} 7 | 8 | 9 | {% endblock %} 10 | 11 | {% block main %} 12 |
13 |

Already have an account?

14 | 15 |
16 | 17 | 24 | 25 |
26 | 27 | 71 |
72 | 73 |
74 | or 75 |
76 | 77 |
78 |

Create a new account:

79 | 87 | 95 | 103 |
104 | {% endblock %} 105 | -------------------------------------------------------------------------------- /src/data/award.rs: -------------------------------------------------------------------------------- 1 | use super::{BashoId, DataError, PlayerId}; 2 | use rusqlite::types::{FromSql, FromSqlError, FromSqlResult, ToSqlOutput, ValueRef}; 3 | use rusqlite::{Connection, ToSql}; 4 | use std::fmt::Display; 5 | use std::str::FromStr; 6 | 7 | #[derive(Debug, PartialEq, PartialOrd, Eq, Ord, Copy, Clone, serde::Serialize)] 8 | pub enum Award { 9 | EmperorsCup = 1, 10 | } 11 | 12 | impl Award { 13 | pub fn emoji(self) -> &'static str { 14 | match self { 15 | Award::EmperorsCup => "🏆", 16 | } 17 | } 18 | 19 | pub fn _bestow( 20 | self, 21 | db: &mut Connection, 22 | basho_id: BashoId, 23 | player_id: PlayerId, 24 | ) -> Result<(), DataError> { 25 | db.prepare( 26 | " 27 | INSERT INTO award (basho_id, type, player_id) 28 | VALUES (?, ?, ?) 29 | ", 30 | )? 31 | .execute(params![basho_id, self, player_id]) 32 | .map(|_| ()) 33 | .map_err(|e| e.into()) 34 | } 35 | 36 | pub fn _revoke( 37 | self, 38 | db: &mut Connection, 39 | basho_id: BashoId, 40 | player_id: PlayerId, 41 | ) -> Result<(), DataError> { 42 | db.prepare( 43 | " 44 | DELETE FROM award 45 | WHERE basho_id = ? AND type = ? AND player_id = ? 46 | ", 47 | )? 48 | .execute(params![basho_id, self, player_id]) 49 | .and_then(|count| match count { 50 | 1 => Ok(()), 51 | n => Err(rusqlite::Error::StatementChangedRows(n)), 52 | }) 53 | .map_err(|e| e.into()) 54 | } 55 | 56 | pub fn parse_list(opt_string: Option) -> Vec { 57 | if let Some(string) = opt_string { 58 | if string.is_empty() { 59 | vec![] 60 | } else { 61 | string 62 | .split(',') 63 | .filter_map(|a| match a.parse() { 64 | Err(e) => { 65 | warn!("failed to parse award type {}: {}", a, e); 66 | None 67 | } 68 | Ok(award) => Some(award), 69 | }) 70 | .collect() 71 | } 72 | } else { 73 | vec![] 74 | } 75 | } 76 | } 77 | 78 | impl Display for Award { 79 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 80 | f.write_str(self.emoji()) 81 | } 82 | } 83 | 84 | impl FromStr for Award { 85 | type Err = String; 86 | 87 | fn from_str(s: &str) -> Result { 88 | match s { 89 | "1" => Ok(Award::EmperorsCup), 90 | _ => Err(format!("unknown award type {}", s)), 91 | } 92 | } 93 | } 94 | 95 | impl FromSql for Award { 96 | fn column_result(value: ValueRef) -> FromSqlResult { 97 | value.as_i64().and_then(|num| match num { 98 | 1 => Ok(Award::EmperorsCup), 99 | _ => Err(FromSqlError::OutOfRange(num)), 100 | }) 101 | } 102 | } 103 | 104 | impl ToSql for Award { 105 | fn to_sql(&self) -> rusqlite::Result> { 106 | Ok(ToSqlOutput::from(*self as u8)) 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /templates/settings.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block subtitle %}Settings{% endblock %} 4 | {% block main_id %}p-settings{% endblock %} 5 | 6 | {% block head %} 7 | 8 | 9 | {% endblock %} 10 | 11 | {% block main %} 12 |
13 |
14 |

Settings

15 | 16 |
17 | Profile 18 | 31 |

32 | Your profile picture is synced with your external account when you log 33 | in. To update your Kachi Clash avatar, first update your profile pic 34 | in your external service account (Discord, Reddit, Google, etc), then 35 | log out and log back into Kachi Clash using the same account. 36 |

37 |
38 | 39 |
40 | Notifications 41 |

Enable push notifications on this device for:

42 |

43 | Please check browser settings to allow notifications from this site. 44 |

45 |

46 | Push notifications are not supported in this browser. 47 |

48 |

49 | On iOS and iPadOS 16.4 or newer, push notifications can be enabled by 50 | opening the share menu and choosing “Add to Home Screen”. 51 |

52 |
    53 |
  • 54 | 58 |
  • 59 |
  • 60 | 64 |
  • 65 |
  • 66 | 70 |
  • 71 |
  • 72 | 76 |
  • 77 |
  • 78 | 82 |
  • 83 |
84 | 87 |
88 | 89 | 90 | 91 |
92 |
93 |
94 | {% endblock %} 95 | -------------------------------------------------------------------------------- /public/fonts/stylesheet.css: -------------------------------------------------------------------------------- 1 | /*! Generated by Font Squirrel (https://www.fontsquirrel.com) on November 25, 2022 */ 2 | 3 | @font-face { 4 | font-family: "exo 2"; 5 | src: 6 | url("exo2-black-webfont.woff2") format("woff2"), 7 | url("exo2-black-webfont.woff") format("woff"); 8 | font-weight: 900; 9 | font-style: normal; 10 | } 11 | 12 | @font-face { 13 | font-family: "exo 2"; 14 | src: 15 | url("exo2-blackitalic-webfont.woff2") format("woff2"), 16 | url("exo2-blackitalic-webfont.woff") format("woff"); 17 | font-weight: 900; 18 | font-style: italic; 19 | } 20 | 21 | @font-face { 22 | font-family: "exo 2"; 23 | src: 24 | url("exo2-extrabold-webfont.woff2") format("woff2"), 25 | url("exo2-extrabold-webfont.woff") format("woff"); 26 | font-weight: 800; 27 | font-style: normal; 28 | } 29 | 30 | @font-face { 31 | font-family: "exo 2"; 32 | src: 33 | url("exo2-extrabolditalic-webfont.woff2") format("woff2"), 34 | url("exo2-extrabolditalic-webfont.woff") format("woff"); 35 | font-weight: 800; 36 | font-style: italic; 37 | } 38 | 39 | @font-face { 40 | font-family: "exo 2"; 41 | src: 42 | url("exo2-bold-webfont.woff2") format("woff2"), 43 | url("exo2-bold-webfont.woff") format("woff"); 44 | font-weight: 600 700; 45 | font-style: normal; 46 | } 47 | 48 | @font-face { 49 | font-family: "exo 2"; 50 | src: 51 | url("exo2-bolditalic-webfont.woff2") format("woff2"), 52 | url("exo2-bolditalic-webfont.woff") format("woff"); 53 | font-weight: 600 700; 54 | font-style: italic; 55 | } 56 | 57 | @font-face { 58 | font-family: "exo 2"; 59 | src: 60 | url("exo2-medium-webfont.woff2") format("woff2"), 61 | url("exo2-medium-webfont.woff") format("woff"); 62 | font-weight: 500; 63 | font-style: normal; 64 | } 65 | 66 | @font-face { 67 | font-family: "exo 2"; 68 | src: 69 | url("exo2-mediumitalic-webfont.woff2") format("woff2"), 70 | url("exo2-mediumitalic-webfont.woff") format("woff"); 71 | font-weight: 500; 72 | font-style: italic; 73 | } 74 | 75 | @font-face { 76 | font-family: "exo 2"; 77 | src: 78 | url("exo2-regular-webfont.woff2") format("woff2"), 79 | url("exo2-regular-webfont.woff") format("woff"); 80 | font-weight: 400; 81 | font-style: normal; 82 | } 83 | 84 | @font-face { 85 | font-family: "exo 2"; 86 | src: 87 | url("exo2-italic-webfont.woff2") format("woff2"), 88 | url("exo2-italic-webfont.woff") format("woff"); 89 | font-weight: 400; 90 | font-style: italic; 91 | } 92 | 93 | @font-face { 94 | font-family: "exo 2"; 95 | src: 96 | url("exo2-light-webfont.woff2") format("woff2"), 97 | url("exo2-light-webfont.woff") format("woff"); 98 | font-weight: 300; 99 | font-style: normal; 100 | } 101 | 102 | @font-face { 103 | font-family: "exo 2"; 104 | src: 105 | url("exo2-lightitalic-webfont.woff2") format("woff2"), 106 | url("exo2-lightitalic-webfont.woff") format("woff"); 107 | font-weight: 300; 108 | font-style: italic; 109 | } 110 | 111 | @font-face { 112 | font-family: "exo 2"; 113 | src: 114 | url("exo2-extralight-webfont.woff2") format("woff2"), 115 | url("exo2-extralight-webfont.woff") format("woff"); 116 | font-weight: 100 200; 117 | font-style: normal; 118 | } 119 | 120 | @font-face { 121 | font-family: "exo 2"; 122 | src: 123 | url("exo2-extralightitalic-webfont.woff2") format("woff2"), 124 | url("exo2-extralightitalic-webfont.woff") format("woff"); 125 | font-weight: 100 200; 126 | font-style: italic; 127 | } 128 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | #[macro_use] 2 | extern crate log; 3 | extern crate actix_identity; 4 | extern crate actix_web; 5 | extern crate envconfig; 6 | extern crate pretty_env_logger; 7 | extern crate reqwest; 8 | #[macro_use] 9 | extern crate serde_derive; 10 | extern crate serde; 11 | extern crate serde_json; 12 | #[macro_use] 13 | extern crate rusqlite; 14 | 15 | use crate::data::push::PushBuilder; 16 | use envconfig::Envconfig; 17 | use std::path::PathBuf; 18 | use url::Url; 19 | 20 | mod data; 21 | mod external; 22 | mod handlers; 23 | mod server; 24 | mod util; 25 | 26 | #[derive(Envconfig, Clone, Debug)] 27 | pub struct Config { 28 | #[envconfig(from = "KACHI_ENV", default = "dev")] 29 | pub env: String, 30 | 31 | #[envconfig(from = "KACHI_STATIC_PATH", default = "public")] 32 | pub static_path: PathBuf, 33 | 34 | #[envconfig(from = "KACHI_DB_PATH", default = "var/kachiclash.sqlite")] 35 | pub db_path: PathBuf, 36 | 37 | #[envconfig(from = "KACHI_HOST", default = "kachiclash.com")] 38 | pub host: String, 39 | 40 | #[envconfig(from = "KACHI_PORT")] 41 | pub port: u16, 42 | 43 | #[envconfig( 44 | from = "KACHI_HERO", 45 | default = "/static/img2/2021-Kachi-Clash-Banner-2.png" 46 | )] 47 | pub hero_img_src: String, 48 | 49 | #[envconfig(from = "SESSION_SECRET")] 50 | pub session_secret: String, 51 | 52 | #[envconfig(from = "WEBHOOK_SECRET")] 53 | pub webhook_secret: String, 54 | 55 | #[envconfig(from = "VAPID_PUBLIC_KEY")] 56 | pub vapid_public_key: String, 57 | 58 | #[envconfig(from = "VAPID_PRIVATE_KEY")] 59 | pub vapid_private_key: String, 60 | 61 | #[envconfig(from = "DISCORD_CLIENT_ID")] 62 | pub discord_client_id: String, 63 | 64 | #[envconfig(from = "DISCORD_CLIENT_SECRET")] 65 | pub discord_client_secret: String, 66 | 67 | #[envconfig(from = "GOOGLE_CLIENT_ID")] 68 | pub google_client_id: String, 69 | 70 | #[envconfig(from = "GOOGLE_CLIENT_SECRET")] 71 | pub google_client_secret: String, 72 | 73 | #[envconfig(from = "REDDIT_CLIENT_ID")] 74 | pub reddit_client_id: String, 75 | 76 | #[envconfig(from = "REDDIT_CLIENT_SECRET")] 77 | pub reddit_client_secret: String, 78 | } 79 | 80 | impl Config { 81 | pub fn is_dev(&self) -> bool { 82 | self.env == "dev" 83 | } 84 | 85 | pub fn url(&self) -> Url { 86 | let mut url = Url::parse(format!("https://{}:{}", self.host, self.port).as_str()) 87 | .expect("create base url for host"); 88 | if self.is_dev() { 89 | url.set_scheme("http").expect("set scheme to unsecure http"); 90 | } else { 91 | url.set_port(None).expect("remove url port"); 92 | } 93 | url 94 | } 95 | } 96 | 97 | #[derive(Clone)] 98 | pub struct AppState { 99 | config: Config, 100 | db: data::DbConn, 101 | push: data::push::PushBuilder, 102 | } 103 | 104 | pub fn init_env() -> anyhow::Result { 105 | if std::env::var_os("RUST_LOG").is_none() { 106 | std::env::set_var("RUST_LOG", "info,kachiclash=debug"); 107 | } 108 | //std::env::set_var("RUST_LOG", "debug"); 109 | pretty_env_logger::init(); 110 | 111 | let config = Config::init_from_env().expect("Could not read config from environment"); 112 | let db = data::make_conn(&config.db_path); 113 | let push = PushBuilder::with_base64_private_key(&config.vapid_private_key)?; 114 | 115 | Ok(AppState { config, db, push }) 116 | } 117 | 118 | pub async fn run_server(app_state: &AppState) -> anyhow::Result<()> { 119 | server::run(app_state).await 120 | } 121 | -------------------------------------------------------------------------------- /public/ts/basho-admin.ts: -------------------------------------------------------------------------------- 1 | import { adminTrigger, alertSendStats } from "./push.js"; 2 | 3 | document.querySelectorAll(".bestow-emperors-cup-button").forEach((button) => { 4 | button.addEventListener("click", () => { 5 | void postCup(button, true); 6 | }); 7 | }); 8 | document.querySelectorAll(".revoke-emperors-cup-button").forEach((button) => { 9 | button.addEventListener("click", () => { 10 | void postCup(button, false); 11 | }); 12 | }); 13 | 14 | async function postCup(button: Element, bestow: boolean): Promise { 15 | if ( 16 | !(button instanceof HTMLButtonElement) || 17 | button.dataset.playerId === undefined 18 | ) { 19 | throw new Error(`unexpected button element: ${button.tagName}`); 20 | } 21 | const data = { 22 | player_id: parseInt(button.dataset.playerId), 23 | }; 24 | const url = 25 | location.href + "/" + (bestow ? "bestow" : "revoke") + "_emperors_cup"; 26 | const response = await fetch(url, { 27 | method: "POST", 28 | body: JSON.stringify(data), 29 | headers: new Headers({ 30 | "Content-Type": "application/json", 31 | }), 32 | credentials: "same-origin", 33 | }); 34 | if (response.ok) { 35 | alert("Emperor's Cup has been " + (bestow ? "bestowed" : "revoked")); 36 | } else { 37 | const text = await response.text(); 38 | alert("error: " + text); 39 | } 40 | } 41 | 42 | const bashoId = ( 43 | document.querySelector('meta[name="basho_id"]') as HTMLMetaElement 44 | ).content; 45 | 46 | document 47 | .querySelector(".trigger-entries-open") 48 | ?.addEventListener("click", (event) => { 49 | (event.target as HTMLButtonElement).disabled = true; 50 | event.preventDefault(); 51 | void adminTrigger({ EntriesOpen: bashoId }); 52 | }); 53 | document 54 | .querySelector(".trigger-countdown") 55 | ?.addEventListener("click", (event) => { 56 | (event.target as HTMLButtonElement).disabled = true; 57 | event.preventDefault(); 58 | void adminTrigger({ BashoStartCountdown: bashoId }); 59 | }); 60 | 61 | document 62 | .querySelector(".update-torikumi") 63 | ?.addEventListener("click", (event) => { 64 | event.preventDefault(); 65 | const button = event.target as HTMLButtonElement; 66 | button.disabled = true; 67 | updateTorikumi(button.dataset.day ?? "1").finally(() => { 68 | button.disabled = false; 69 | }); 70 | }); 71 | async function updateTorikumi(defaultDay: string): Promise { 72 | const day = parseInt(prompt("Day:", defaultDay) ?? "NaN"); 73 | if (isNaN(day)) return; 74 | 75 | const notify = confirm("Send push notifications?"); 76 | 77 | const res = await fetch(`/basho/${bashoId}/day/${day}`, { 78 | method: "POST", 79 | credentials: "same-origin", 80 | body: JSON.stringify({ notify }), 81 | headers: { 82 | "Content-Type": "application/json", 83 | }, 84 | }); 85 | alertSendStats(await res.json()); 86 | location.reload(); 87 | } 88 | 89 | document 90 | .querySelector(".finalize-basho") 91 | ?.addEventListener("click", (event) => { 92 | (event.target as HTMLButtonElement).disabled = true; 93 | event.preventDefault(); 94 | void finalizeBasho(); 95 | }); 96 | async function finalizeBasho(): Promise { 97 | const res = await fetch(`/basho/${bashoId}/finalize`, { 98 | method: "POST", 99 | credentials: "same-origin", 100 | }); 101 | alertSendStats(await res.json()); 102 | location.reload(); 103 | } 104 | 105 | document.querySelector(".hide-admin")?.addEventListener("click", (event) => { 106 | event.preventDefault(); 107 | document.getElementById("admin")?.remove(); 108 | }); 109 | 110 | export default {}; 111 | -------------------------------------------------------------------------------- /public/ts/login.ts: -------------------------------------------------------------------------------- 1 | interface AuthProviderInfo { 2 | provider_name: string; 3 | } 4 | 5 | interface LookupResponse { 6 | providers: AuthProviderInfo[]; 7 | } 8 | 9 | const lookupForm = document.getElementById("lookup-form") as HTMLFormElement; 10 | const usernameInput = document.getElementById( 11 | "username-input", 12 | ) as HTMLInputElement; 13 | const lookupButton = lookupForm.querySelector( 14 | "button[type=submit]", 15 | ) as HTMLButtonElement; 16 | const lookupError = document.getElementById("lookup-error") as HTMLElement; 17 | const lookupResult = document.getElementById("lookup-result") as HTMLElement; 18 | const multipleProviders = document.getElementById( 19 | "multiple-providers", 20 | ) as HTMLElement; 21 | const discordLoginBtn = document.getElementById( 22 | "discord-login-btn", 23 | ) as HTMLAnchorElement; 24 | const googleLoginBtn = document.getElementById( 25 | "google-login-btn", 26 | ) as HTMLAnchorElement; 27 | const redditLoginBtn = document.getElementById( 28 | "reddit-login-btn", 29 | ) as HTMLAnchorElement; 30 | 31 | lookupForm.addEventListener("submit", async (e) => { 32 | e.preventDefault(); 33 | 34 | const username = usernameInput.value.trim(); 35 | if (!username) { 36 | return; 37 | } 38 | 39 | // Reset UI 40 | lookupError.style.display = "none"; 41 | lookupResult.style.display = "none"; 42 | multipleProviders.style.display = "none"; 43 | // Hide all provider buttons 44 | discordLoginBtn.style.display = "none"; 45 | googleLoginBtn.style.display = "none"; 46 | redditLoginBtn.style.display = "none"; 47 | lookupButton.disabled = true; 48 | lookupButton.textContent = "Looking up..."; 49 | 50 | try { 51 | const response = await fetch( 52 | `/login/lookup?username=${encodeURIComponent(username)}`, 53 | ); 54 | 55 | if (!response.ok) { 56 | if (response.status === 404) { 57 | showError( 58 | "Username not found. Please check the spelling or create a new account.", 59 | ); 60 | } else { 61 | showError( 62 | "An error occurred while looking up your account. Please try again.", 63 | ); 64 | } 65 | resetButton(); 66 | return; 67 | } 68 | 69 | const data = (await response.json()) as LookupResponse; 70 | const providers = data.providers || []; 71 | 72 | if (providers.length === 0) { 73 | showError("Unable to determine login provider. Please try again."); 74 | resetButton(); 75 | return; 76 | } 77 | 78 | handleMultipleProviders(providers); 79 | } catch (error) { 80 | console.error("Lookup error:", error); 81 | showError( 82 | "An error occurred while looking up your account. Please try again.", 83 | ); 84 | resetButton(); 85 | } 86 | }); 87 | 88 | function showError(message: string): void { 89 | lookupError.textContent = message; 90 | lookupError.style.display = "block"; 91 | } 92 | 93 | function resetButton(): void { 94 | lookupButton.disabled = false; 95 | lookupButton.textContent = "Login"; 96 | } 97 | 98 | function handleMultipleProviders(providers: AuthProviderInfo[]): void { 99 | multipleProviders.style.display = "block"; 100 | lookupResult.style.display = "block"; 101 | 102 | // Show the appropriate provider buttons based on what's available 103 | providers.forEach((provider) => { 104 | const name = provider.provider_name.toLowerCase(); 105 | 106 | if (name === "discord") { 107 | discordLoginBtn.style.display = "flex"; 108 | } else if (name === "google") { 109 | googleLoginBtn.style.display = "flex"; 110 | } else if (name === "reddit") { 111 | redditLoginBtn.style.display = "flex"; 112 | } 113 | }); 114 | 115 | resetButton(); 116 | } 117 | -------------------------------------------------------------------------------- /src/handlers/webhook.rs: -------------------------------------------------------------------------------- 1 | use std::ops::Deref; 2 | 3 | use actix_identity::Identity; 4 | use actix_web::http::header::{self, from_one_raw_str, ContentType, TryIntoHeaderValue}; 5 | use actix_web::{post, web, HttpRequest, HttpResponse, Responder}; 6 | use anyhow::anyhow; 7 | 8 | use super::{BaseTemplate, Result}; 9 | use crate::data::push::mass_notify_day_result; 10 | use crate::external::sumo_api; 11 | use crate::AppState; 12 | 13 | #[post("/register")] 14 | pub async fn register(state: web::Data, identity: Identity) -> Result { 15 | BaseTemplate::for_admin(&state.db.lock().unwrap(), &identity, &state)?; 16 | Ok(sumo_api::register_webhook(&state.config).await?) 17 | } 18 | 19 | #[post("/delete")] 20 | pub async fn delete(state: web::Data, identity: Identity) -> Result { 21 | BaseTemplate::for_admin(&state.db.lock().unwrap(), &identity, &state)?; 22 | Ok(sumo_api::delete_webhook(&state.config).await?) 23 | } 24 | 25 | #[derive(Deserialize)] 26 | struct TestParams { 27 | #[serde(rename = "type")] 28 | webhook_type: String, 29 | } 30 | 31 | #[post("/test")] 32 | pub async fn request_test( 33 | state: web::Data, 34 | query: web::Form, 35 | identity: Identity, 36 | ) -> Result { 37 | BaseTemplate::for_admin(&state.db.lock().unwrap(), &identity, &state)?; 38 | Ok(sumo_api::request_webhook_test(&state.config, &query.webhook_type).await?) 39 | } 40 | 41 | struct XWebhookSignature(String); 42 | 43 | impl TryIntoHeaderValue for XWebhookSignature { 44 | type Error = actix_web::error::HttpError; 45 | 46 | fn try_into_value(self) -> std::result::Result { 47 | header::HeaderValue::from_str(&self.0).map_err(|e| e.into()) 48 | } 49 | } 50 | 51 | impl header::Header for XWebhookSignature { 52 | fn name() -> header::HeaderName { 53 | header::HeaderName::from_static("x-webhook-signature") 54 | } 55 | 56 | fn parse( 57 | msg: &M, 58 | ) -> std::result::Result { 59 | let str: std::result::Result = from_one_raw_str(msg.headers().get(Self::name())); 60 | Ok(Self(str.unwrap_or_else(|_| "".to_string()))) 61 | } 62 | } 63 | 64 | #[post("/sumo_api")] 65 | pub async fn receive_sumo_api( 66 | req: HttpRequest, 67 | body: web::Bytes, 68 | sig: web::Header, 69 | content_type: web::Header, 70 | state: web::Data, 71 | ) -> Result { 72 | if content_type.0 != ContentType::json() { 73 | return Err(anyhow!("Unexpected content type: {}", content_type.0).into()); 74 | } 75 | let data = match serde_json::from_slice(&body) { 76 | Ok(data) => data, 77 | Err(e) => { 78 | warn!("Failed to parse webhook JSON payload: {}", e); 79 | trace!("Request body:\n{}", String::from_utf8_lossy(&body)); 80 | return Err(anyhow!("Failed to parse webhook JSON payload: {}", e).into()); 81 | } 82 | }; 83 | let url = state.config.url().join(req.path()).unwrap(); 84 | 85 | let sumo_api::ReceiveWebhookResult { 86 | basho_id, 87 | day, 88 | should_send_notifications, 89 | } = sumo_api::receive_webhook( 90 | &url, 91 | &body, 92 | &sig.deref().0, 93 | &data, 94 | &mut state.db.lock().unwrap(), 95 | &state.config.webhook_secret, 96 | )?; 97 | if should_send_notifications { 98 | let stats = 99 | mass_notify_day_result(&state.db, &state.push, &state.config.url(), basho_id, day) 100 | .await?; 101 | info!("push notifications sent: {:?}", stats); 102 | } 103 | Ok(HttpResponse::NoContent().finish()) 104 | } 105 | -------------------------------------------------------------------------------- /templates/stats.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block subtitle %}Stats{% endblock %} 4 | {% block main_id %}p-stats{% endblock %} 5 | 6 | {% block head %} 7 | 8 | {% endblock %} 9 | 10 | {% block main %} 11 |
12 |

Previous Emperor's Cup Winners

13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | {% for basho in basho_list -%} 22 | {% if !basho.winners.is_empty() %} 23 | 24 | 27 | 28 | 34 | 40 | 41 | {% endif %} 42 | {% endfor %} 43 | 44 |
BashoPlayersWinnerScore
25 | {{ basho.id|fmt("{:#}") }} 26 | {{ basho.player_count }} 29 | {% for player in basho.winners -%} 30 | {{ player.render().unwrap()|safe }} 31 | {%- if !loop.last %},{% endif -%} 32 | {%- endfor %} 33 | 35 | {% match basho.winning_score %} 36 | {% when Some with (score) %}{{ score }} 37 | {% when None %} 38 | {% endmatch %} 39 |
45 |
46 | 47 |
48 |

Players

49 |

50 | Ranked by total wins recorded in the 51 | past {{ leader_basho_count }} basho. 52 |

53 |

54 | See also: past 55 | {% for c in leader_basho_count_options %} 56 | {{ c }} basho{% if !loop.last %},{% endif -%} 58 | {%- endfor %}. 59 |

60 | 61 | {% match self.self_leader() %} 62 | {% when Some with (leader) %} 63 |

64 | You are currently ranked #{{ leader.ord }} 65 |

66 | {% when None %} 67 | {% endmatch %} 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | {% for leader in leaders %} 89 | 90 | 91 | 92 | 95 | 96 | 97 | 100 | 101 | 102 | 105 | 106 | {% endfor %} 107 | 108 |
RankPlayerBasho RanksWins
bestworstavgmostleastavgtotal
{{ leader.ord }}{{ leader.player.render().unwrap()|safe }} 93 | {% call numeric_stat(leader.ranks.min) %} 94 | {% call numeric_stat(leader.ranks.max) %}{% call numeric_stat(leader.ranks.mean) %} 98 | {% call numeric_stat(leader.wins.max) %} 99 | {% call numeric_stat(leader.wins.min) %}{% call numeric_stat(leader.wins.mean) %} 103 | {% call numeric_stat(leader.wins.total) %} 104 |
109 |
110 | {% endblock %} 111 | 112 | {%- macro numeric_stat(opt_num) -%} 113 | {%- match opt_num -%} 114 | {%- when Some with (num) -%} 115 | {{ "{:.1}"|format(num) }} 116 | {%- when None -%} 117 | {%- endmatch -%} 118 | {%- endmacro -%} 119 | -------------------------------------------------------------------------------- /scripts/Sumo Banzuke Process J.sublime-macro: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "args": { 4 | "to": "bol" 5 | }, 6 | "command": "move_to" 7 | }, 8 | { 9 | "args": { 10 | "by": "lines", 11 | "forward": true 12 | }, 13 | "command": "move" 14 | }, 15 | { 16 | "args": { 17 | "file": "res://Packages/Default/Delete to Hard EOL.sublime-macro" 18 | }, 19 | "command": "run_macro_file" 20 | }, 21 | { 22 | "args": { 23 | "file": "res://Packages/Default/Delete to Hard EOL.sublime-macro" 24 | }, 25 | "command": "run_macro_file" 26 | }, 27 | { 28 | "args": { 29 | "file": "res://Packages/Default/Delete to Hard EOL.sublime-macro" 30 | }, 31 | "command": "run_macro_file" 32 | }, 33 | { 34 | "args": { 35 | "file": "res://Packages/Default/Delete to Hard EOL.sublime-macro" 36 | }, 37 | "command": "run_macro_file" 38 | }, 39 | { 40 | "args": { 41 | "file": "res://Packages/Default/Delete to Hard EOL.sublime-macro" 42 | }, 43 | "command": "run_macro_file" 44 | }, 45 | { 46 | "args": { 47 | "file": "res://Packages/Default/Delete to Hard EOL.sublime-macro" 48 | }, 49 | "command": "run_macro_file" 50 | }, 51 | { 52 | "args": { 53 | "by": "characters", 54 | "forward": true 55 | }, 56 | "command": "move" 57 | }, 58 | { 59 | "args": { 60 | "extend": true, 61 | "to": "eol" 62 | }, 63 | "command": "move_to" 64 | }, 65 | { 66 | "args": null, 67 | "command": "copy" 68 | }, 69 | { 70 | "args": { 71 | "by": "lines", 72 | "forward": false 73 | }, 74 | "command": "move" 75 | }, 76 | { 77 | "args": { 78 | "to": "bol" 79 | }, 80 | "command": "move_to" 81 | }, 82 | { 83 | "args": { 84 | "characters": "J" 85 | }, 86 | "command": "insert" 87 | }, 88 | { 89 | "args": null, 90 | "command": "paste" 91 | }, 92 | { 93 | "args": { 94 | "characters": "e" 95 | }, 96 | "command": "insert" 97 | }, 98 | { 99 | "args": { 100 | "characters": " " 101 | }, 102 | "command": "insert" 103 | }, 104 | { 105 | "args": { 106 | "by": "lines", 107 | "forward": true 108 | }, 109 | "command": "move" 110 | }, 111 | { 112 | "args": { 113 | "to": "bol" 114 | }, 115 | "command": "move_to" 116 | }, 117 | { 118 | "args": { 119 | "file": "res://Packages/Default/Delete to Hard EOL.sublime-macro" 120 | }, 121 | "command": "run_macro_file" 122 | }, 123 | { 124 | "args": { 125 | "file": "res://Packages/Default/Delete to Hard EOL.sublime-macro" 126 | }, 127 | "command": "run_macro_file" 128 | }, 129 | { 130 | "args": { 131 | "file": "res://Packages/Default/Delete to Hard EOL.sublime-macro" 132 | }, 133 | "command": "run_macro_file" 134 | }, 135 | { 136 | "args": { 137 | "characters": "J" 138 | }, 139 | "command": "insert" 140 | }, 141 | { 142 | "args": null, 143 | "command": "paste" 144 | }, 145 | { 146 | "args": { 147 | "characters": "w" 148 | }, 149 | "command": "insert" 150 | }, 151 | { 152 | "args": { 153 | "characters": " " 154 | }, 155 | "command": "insert" 156 | }, 157 | { 158 | "args": { 159 | "by": "lines", 160 | "forward": true 161 | }, 162 | "command": "move" 163 | }, 164 | { 165 | "args": { 166 | "to": "bol" 167 | }, 168 | "command": "move_to" 169 | }, 170 | { 171 | "args": { 172 | "file": "res://Packages/Default/Delete to Hard EOL.sublime-macro" 173 | }, 174 | "command": "run_macro_file" 175 | }, 176 | { 177 | "args": { 178 | "file": "res://Packages/Default/Delete to Hard EOL.sublime-macro" 179 | }, 180 | "command": "run_macro_file" 181 | }, 182 | { 183 | "args": { 184 | "file": "res://Packages/Default/Delete to Hard EOL.sublime-macro" 185 | }, 186 | "command": "run_macro_file" 187 | }, 188 | { 189 | "args": { 190 | "file": "res://Packages/Default/Delete to Hard EOL.sublime-macro" 191 | }, 192 | "command": "run_macro_file" 193 | }, 194 | { 195 | "args": { 196 | "file": "res://Packages/Default/Delete to Hard EOL.sublime-macro" 197 | }, 198 | "command": "run_macro_file" 199 | }, 200 | { 201 | "args": { 202 | "file": "res://Packages/Default/Delete to Hard EOL.sublime-macro" 203 | }, 204 | "command": "run_macro_file" 205 | } 206 | ] -------------------------------------------------------------------------------- /src/external/google.rs: -------------------------------------------------------------------------------- 1 | use async_trait::async_trait; 2 | use chrono::{DateTime, Utc}; 3 | use oauth2::basic::BasicClient; 4 | use oauth2::{AuthUrl, ClientId, ClientSecret, RedirectUrl, TokenUrl}; 5 | use rusqlite::{Error, Transaction}; 6 | 7 | use super::AuthProvider; 8 | use crate::data::PlayerId; 9 | use crate::external::{OAuthClient, UserInfo}; 10 | use crate::Config; 11 | 12 | #[derive(Debug)] 13 | pub struct GoogleAuthProvider; 14 | 15 | #[async_trait] 16 | impl AuthProvider for GoogleAuthProvider { 17 | fn service_name(&self) -> &'static str { 18 | "Google" 19 | } 20 | 21 | fn logged_in_user_info_url(&self) -> &'static str { 22 | "https://www.googleapis.com/userinfo/v2/me" 23 | } 24 | 25 | fn oauth_scopes(&self) -> &'static [&'static str] { 26 | &["https://www.googleapis.com/auth/userinfo.profile"] 27 | } 28 | 29 | fn make_oauth_client(&self, config: &Config) -> OAuthClient { 30 | let mut redirect_url = config.url(); 31 | redirect_url.set_path("login/google_redirect"); 32 | 33 | BasicClient::new(ClientId::new(config.google_client_id.to_owned())) 34 | .set_client_secret(ClientSecret::new(config.google_client_secret.to_owned())) 35 | .set_auth_uri( 36 | AuthUrl::new("https://accounts.google.com/o/oauth2/v2/auth".to_string()).unwrap(), 37 | ) 38 | .set_token_uri( 39 | TokenUrl::new("https://oauth2.googleapis.com/token".to_string()).unwrap(), 40 | ) 41 | .set_redirect_uri(RedirectUrl::from_url(redirect_url)) 42 | } 43 | 44 | fn make_user_info_url(&self, user_id: &str) -> String { 45 | format!( 46 | "https://people.googleapis.com/v1/{{resourceName=people/{}}}?personFields=photos", 47 | user_id 48 | ) 49 | } 50 | 51 | async fn parse_user_info_response( 52 | &self, 53 | res: reqwest::Response, 54 | ) -> anyhow::Result> { 55 | Ok(Box::new(res.json::().await?)) 56 | } 57 | } 58 | 59 | #[derive(Debug, Deserialize, Clone)] 60 | pub struct GoogleUserInfo { 61 | pub id: String, 62 | pub name: Option, 63 | pub picture: Option, // url 64 | } 65 | 66 | impl UserInfo for GoogleUserInfo { 67 | fn update_existing_player( 68 | &self, 69 | txn: &Transaction, 70 | mod_date: DateTime, 71 | ) -> Result, Error> { 72 | match txn 73 | .prepare("SELECT player_id, name, picture FROM player_google WHERE id = ?")? 74 | .query_map( 75 | params![self.id], 76 | |row| -> Result<(PlayerId, Option, Option), _> { 77 | Ok((row.get("player_id")?, row.get("name")?, row.get("picture")?)) 78 | }, 79 | )? 80 | .next() 81 | { 82 | None => Ok(None), 83 | Some(Ok((player_id, name, picture))) => { 84 | if name != self.name || picture != self.picture { 85 | txn.execute( 86 | " 87 | UPDATE player_google 88 | SET name = ?, picture = ?, mod_date = ? 89 | WHERE id = ? 90 | ", 91 | params![self.name, self.picture, mod_date, self.id], 92 | )?; 93 | } 94 | Ok(Some(player_id)) 95 | } 96 | Some(Err(e)) => Err(e), 97 | } 98 | } 99 | 100 | fn insert_into_db( 101 | &self, 102 | txn: &Transaction, 103 | mod_date: DateTime, 104 | player_id: PlayerId, 105 | ) -> Result { 106 | txn.execute( 107 | " 108 | INSERT INTO player_google (player_id, id, name, picture, mod_date) 109 | VALUES (?, ?, ?, ?, ?)", 110 | params![player_id, self.id, self.name, self.picture, mod_date], 111 | ) 112 | } 113 | 114 | fn name_suggestion(&self) -> Option { 115 | self.name.to_owned() 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /templates/heya.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block main_id %}p-heya{% endblock %} 4 | 5 | {% block subtitle %} 6 | {{ heya.name }} 7 | {% endblock %} 8 | 9 | {% block head %} 10 | 11 | 12 | {% endblock %} 13 | 14 | {% block main %} 15 |

{{ heya.name }}

16 | 17 |
18 | {% if is_oyakata %} 19 |
20 | 21 | 22 |
23 | {% endif %} 24 |
25 |
Oyakata
26 |
{{ heya.oyakata.render().unwrap()|safe }}
27 |
Inaugurated
28 |
{{ heya.create_date.format("%Y-%m-%d") }}
29 |
30 |
31 | 32 |
33 |

Members

34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | {% for basho_id in heya.recent_scores_bashos.as_ref().unwrap().iter() %} 47 | 59 | {% endfor %} 60 | 61 | 62 | 63 | 64 | {% for member in heya.members.as_ref().unwrap() %} 65 | 66 | 67 | 68 | 69 | {% for score in member.recent_scores %} 70 | 82 | {% endfor %} 83 | 106 | 107 | {% endfor %} 108 | 109 |
Recent Scores
SinceYear 53 | 56 | {{ basho_id.season() }} 57 | 58 |
{{ member.player.render().unwrap()|safe }}{{ member.recruit_date.format("%Y") }}{{ member.recent_scores_total() }} 76 | {% if let Some(s) = score %} 77 | {{ s }} 78 | {% else %} 79 | -- 80 | {% endif %} 81 | 84 | {% if !member.is_oyakata && (is_oyakata || member.is_self) %} 85 |
91 | 96 | 103 |
104 | {% endif %} 105 |
110 |
111 | 112 | {% if let Some(player) = base.player %} 113 | {% if is_oyakata || player.is_admin() %} 114 |
115 |

Recruitment

116 | 117 |

118 | To recruit members, click the Recruit button on players’ 119 | profile pages. 120 |

121 | 122 | {% if player.is_admin() %} 123 |
124 | 125 | 126 |
127 | {% endif %} 128 |
129 | {% endif %} 130 | {% endif %} 131 | {% endblock %} 132 | -------------------------------------------------------------------------------- /public/ts/base.ts: -------------------------------------------------------------------------------- 1 | import { pushPermissionState } from "./service-client.js"; 2 | 3 | initBashoCountDown(); 4 | initUserMenu(); 5 | void initPushPromo(); 6 | 7 | // Init basho start count down clock 8 | function initBashoCountDown(): void { 9 | for (const timeSpan of document.querySelectorAll(".js-basho-count-down")) { 10 | if ( 11 | !(timeSpan instanceof HTMLElement) || 12 | timeSpan.dataset.startDate === undefined 13 | ) { 14 | throw new Error( 15 | `unexpected element with css class .js-basho-count-down: ${timeSpan.tagName}`, 16 | ); 17 | } 18 | const startTimestamp = parseInt(timeSpan.dataset.startDate); 19 | const updateTimeRemaining = function (): void { 20 | const remaining = (startTimestamp - Date.now()) / 1000; 21 | const seconds = Math.floor(remaining % 60); 22 | const minutes = Math.floor(remaining / 60) % 60; 23 | const hours = Math.floor(remaining / 60 / 60) % 24; 24 | const days = Math.floor(remaining / 60 / 60 / 24); 25 | let str = ""; 26 | 27 | if (days > 1) str += `${days} days `; 28 | else if (days > 0) str += "1 day "; 29 | 30 | if (hours > 1) str += `${hours} hours `; 31 | else if (hours === 1) str += "1 hour "; 32 | else if (days > 0) str += "0 hours "; 33 | 34 | if (minutes > 1) str += `${minutes} minutes `; 35 | else if (minutes === 1) str += "1 minute "; 36 | else if (hours > 0) str += "0 minutes "; 37 | 38 | if (seconds > 1) str += `${seconds} seconds `; 39 | else if (seconds === 1) str += "1 second "; 40 | else if (minutes > 0) str += "0 seconds "; 41 | 42 | timeSpan.innerText = str.trim(); 43 | }; 44 | 45 | updateTimeRemaining(); 46 | setInterval(updateTimeRemaining, 1000); 47 | } 48 | 49 | // Show local time of basho start times 50 | const DATETIME_FORMAT = new Intl.DateTimeFormat(undefined, { 51 | year: "numeric", 52 | month: "long", 53 | day: "numeric", 54 | hour: "numeric", 55 | minute: "numeric", 56 | timeZoneName: "short", 57 | }); 58 | for (const el of document.querySelectorAll(".js-local-datetime")) { 59 | if (!(el instanceof HTMLElement) || el.dataset.timestamp === undefined) { 60 | throw new Error( 61 | `unexpected element with css class .js-basho-count-down: ${el.tagName}`, 62 | ); 63 | } 64 | const timestamp = parseInt(el.dataset.timestamp); 65 | if (!isNaN(timestamp)) { 66 | const date = new Date(timestamp); 67 | el.innerText = DATETIME_FORMAT.format(date); 68 | } 69 | } 70 | } 71 | 72 | // User menu 73 | function initUserMenu(): void { 74 | const playerMenu = document.querySelector( 75 | "#g-header .player-menu", 76 | ) as HTMLElement; 77 | const menuHeader = playerMenu.querySelector(".g-player-listing"); 78 | if (menuHeader instanceof HTMLAnchorElement) { 79 | const bodyClickHandler = (event: Event): void => { 80 | const target = event.target; 81 | if (target instanceof Element && !target.matches(".player-menu *")) { 82 | playerMenu.classList.remove("open"); 83 | window.removeEventListener("click", bodyClickHandler, { 84 | capture: true, 85 | }); 86 | } 87 | }; 88 | menuHeader.addEventListener("click", (event) => { 89 | event.preventDefault(); 90 | if (playerMenu.classList.toggle("open")) { 91 | window.addEventListener("click", bodyClickHandler, { capture: true }); 92 | } else { 93 | window.removeEventListener("click", bodyClickHandler, { 94 | capture: true, 95 | }); 96 | } 97 | }); 98 | } 99 | } 100 | 101 | async function initPushPromo(): Promise { 102 | const promo = document.getElementById("push-promo"); 103 | 104 | if ( 105 | promo === null || 106 | localStorage.getItem("push-promo-dismissed") === "1" || 107 | (await pushPermissionState()) !== "prompt" 108 | ) { 109 | return; 110 | } 111 | 112 | promo.style.display = "block"; 113 | 114 | const dismiss = promo.querySelector("button") as HTMLButtonElement; 115 | dismiss.addEventListener("click", (event) => { 116 | event.preventDefault(); 117 | promo.style.display = ""; 118 | localStorage.setItem("push-promo-dismissed", "1"); 119 | }); 120 | } 121 | -------------------------------------------------------------------------------- /templates/base.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Kachi Clash · {% block subtitle %}main{% endblock %} 5 | 6 | 7 | 8 | 9 | 10 | 15 | 16 | 17 | 18 | 19 | 20 | 26 | 27 | {# Show standard placeholder for broken player avatar images #} 28 | 42 | 43 | {% block head %}{% endblock %} 44 | 45 | 46 |
47 |
48 | {% match base.player %} 49 | {%- when Some with (player) -%} 50 | {{ player.render().unwrap()|safe }} 51 | 52 |
  • Profile
  • 53 |
  • Settings
  • 54 |
  • Log Out
  • 55 | 56 |

  • 57 | 58 |
  • Heya Directory
  • 59 | {% if let Some(heyas) = player.heyas %} 60 | {% for heya in heyas %} 61 |
  • {{ heya.name }}
  • 62 | {% endfor %} 63 | {% endif %} 64 | 65 |

  • 66 | 67 |
  • Stats
  • 68 |
  • 69 | Discord 72 |
  • 73 |
    74 | 75 | {%- when None -%} 76 | Discord 79 | | 80 | Sign In 81 | {%- endmatch %} 82 |
    83 | 84 | 93 | 94 | {% if let Some(player) = base.player -%} 95 |
    96 |

    You can now subscribe to push notifications in your settings.

    97 | 98 |
    99 | {%- endif -%} 100 |
    101 | 102 |
    103 | {% block main %}{% endblock %} 104 |
    105 | 106 | 129 | 130 | 131 | -------------------------------------------------------------------------------- /public/ts/settings.ts: -------------------------------------------------------------------------------- 1 | import { sendTestNotification } from "./push.js"; 2 | import { 3 | pushPermissionState, 4 | pushSubscriptionState, 5 | subscribeToPushNotifications, 6 | type SubscriptionState, 7 | } from "./service-client.js"; 8 | 9 | interface FormControls extends HTMLCollectionBase { 10 | // [item: string]: HTMLElement | RadioNodeList 11 | name: HTMLInputElement; 12 | notifications: HTMLFieldSetElement; 13 | "test-notification": HTMLButtonElement; 14 | } 15 | 16 | const form = document.getElementById("settings-form") as HTMLFormElement; 17 | const messages = form.querySelector(".messages") as HTMLElement; 18 | const saveButton = form.querySelector(".save-button") as HTMLButtonElement; 19 | const nameField = (form.elements as unknown as FormControls).name; 20 | const testNotificationButton = (form.elements as unknown as FormControls)[ 21 | "test-notification" 22 | ]; 23 | const notifications = (form.elements as unknown as FormControls).notifications; 24 | const typeCheckboxes: NodeListOf = 25 | notifications.querySelectorAll('input[type="checkbox"]'); 26 | 27 | let subscriptionState: SubscriptionState | null = null; 28 | let edited = false; 29 | 30 | notifications.classList.toggle("ios", /iPhone|iPad/.test(navigator.userAgent)); 31 | 32 | form.addEventListener("submit", (event) => { 33 | event.preventDefault(); 34 | void save(); 35 | }); 36 | form.addEventListener("input", () => { 37 | showMessage(false, ""); 38 | edited = true; 39 | refreshBusyState(false); 40 | }); 41 | 42 | testNotificationButton.addEventListener("click", (event) => { 43 | event.preventDefault(); 44 | void sendTestNotification(); 45 | }); 46 | 47 | async function refreshState(): Promise { 48 | try { 49 | refreshBusyState(true); 50 | const permission = await pushPermissionState(); 51 | notifications.dataset.permissionState = permission; 52 | subscriptionState = 53 | permission === "granted" ? await pushSubscriptionState() : null; 54 | for (const checkbox of typeCheckboxes) { 55 | checkbox.checked = 56 | subscriptionState?.opt_in.includes(checkbox.value) ?? false; 57 | } 58 | } catch (error) { 59 | console.error(error); 60 | showMessage(true, "Failed to refresh state"); 61 | } finally { 62 | refreshBusyState(false); 63 | } 64 | } 65 | 66 | function refreshBusyState(busy: boolean): void { 67 | form.classList.toggle("busy", busy); 68 | saveButton.disabled = busy || !edited; 69 | notifications.disabled = busy; 70 | nameField.disabled = busy; 71 | testNotificationButton.disabled = busy || subscriptionState == null; 72 | } 73 | 74 | async function save(): Promise { 75 | refreshBusyState(true); 76 | showMessage(false, ""); 77 | try { 78 | let pushSubscription: PushSubscription | null = null; 79 | const optIn = getOptInTypes(); 80 | if (optIn.length > 0) { 81 | pushSubscription = await subscribeToPushNotifications(); 82 | } 83 | 84 | const body = { 85 | name: nameField.value, 86 | push_subscription: pushSubscription?.toJSON(), 87 | notification_opt_in: optIn, 88 | }; 89 | const resp = await fetch("/settings", { 90 | method: "POST", 91 | body: JSON.stringify(body), 92 | headers: { 93 | "Content-Type": "application/json", 94 | }, 95 | credentials: "same-origin", 96 | }); 97 | if (resp.ok) { 98 | showMessage(false, "Settings have been saved."); 99 | } else { 100 | const body = await resp.text(); 101 | throw new Error(body); 102 | } 103 | } catch (error: unknown) { 104 | showMessage(true, (error as object).toString()); 105 | } finally { 106 | edited = false; 107 | await refreshState(); 108 | refreshBusyState(false); 109 | } 110 | } 111 | 112 | function showMessage(isError: boolean, message: string): void { 113 | messages.style.display = message !== "" ? "block" : "none"; 114 | messages.classList.toggle("error", isError); 115 | messages.innerText = message; 116 | messages.scrollIntoView(); 117 | } 118 | 119 | function getOptInTypes(): string[] { 120 | const types: string[] = []; 121 | for (const checkbox of typeCheckboxes) { 122 | if (checkbox.checked) { 123 | types.push(checkbox.value); 124 | } 125 | } 126 | return types; 127 | } 128 | 129 | void refreshState(); 130 | -------------------------------------------------------------------------------- /src/external/reddit.rs: -------------------------------------------------------------------------------- 1 | use async_trait::async_trait; 2 | use chrono::{DateTime, Utc}; 3 | use oauth2::basic::BasicClient; 4 | use oauth2::{AuthUrl, ClientId, ClientSecret, RedirectUrl, TokenUrl}; 5 | use rusqlite::{Error, Transaction}; 6 | 7 | use super::AuthProvider; 8 | use crate::data::PlayerId; 9 | use crate::external::{OAuthClient, UserInfo}; 10 | use crate::Config; 11 | 12 | #[derive(Debug)] 13 | pub struct RedditAuthProvider; 14 | 15 | #[async_trait] 16 | impl AuthProvider for RedditAuthProvider { 17 | fn service_name(&self) -> &'static str { 18 | "Reddit" 19 | } 20 | 21 | fn logged_in_user_info_url(&self) -> &'static str { 22 | "https://oauth.reddit.com/api/v1/me" 23 | } 24 | 25 | fn oauth_scopes(&self) -> &'static [&'static str] { 26 | &["identity"] 27 | } 28 | 29 | fn make_oauth_client(&self, config: &Config) -> OAuthClient { 30 | let mut redirect_url = config.url(); 31 | redirect_url.set_path("login/reddit_redirect"); 32 | 33 | BasicClient::new(ClientId::new(config.reddit_client_id.to_owned())) 34 | .set_client_secret(ClientSecret::new(config.reddit_client_secret.to_owned())) 35 | .set_auth_uri( 36 | AuthUrl::new( 37 | "https://www.reddit.com/api/v1/authorize?duration=temporary".to_string(), 38 | ) 39 | .unwrap(), 40 | ) 41 | .set_token_uri( 42 | TokenUrl::new("https://www.reddit.com/api/v1/access_token".to_string()).unwrap(), 43 | ) 44 | .set_redirect_uri(RedirectUrl::from_url(redirect_url)) 45 | } 46 | 47 | fn make_user_info_url(&self, user_id: &str) -> String { 48 | format!("https://oauth.reddit.com/api/v1/user/{}/about", user_id) 49 | } 50 | 51 | async fn parse_user_info_response( 52 | &self, 53 | res: reqwest::Response, 54 | ) -> anyhow::Result> { 55 | Ok(Box::new(res.json::().await?)) 56 | } 57 | } 58 | 59 | #[derive(Debug, Deserialize, Clone)] 60 | pub struct RedditUserInfo { 61 | pub id: String, 62 | pub name: String, 63 | pub icon_img: Option, // url 64 | } 65 | 66 | impl UserInfo for RedditUserInfo { 67 | fn update_existing_player( 68 | &self, 69 | txn: &Transaction, 70 | mod_date: DateTime, 71 | ) -> Result, Error> { 72 | //debug!("reddit user info: {:?}", self); 73 | 74 | match txn 75 | .prepare("SELECT player_id, name, icon_img FROM player_reddit WHERE id = ?")? 76 | .query_map( 77 | params![self.id], 78 | |row| -> Result<(PlayerId, String, Option), _> { 79 | Ok(( 80 | row.get("player_id")?, 81 | row.get("name")?, 82 | row.get("icon_img")?, 83 | )) 84 | }, 85 | )? 86 | .next() 87 | { 88 | None => Ok(None), 89 | Some(Ok((player_id, name, icon_img))) => { 90 | if name != self.name || icon_img != self.icon_img { 91 | txn.execute( 92 | " 93 | UPDATE player_reddit 94 | SET name = ?, icon_img = ?, mod_date = ? 95 | WHERE id = ? 96 | ", 97 | params![self.name, self.icon_img, mod_date, self.id], 98 | )?; 99 | } 100 | Ok(Some(player_id)) 101 | } 102 | Some(Err(e)) => Err(e), 103 | } 104 | } 105 | 106 | fn insert_into_db( 107 | &self, 108 | txn: &Transaction, 109 | mod_date: DateTime, 110 | player_id: PlayerId, 111 | ) -> Result { 112 | txn.execute( 113 | " 114 | INSERT INTO player_reddit (player_id, id, name, icon_img, mod_date) 115 | VALUES (?, ?, ?, ?, ?)", 116 | params![player_id, self.id, self.name, self.icon_img, mod_date], 117 | ) 118 | } 119 | 120 | fn name_suggestion(&self) -> Option { 121 | Some(self.name.to_owned()) 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /templates/index.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block main_id %}p-index{% endblock %} 4 | 5 | {% block head %} 6 | 7 | {% endblock %} 8 | 9 | {% block main %} 10 |

    11 | 12 | 17 | 22 | Kachi Clash — a Grand Sumo prediction game 28 | 29 |

    30 | 31 | {% match current_basho %} 32 | {% when Some with (basho) %} 33 |
    34 | 35 | Enter current tournament 42 | 43 | {% if !basho.has_started() %} 44 |

    45 | Time remaining to enter/change your picks: 46 | 50 |

    51 | {% endif %} 52 |
    53 | {% when None %} 54 | {% if base.is_admin() %} 55 |

    56 | Admin: 57 | enter banzuke for {{ next_basho_id }} 60 |

    61 | {% endif %} 62 | {% endmatch %} 63 | 64 | {% match prev_basho %} 65 | {% when Some with (basho) %} 66 |
    67 |

    68 | 69 | 70 | 74 | Congratulations to the previous basho champion 80 | 81 | 82 |

    83 | 84 | {% for player in basho.winners -%} 85 | {{ player.render().unwrap()|safe }} 86 | {% endfor %} 87 | 88 |
    89 | {% when None %} 90 | {% endmatch %} 91 | 92 |
    93 |
    94 | {% if base.player.is_some() %} 95 |

    96 | You are currently ranked 97 | {% match self.self_leader() %} 98 | {% when Some with (leader) %} 99 | 100 | {{ "{:#}"|format(leader.rank) }} 101 | 102 | {% when None %} below Juryo 103 | {% endmatch %} 104 |

    105 | {% endif %} 106 | 107 |

    108 | Rankings are based on total wins over the past 6 basho (one year). 109 | All player stats 110 |

    111 | 112 |
    113 | {% for (rank, ord, wins, players) in self.leaders_by_rank() %} 114 |
    118 |

    119 | {{ "{:#}"|format(rank) }} 120 |
    #{{ ord }} ({{ wins }} wins)
    121 |

    122 |
      123 | {% for player in players %} 124 |
    1. 125 | {{ player.player.render().unwrap()|safe }} 126 |
    2. 127 | {% endfor %} 128 |
    129 |
    130 | {% endfor %} 131 |
    132 |
    133 |
    134 | {% endblock %} 135 | -------------------------------------------------------------------------------- /src/data/mod.rs: -------------------------------------------------------------------------------- 1 | use heya::HeyaId; 2 | use rusqlite::config::DbConfig::SQLITE_DBCONFIG_ENABLE_FKEY; 3 | use rusqlite::{Connection, OpenFlags}; 4 | use std::path::Path; 5 | use std::sync::{Arc, Mutex}; 6 | 7 | #[cfg(debug_assertions)] 8 | use rusqlite::trace::{TraceEvent, TraceEventCodes}; 9 | 10 | mod rank; 11 | pub use rank::{Rank, RankDivision, RankGroup, RankName, RankSide}; 12 | 13 | pub mod player; 14 | pub use player::{Player, PlayerId}; 15 | 16 | pub mod basho; 17 | pub use basho::{BashoInfo, BashoRikishi, BashoRikishiByRank, FetchBashoRikishi}; 18 | 19 | pub mod basho_id; 20 | pub use basho_id::BashoId; 21 | 22 | pub mod award; 23 | pub use award::Award; 24 | use std::error::Error; 25 | use std::fmt; 26 | 27 | pub mod leaders; 28 | 29 | pub mod push; 30 | 31 | pub mod heya; 32 | pub use heya::Heya; 33 | 34 | pub type RikishiId = u32; 35 | pub type Day = u8; 36 | 37 | pub type DbConn = Arc>; 38 | 39 | pub fn make_conn(path: &Path) -> DbConn { 40 | #[allow(unused_mut)] 41 | let mut conn = Connection::open_with_flags( 42 | path, 43 | OpenFlags::SQLITE_OPEN_READ_WRITE | OpenFlags::SQLITE_OPEN_NO_MUTEX, 44 | ) 45 | .expect("sqlite db"); 46 | conn.set_db_config(SQLITE_DBCONFIG_ENABLE_FKEY, true) 47 | .expect("set foreign key enformance to on"); 48 | 49 | #[cfg(debug_assertions)] 50 | conn.trace_v2(TraceEventCodes::SQLITE_TRACE_PROFILE, Some(db_trace)); 51 | 52 | Arc::new(Mutex::new(conn)) 53 | } 54 | 55 | #[cfg(debug_assertions)] 56 | fn db_trace(event: TraceEvent) { 57 | use regex::Regex; 58 | use std::sync::LazyLock; 59 | static RE: LazyLock = LazyLock::new(|| Regex::new(r"\s+").unwrap()); 60 | 61 | if let TraceEvent::Profile(stmt, duration) = event { 62 | trace!( 63 | "sqlite: {} ({:.3}s)", 64 | RE.replace_all(&stmt.sql(), " "), 65 | duration.as_secs_f32() 66 | ); 67 | } 68 | } 69 | 70 | type Result = std::result::Result; 71 | 72 | #[derive(Debug)] 73 | pub enum DataError { 74 | BashoHasStarted, 75 | InvalidPicks, 76 | HeyaIntegrity { 77 | what: String, 78 | }, 79 | RikishiNotFound { 80 | family_name: String, 81 | }, 82 | AmbiguousShikona { 83 | family_names: Vec, 84 | }, 85 | HeyaNotFound { 86 | slug: Option, 87 | id: Option, 88 | }, 89 | DatabaseError(rusqlite::Error), 90 | WebPushError(web_push::WebPushError), 91 | JsonError(serde_json::Error), 92 | #[allow(dead_code)] 93 | UnknownLoginProvider, 94 | } 95 | 96 | impl From for DataError { 97 | fn from(e: rusqlite::Error) -> Self { 98 | DataError::DatabaseError(e) 99 | } 100 | } 101 | 102 | impl From for DataError { 103 | fn from(e: web_push::WebPushError) -> Self { 104 | DataError::WebPushError(e) 105 | } 106 | } 107 | 108 | impl From for DataError { 109 | fn from(e: serde_json::Error) -> Self { 110 | DataError::JsonError(e) 111 | } 112 | } 113 | 114 | impl Error for DataError {} 115 | 116 | impl fmt::Display for DataError { 117 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 118 | match self { 119 | DataError::BashoHasStarted => write!(f, "Basho has already started"), 120 | DataError::InvalidPicks => write!(f, "Invalid picks"), 121 | DataError::HeyaIntegrity { what } => write!(f, "Heya integrity error: {}", what), 122 | DataError::RikishiNotFound { family_name } => { 123 | write!(f, "Rikishi not found: {}", family_name) 124 | } 125 | DataError::AmbiguousShikona { family_names } => { 126 | write!(f, "Multiple rikishi with shikona: {:?}", family_names) 127 | } 128 | DataError::HeyaNotFound { slug, id } => { 129 | write!(f, "Heya not found for slug {slug:?} or id {id:?}") 130 | } 131 | DataError::DatabaseError(e) => write!(f, "Database error: {}", e), 132 | DataError::UnknownLoginProvider => write!(f, "Unknown login provider"), 133 | DataError::WebPushError(e) => write!(f, "Web Push error: {}", e), 134 | DataError::JsonError(e) => write!(f, "JSON error: {}", e), 135 | }?; 136 | Ok(()) 137 | } 138 | } 139 | -------------------------------------------------------------------------------- /src/handlers/heya.rs: -------------------------------------------------------------------------------- 1 | use actix_identity::Identity; 2 | use actix_web::{get, http, post, web, HttpResponse, Responder}; 3 | use askama::Template; 4 | use askama_web::WebTemplate; 5 | use rusqlite::Connection; 6 | 7 | use crate::data::heya::{HOST_MAX, JOIN_MAX}; 8 | use crate::data::{Heya, PlayerId}; 9 | use crate::handlers::{HandlerError, IdentityExt}; 10 | use crate::AppState; 11 | 12 | use super::{BaseTemplate, Result}; 13 | 14 | #[derive(Template, WebTemplate)] 15 | #[template(path = "heya.html")] 16 | pub struct HeyaTemplate { 17 | base: BaseTemplate, 18 | heya: Heya, 19 | is_oyakata: bool, 20 | } 21 | 22 | #[get("")] 23 | pub async fn page( 24 | state: web::Data, 25 | identity: Option, 26 | path: web::Path, 27 | ) -> Result { 28 | let db = state.db.lock().unwrap(); 29 | let base = BaseTemplate::new(&db, identity.as_ref(), &state)?; 30 | let player_id = identity.and_then(|i| i.player_id().ok()); 31 | let mut heya = Heya::with_slug(&db, &path, true)?; 32 | for m in heya.members.as_mut().unwrap() { 33 | m.is_self = player_id.is_some_and(|id| id == m.player.id); 34 | } 35 | Ok(HeyaTemplate { 36 | is_oyakata: player_id == Some(heya.oyakata.id), 37 | base, 38 | heya, 39 | }) 40 | } 41 | 42 | #[derive(Debug, Deserialize)] 43 | pub struct EditData { 44 | set_name: Option, 45 | add_player_id: Option, 46 | delete_player_id: Option, 47 | } 48 | 49 | #[post("")] 50 | pub async fn edit( 51 | path: web::Path, 52 | data: web::Form, 53 | state: web::Data, 54 | identity: Identity, 55 | ) -> Result { 56 | let mut db = state.db.lock().unwrap(); 57 | let mut heya = Heya::with_slug(&db, &path, false)?; 58 | apply_edit_actions(&mut heya, &mut db, data.0, identity.player_id()?)?; 59 | 60 | let updated_heya = Heya::with_id(&db, heya.id, false)?; 61 | Ok(HttpResponse::SeeOther() 62 | .insert_header((http::header::LOCATION, updated_heya.url_path())) 63 | .finish()) 64 | } 65 | 66 | fn apply_edit_actions( 67 | heya: &mut Heya, 68 | db: &mut Connection, 69 | data: EditData, 70 | user: PlayerId, 71 | ) -> Result<()> { 72 | if let Some(name) = data.set_name { 73 | heya.set_name(db, &name)?; 74 | } 75 | if let Some(player_id) = data.add_player_id { 76 | if heya.oyakata.id == user { 77 | heya.add_member(db, player_id)?; 78 | } else { 79 | return Err(HandlerError::MustBeLoggedIn); 80 | } 81 | } 82 | if let Some(player_id) = data.delete_player_id { 83 | // Member can choose to leave; oyakata can kick others out: 84 | if heya.oyakata.id == user || player_id == user { 85 | heya.delete_member(db, player_id)?; 86 | } else { 87 | return Err(HandlerError::MustBeLoggedIn); 88 | } 89 | } 90 | 91 | Ok(()) 92 | } 93 | 94 | #[derive(Template, WebTemplate)] 95 | #[template(path = "heya_list.html")] 96 | pub struct HeyaListTemplate { 97 | base: BaseTemplate, 98 | heyas: Vec, 99 | hosted: usize, 100 | } 101 | 102 | #[get("/heya")] 103 | pub async fn list( 104 | state: web::Data, 105 | identity: Option, 106 | ) -> Result { 107 | let db = state.db.lock().unwrap(); 108 | let heyas = Heya::list_all(&db)?; 109 | let player_id = identity.as_ref().and_then(|i| i.player_id().ok()); 110 | Ok(HeyaListTemplate { 111 | base: BaseTemplate::new(&db, identity.as_ref(), &state)?, 112 | hosted: heyas 113 | .iter() 114 | .filter(|h| h.oyakata.id == player_id.unwrap_or(-1)) 115 | .count(), 116 | heyas, 117 | }) 118 | } 119 | 120 | #[derive(Debug, Deserialize)] 121 | pub struct CreateHeyaData { 122 | name: String, 123 | } 124 | 125 | #[post("/heya")] 126 | pub async fn create( 127 | data: web::Form, 128 | state: web::Data, 129 | identity: Identity, 130 | ) -> Result { 131 | let mut db = state.db.lock().unwrap(); 132 | let player_id = identity.player_id()?; 133 | let heya = Heya::new(&mut db, &data.name, player_id)?; 134 | Ok(HttpResponse::SeeOther() 135 | .insert_header((http::header::LOCATION, heya.url_path())) 136 | .finish()) 137 | } 138 | -------------------------------------------------------------------------------- /scripts/Sumo Banzuke Process M.sublime-macro: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "args": 4 | { 5 | "to": "bol" 6 | }, 7 | "command": "move_to" 8 | }, 9 | { 10 | "args": 11 | { 12 | "by": "lines", 13 | "forward": true 14 | }, 15 | "command": "move" 16 | }, 17 | { 18 | "args": 19 | { 20 | "file": "res://Packages/Default/Delete to Hard EOL.sublime-macro" 21 | }, 22 | "command": "run_macro_file" 23 | }, 24 | { 25 | "args": 26 | { 27 | "file": "res://Packages/Default/Delete to Hard EOL.sublime-macro" 28 | }, 29 | "command": "run_macro_file" 30 | }, 31 | { 32 | "args": 33 | { 34 | "file": "res://Packages/Default/Delete to Hard EOL.sublime-macro" 35 | }, 36 | "command": "run_macro_file" 37 | }, 38 | { 39 | "args": 40 | { 41 | "file": "res://Packages/Default/Delete to Hard EOL.sublime-macro" 42 | }, 43 | "command": "run_macro_file" 44 | }, 45 | { 46 | "args": 47 | { 48 | "file": "res://Packages/Default/Delete to Hard EOL.sublime-macro" 49 | }, 50 | "command": "run_macro_file" 51 | }, 52 | { 53 | "args": 54 | { 55 | "file": "res://Packages/Default/Delete to Hard EOL.sublime-macro" 56 | }, 57 | "command": "run_macro_file" 58 | }, 59 | { 60 | "args": 61 | { 62 | "by": "characters", 63 | "forward": true 64 | }, 65 | "command": "move" 66 | }, 67 | { 68 | "args": 69 | { 70 | "extend": true, 71 | "to": "eol" 72 | }, 73 | "command": "move_to" 74 | }, 75 | { 76 | "args": null, 77 | "command": "copy" 78 | }, 79 | { 80 | "args": 81 | { 82 | "by": "lines", 83 | "forward": false 84 | }, 85 | "command": "move" 86 | }, 87 | { 88 | "args": 89 | { 90 | "to": "bol" 91 | }, 92 | "command": "move_to" 93 | }, 94 | { 95 | "args": 96 | { 97 | "characters": "M" 98 | }, 99 | "command": "insert" 100 | }, 101 | { 102 | "args": null, 103 | "command": "paste" 104 | }, 105 | { 106 | "args": 107 | { 108 | "characters": "e" 109 | }, 110 | "command": "insert" 111 | }, 112 | { 113 | "args": 114 | { 115 | "characters": " " 116 | }, 117 | "command": "insert" 118 | }, 119 | { 120 | "args": 121 | { 122 | "by": "lines", 123 | "forward": true 124 | }, 125 | "command": "move" 126 | }, 127 | { 128 | "args": 129 | { 130 | "to": "bol" 131 | }, 132 | "command": "move_to" 133 | }, 134 | { 135 | "args": 136 | { 137 | "file": "res://Packages/Default/Delete to Hard EOL.sublime-macro" 138 | }, 139 | "command": "run_macro_file" 140 | }, 141 | { 142 | "args": 143 | { 144 | "file": "res://Packages/Default/Delete to Hard EOL.sublime-macro" 145 | }, 146 | "command": "run_macro_file" 147 | }, 148 | { 149 | "args": 150 | { 151 | "file": "res://Packages/Default/Delete to Hard EOL.sublime-macro" 152 | }, 153 | "command": "run_macro_file" 154 | }, 155 | { 156 | "args": 157 | { 158 | "characters": "M" 159 | }, 160 | "command": "insert" 161 | }, 162 | { 163 | "args": null, 164 | "command": "paste" 165 | }, 166 | { 167 | "args": 168 | { 169 | "characters": "w" 170 | }, 171 | "command": "insert" 172 | }, 173 | { 174 | "args": 175 | { 176 | "characters": " " 177 | }, 178 | "command": "insert" 179 | }, 180 | { 181 | "args": 182 | { 183 | "by": "lines", 184 | "forward": true 185 | }, 186 | "command": "move" 187 | }, 188 | { 189 | "args": 190 | { 191 | "to": "bol" 192 | }, 193 | "command": "move_to" 194 | }, 195 | { 196 | "args": 197 | { 198 | "file": "res://Packages/Default/Delete to Hard EOL.sublime-macro" 199 | }, 200 | "command": "run_macro_file" 201 | }, 202 | { 203 | "args": 204 | { 205 | "file": "res://Packages/Default/Delete to Hard EOL.sublime-macro" 206 | }, 207 | "command": "run_macro_file" 208 | }, 209 | { 210 | "args": 211 | { 212 | "file": "res://Packages/Default/Delete to Hard EOL.sublime-macro" 213 | }, 214 | "command": "run_macro_file" 215 | }, 216 | { 217 | "args": 218 | { 219 | "file": "res://Packages/Default/Delete to Hard EOL.sublime-macro" 220 | }, 221 | "command": "run_macro_file" 222 | }, 223 | { 224 | "args": 225 | { 226 | "file": "res://Packages/Default/Delete to Hard EOL.sublime-macro" 227 | }, 228 | "command": "run_macro_file" 229 | }, 230 | { 231 | "args": 232 | { 233 | "file": "res://Packages/Default/Delete to Hard EOL.sublime-macro" 234 | }, 235 | "command": "run_macro_file" 236 | } 237 | ] 238 | -------------------------------------------------------------------------------- /public/ts/service-client.ts: -------------------------------------------------------------------------------- 1 | const appKey = ( 2 | document.head.querySelector( 3 | 'meta[name="vapid-public-key"]', 4 | ) as HTMLMetaElement 5 | ).content; 6 | 7 | // Support for declarative web push notifications: 8 | // https://webkit.org/blog/16535/meet-declarative-web-push/#how-to-use-declarative-web-push 9 | declare global { 10 | interface Window { 11 | pushManager: PushManager | undefined; 12 | } 13 | } 14 | 15 | const pushManager = initPushManager(); 16 | 17 | async function initPushManager(): Promise { 18 | if ("pushManager" in window) { 19 | await unregisterServiceWorker(); 20 | return window.pushManager; 21 | } else if ("serviceWorker" in navigator) { 22 | const serviceWorker = await navigator.serviceWorker?.register( 23 | "/static/js/service-worker.js", 24 | { 25 | scope: "/", 26 | // this is not supported in FireFox yet, as of v111 27 | // type: 'module' 28 | }, 29 | ); 30 | return serviceWorker.pushManager; 31 | } else { 32 | return undefined; 33 | } 34 | } 35 | 36 | async function unregisterServiceWorker() { 37 | const serviceWorker = await navigator.serviceWorker?.getRegistration(); 38 | if (serviceWorker === undefined) { 39 | console.debug("No existing service worker"); 40 | return; 41 | } 42 | if (await serviceWorker.unregister()) { 43 | console.info("Unregistered existing service worker", serviceWorker); 44 | } else { 45 | console.warn("Failed to unregister existing service worker", serviceWorker); 46 | } 47 | } 48 | 49 | export async function subscribeToPushNotifications(): Promise { 50 | const pm = await pushManager; 51 | if (pm === undefined || !("Notification" in window)) { 52 | throw new Error("Push notifications are not supported in this browser."); 53 | } 54 | 55 | const permission = await Notification.requestPermission(); 56 | if (permission === "denied") { 57 | throw new Error( 58 | "Please check browser settings to allow notifications from this site.", 59 | ); 60 | } 61 | 62 | try { 63 | return await pm.subscribe({ 64 | userVisibleOnly: true, 65 | applicationServerKey: appKey, 66 | }); 67 | } catch (e: unknown) { 68 | throw new Error( 69 | `Could not enable push notifications. Please check your browser settings.\n\n${(e as object).toString()}`, 70 | ); 71 | } 72 | } 73 | 74 | export type PushPermissionState = PermissionState | "unavailable"; 75 | 76 | export async function pushPermissionState(): Promise { 77 | const pm = await pushManager; 78 | if (pm === undefined || !("Notification" in window)) { 79 | return "unavailable"; 80 | } else { 81 | // It seems that in Safari, these three methods of getting the permission state are sometimes divergent, so we'll take all three and return 'granted' if any of them say so; otherwise use navigator.permissions as the source of truth. https://developer.apple.com/forums/thread/731412 82 | const pushPerm = await pm.permissionState({ 83 | userVisibleOnly: true, 84 | applicationServerKey: appKey, 85 | }); 86 | const notifPerm = Notification.permission; 87 | const queryPerm = ( 88 | await navigator.permissions.query({ name: "notifications" }) 89 | ).state; 90 | if ( 91 | pushPerm === "granted" || 92 | queryPerm === "granted" || 93 | notifPerm === "granted" 94 | ) { 95 | return "granted"; 96 | } else { 97 | return queryPerm; 98 | } 99 | } 100 | } 101 | 102 | export interface SubscriptionState { 103 | opt_in: string[]; 104 | } 105 | 106 | export async function pushSubscriptionState(): Promise { 107 | const pm = await pushManager; 108 | const subscription = await pm?.getSubscription(); 109 | if (subscription === null || subscription === undefined) { 110 | return null; 111 | } 112 | 113 | const resp = await fetch("/push/check", { 114 | method: "POST", 115 | body: JSON.stringify(subscription.toJSON()), 116 | headers: { 117 | "Content-Type": "application/json", 118 | }, 119 | credentials: "same-origin", 120 | }); 121 | 122 | if (resp.ok) { 123 | return (await resp.json()) as SubscriptionState; 124 | } else if (resp.status === 404) { 125 | alert("Push notification registration has been lost. Please re-subscribe."); 126 | await subscription.unsubscribe(); 127 | return null; 128 | } else { 129 | const body = await resp.text(); 130 | throw new Error(body); 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /templates/player.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block main_id %}p-player{% endblock %} 4 | 5 | {% block subtitle %}{{ player.name }}{% endblock %} 6 | 7 | {% block head %} 8 | 9 | {% endblock %} 10 | 11 | {% block main %} 12 |
    13 | avatar 19 | 20 | {% if self.is_self() %} 21 |
    22 | ✏️ edit 23 |
    24 | {% endif %} 25 | 26 |
    27 | {{ player.name }} 28 | {% if player.has_emperors_cup() -%} 29 | {{ crate::data::award::Award::EmperorsCup.emoji() }} 30 | {%- endif %} 31 |
    32 |
      33 |
    • 34 | {%- match player.rank -%} 35 | {%- when Some(rank) -%} 36 | Rank: {{ rank|fmt("{:#}") }} 37 | {%- when None -%} 38 | Unranked 39 | {%- endmatch -%} 40 |
    • 41 |
    • 42 | Joined: {{ player.join_date.format("%Y-%m-%d") }} via 43 | {{ player.login_service_name() }} 44 |
    • 45 | 46 | {% set heyas = player.heyas.as_ref().unwrap() %} 47 | {% if heyas.len() > 0 || recruit_heyas.len() > 0 %} 48 |
    • 49 | Heya: 50 | {% for heya in heyas %} 51 | {{ heya.name }} 52 | ( 53 | {%- if player.id == heya.oyakata.id -%} 54 | established 55 | {%- else -%} 56 | recruited 57 | {%- endif %} 58 | {{ heya.recruit_date.unwrap().format("%Y-%m-%d") -}} 59 | ) 60 | {% endfor %} 61 | {% for heya in recruit_heyas %} 62 |
      63 | 68 | 69 |
      70 | {% endfor %} 71 |
    • 72 | {% endif %} 73 |
    74 |
    75 | 76 |
    77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | {% for basho in basho_scores -%} 90 | 93 | 98 | 99 | 107 | 108 | {% for rikishi in basho.rikishi %} 109 | 117 | {% endfor %} 118 | 119 | 126 | 133 | 136 | 137 | {% endfor %} 138 | 139 |
    BashoRankPicksScore
    94 | {{ basho.basho_id|fmt("{:#}") }} 97 | 100 | {%- match basho.rank -%} 101 | {%- when Some with (rank) -%} 102 | {{ rank }} 103 | {%- when None -%} 104 | Unranked 105 | {%- endmatch -%} 106 | 110 | {% match rikishi %} 111 | {% when Some with (r) %} 112 | {{ r.name }} 113 | ({{ r.wins }}-{{ r.losses }}) 114 | {% when None %} 115 | {% endmatch %} 116 | 120 | {%- match basho.wins -%} 121 | {%- when Some with (wins) -%} 122 | {{ wins }} 123 | {%- when None -%} 124 | {%- endmatch -%} 125 | 127 | {%- match basho.place -%} 128 | {%- when Some with (place) -%} 129 | #{{ place }} 130 | {%- when None -%} 131 | {%- endmatch -%} 132 | 134 | {% for award in basho.awards %}{{ award.emoji() }}{% endfor %} 135 |
    140 |
    141 | {% endblock %} 142 | -------------------------------------------------------------------------------- /public/scss/index.scss: -------------------------------------------------------------------------------- 1 | @use "media"; 2 | 3 | #p-index { 4 | section { 5 | margin: 0; 6 | } 7 | 8 | #next-basho { 9 | padding-top: 0; 10 | padding-bottom: 0; 11 | 12 | .button-link { 13 | width: 100%; 14 | height: auto; 15 | transition: transform ease-out 100ms; 16 | 17 | &:hover { 18 | transform: scale(1.04); 19 | transition: transform ease-out 200ms; 20 | } 21 | } 22 | } 23 | 24 | #prev-winners { 25 | padding-bottom: 0; 26 | 27 | h2 { 28 | position: absolute; 29 | z-index: 1; 30 | margin: 0; 31 | 32 | img { 33 | width: 100%; 34 | height: auto; 35 | } 36 | } 37 | 38 | .winners { 39 | position: relative; 40 | margin-left: min(210px, 23%); 41 | margin-top: min(160px, 15%); 42 | padding-right: min(100px, 10%); 43 | padding-bottom: 50px; 44 | font-size: 1.75rem; 45 | display: flex; 46 | flex-flow: row wrap; 47 | align-items: baseline; 48 | justify-content: space-evenly; 49 | z-index: 2; 50 | 51 | .g-player-listing { 52 | margin-right: 1ex; 53 | } 54 | 55 | @media (max-width: media.$medium-width) { 56 | font-size: 1.5rem; 57 | } 58 | 59 | @media (max-width: media.$narrow) { 60 | font-size: 1.25rem; 61 | } 62 | } 63 | } 64 | 65 | #leaderboard { 66 | padding-top: calc(36.36% + 2px); // percentage of bg image *width* 67 | padding-left: 10%; 68 | padding-right: 10%; 69 | padding-bottom: 0; 70 | margin-bottom: 0; 71 | // compute height based on aspect ratio of bg image 72 | height: calc(min(100vw, var(--content-max-width)) * 1.2273); 73 | 74 | // engraved wood appearance 75 | text-shadow: var(--emboss-text-shadow); 76 | 77 | background-image: url(/static/img2/player-ranking/light-bg.webp); 78 | background-size: contain; 79 | background-repeat: no-repeat; 80 | @media (prefers-color-scheme: dark) { 81 | background-image: url(/static/img2/player-ranking/dark-bg.webp); 82 | } 83 | 84 | &::before { 85 | content: " "; 86 | z-index: 1; 87 | pointer-events: none; 88 | position: absolute; 89 | width: 100%; 90 | left: 0; 91 | top: 29.63%; 92 | height: 4.545%; 93 | background-image: url(/static/img2/player-ranking/light-mask.webp); 94 | background-size: contain; 95 | background-repeat: no-repeat; 96 | @media (prefers-color-scheme: dark) { 97 | background-image: url(/static/img2/player-ranking/dark-mask.webp); 98 | } 99 | } 100 | 101 | .scroll-container { 102 | width: 100%; 103 | height: 100%; 104 | overflow-y: scroll; 105 | padding: 2em 10%; 106 | } 107 | 108 | .current-rank { 109 | margin-bottom: 0; 110 | 111 | @media (max-width: media.$narrow) { 112 | text-align: center; 113 | > a { 114 | display: block; 115 | } 116 | } 117 | } 118 | 119 | .g-player-listing .rank { 120 | display: none; 121 | } 122 | 123 | .grid { 124 | display: grid; 125 | grid-template-columns: [east] 50% [mid] 50% [west]; 126 | grid-auto-flow: row; 127 | border-bottom: 1px solid var(--color-border); 128 | padding-bottom: 1em; 129 | margin-bottom: 1em; 130 | column-gap: 1em; 131 | 132 | .group { 133 | &.side-e { 134 | grid-column: east / mid; 135 | @media (max-width: media.$narrow) { 136 | grid-column: east / west; 137 | } 138 | } 139 | &.side-w { 140 | grid-column: mid / west; 141 | @media (max-width: media.$narrow) { 142 | grid-column: east / west; 143 | } 144 | } 145 | 146 | &.rank-name-y { 147 | font-size: x-large; 148 | } 149 | &.rank-name-o, 150 | &.rank-name-s, 151 | &.rank-name-k { 152 | font-size: large; 153 | } 154 | &.rank-name-j { 155 | font-size: small; 156 | } 157 | &.rank-name-ms { 158 | font-size: x-small; 159 | } 160 | &.rank-name-sd, 161 | &.rank-name-jd, 162 | &.rank-name-jk { 163 | font-size: xx-small; 164 | } 165 | 166 | a { 167 | color: inherit; 168 | } 169 | 170 | h3 { 171 | text-align: left; 172 | margin: 1.5em 0 0.25em; 173 | 174 | > .sub { 175 | font-size: x-small; 176 | color: var(--color-fg-sub); 177 | } 178 | } 179 | 180 | .players { 181 | margin: 0; 182 | padding: 0; 183 | display: flex; 184 | flex-flow: row wrap; 185 | align-items: baseline; 186 | column-gap: 1em; 187 | 188 | > li { 189 | list-style: none; 190 | 191 | &.self { 192 | background: var(--color-table-highlight-bg); 193 | box-shadow: var(--table-highlight-shadow); 194 | } 195 | } 196 | } 197 | } 198 | } 199 | 200 | .all-stats { 201 | font-weight: bolder; 202 | color: var(--color-mauve); 203 | white-space: nowrap; 204 | 205 | &::after { 206 | content: " ➡"; 207 | } 208 | } 209 | } 210 | } 211 | -------------------------------------------------------------------------------- /src/external/discord.rs: -------------------------------------------------------------------------------- 1 | use crate::external::OAuthClient; 2 | use crate::Config; 3 | 4 | use std::fmt; 5 | use url::Url; 6 | 7 | use super::{AuthProvider, ImageSize, UserInfo}; 8 | use crate::data::PlayerId; 9 | use async_trait::async_trait; 10 | use chrono::{DateTime, Utc}; 11 | use oauth2::basic::BasicClient; 12 | use oauth2::{AuthUrl, ClientId, ClientSecret, RedirectUrl, TokenUrl}; 13 | use rusqlite::Transaction; 14 | 15 | const IMG_BASE: &str = "https://cdn.discordapp.com/"; 16 | 17 | #[derive(Debug)] 18 | pub struct DiscordAuthProvider; 19 | 20 | #[async_trait] 21 | impl AuthProvider for DiscordAuthProvider { 22 | fn service_name(&self) -> &'static str { 23 | "Discord" 24 | } 25 | 26 | fn logged_in_user_info_url(&self) -> &'static str { 27 | "https://discordapp.com/api/v6/users/@me" 28 | } 29 | 30 | fn oauth_scopes(&self) -> &'static [&'static str] { 31 | &["identify"] 32 | } 33 | 34 | fn make_oauth_client(&self, config: &Config) -> OAuthClient { 35 | let mut redirect_url = config.url(); 36 | redirect_url.set_path("login/discord_redirect"); 37 | 38 | BasicClient::new(ClientId::new(config.discord_client_id.to_owned())) 39 | .set_client_secret(ClientSecret::new(config.discord_client_secret.to_owned())) 40 | .set_auth_uri( 41 | AuthUrl::new("https://discordapp.com/api/oauth2/authorize".to_string()).unwrap(), 42 | ) 43 | .set_token_uri( 44 | TokenUrl::new("https://discordapp.com/api/oauth2/token".to_string()).unwrap(), 45 | ) 46 | .set_redirect_uri(RedirectUrl::from_url(redirect_url)) 47 | } 48 | 49 | fn make_user_info_url(&self, user_id: &str) -> String { 50 | format!("https://discordapp.com/api/v6/users/{}", user_id) 51 | } 52 | 53 | async fn parse_user_info_response( 54 | &self, 55 | res: reqwest::Response, 56 | ) -> anyhow::Result> { 57 | Ok(Box::new(res.json::().await?)) 58 | } 59 | } 60 | 61 | #[derive(Debug, Deserialize, Clone)] 62 | pub struct DiscordUserInfo { 63 | pub id: String, 64 | pub username: String, 65 | pub discriminator: String, // 4-digits 66 | pub avatar: Option, 67 | } 68 | 69 | impl UserInfo for DiscordUserInfo { 70 | fn update_existing_player( 71 | &self, 72 | txn: &Transaction, 73 | mod_date: DateTime, 74 | ) -> Result, rusqlite::Error> { 75 | match txn 76 | .prepare("SELECT player_id, username, discriminator, avatar FROM player_discord WHERE user_id = ?")? 77 | .query_map( 78 | params![self.id], 79 | |row| -> Result<(PlayerId, String, String, Option), _> { 80 | Ok((row.get("player_id")?, 81 | row.get("username")?, 82 | row.get("discriminator")?, 83 | row.get("avatar")?, 84 | )) 85 | } 86 | )? 87 | .next() { 88 | 89 | None => Ok(None), 90 | Some(Ok((player_id, username, discriminator, avatar))) => { 91 | if username != self.username || discriminator != self.discriminator || avatar != self.avatar { 92 | txn.execute(" 93 | UPDATE player_discord 94 | SET username = ?, discriminator = ?, avatar = ?, mod_date = ? 95 | WHERE user_id = ? 96 | ", 97 | params![self.username, self.discriminator, self.avatar, mod_date, self.id])?; 98 | } 99 | Ok(Some(player_id)) 100 | }, 101 | Some(Err(e)) => Err(e), 102 | } 103 | } 104 | 105 | fn insert_into_db( 106 | &self, 107 | txn: &Transaction, 108 | mod_date: DateTime, 109 | player_id: PlayerId, 110 | ) -> Result { 111 | txn.execute(" 112 | INSERT INTO player_discord (player_id, user_id, username, discriminator, avatar, mod_date) 113 | VALUES (?, ?, ?, ?, ?, ?)", 114 | params![player_id, self.id, self.username, self.discriminator, self.avatar, mod_date]) 115 | } 116 | 117 | fn name_suggestion(&self) -> Option { 118 | Some(self.username.to_owned()) 119 | } 120 | } 121 | 122 | pub enum ImageExt { 123 | Png, 124 | // JPEG, 125 | } 126 | 127 | impl fmt::Display for ImageExt { 128 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 129 | write!( 130 | f, 131 | "{}", 132 | match self { 133 | ImageExt::Png => "png", 134 | // ImageExt::JPEG => "jpg", 135 | } 136 | ) 137 | } 138 | } 139 | 140 | pub fn avatar_url( 141 | user_id: &str, 142 | avatar: &Option, 143 | discriminator: &str, 144 | ext: ImageExt, 145 | size: ImageSize, 146 | ) -> Url { 147 | let base = Url::parse(IMG_BASE).unwrap(); 148 | if let Some(hash) = &avatar { 149 | base.join(&format!("avatars/{}/{}.{}?size={}", user_id, hash, ext, size as i32)[..]) 150 | .unwrap() 151 | } else { 152 | let discrim = str::parse(discriminator).unwrap_or(0) % 5; 153 | base.join(&format!("embed/avatars/{}.png?size={}", discrim, size as i32)[..]) 154 | .unwrap() 155 | } 156 | } 157 | -------------------------------------------------------------------------------- /src/external/mod.rs: -------------------------------------------------------------------------------- 1 | use anyhow::{anyhow, Result}; 2 | use async_trait::async_trait; 3 | use chrono::{DateTime, Utc}; 4 | use oauth2::basic::{BasicClient, BasicTokenResponse}; 5 | use oauth2::{ 6 | AccessToken, AuthorizationCode, CsrfToken, EndpointNotSet, EndpointSet, RequestTokenError, 7 | Scope, 8 | }; 9 | use reqwest::redirect; 10 | use rusqlite::Transaction; 11 | use url::Url; 12 | 13 | use crate::data::PlayerId; 14 | use crate::Config; 15 | use std::fmt::Debug; 16 | 17 | pub mod discord; 18 | pub mod google; 19 | pub mod reddit; 20 | 21 | pub mod sumo_api; 22 | 23 | pub enum ImageSize { 24 | Tiny = 64, 25 | // SMALL = 128, 26 | Medium = 512, 27 | // LARGE = 1024, 28 | } 29 | 30 | pub trait UserInfo { 31 | fn update_existing_player( 32 | &self, 33 | txn: &Transaction, 34 | mod_date: DateTime, 35 | ) -> Result, rusqlite::Error>; 36 | 37 | fn insert_into_db( 38 | &self, 39 | txn: &Transaction, 40 | mod_date: DateTime, 41 | player_id: PlayerId, 42 | ) -> Result; 43 | 44 | fn name_suggestion(&self) -> Option; 45 | 46 | fn anon_name_suggestion(&self) -> String { 47 | format!("anon{:05}", rand::random::()) 48 | } 49 | } 50 | 51 | pub type OAuthClient = 52 | BasicClient; 53 | 54 | #[async_trait] 55 | pub trait AuthProvider: Send + Sync + Debug { 56 | fn service_name(&self) -> &'static str; 57 | fn logged_in_user_info_url(&self) -> &'static str; 58 | fn oauth_scopes(&self) -> &'static [&'static str]; 59 | fn make_oauth_client(&self, config: &Config) -> OAuthClient; 60 | #[allow(dead_code)] 61 | fn make_user_info_url(&self, user_id: &str) -> String; 62 | async fn parse_user_info_response( 63 | &self, 64 | res: reqwest::Response, 65 | ) -> anyhow::Result>; 66 | 67 | fn authorize_url(&self, config: &Config) -> (Url, CsrfToken) { 68 | let client = self.make_oauth_client(config); 69 | let mut req = client.authorize_url(CsrfToken::new_random); 70 | for &scope in self.oauth_scopes() { 71 | req = req.add_scope(Scope::new(scope.to_string())); 72 | } 73 | req.url() 74 | } 75 | 76 | async fn exchange_code( 77 | &self, 78 | config: &Config, 79 | auth_code: AuthorizationCode, 80 | ) -> anyhow::Result { 81 | let http_client = reqwest::Client::builder() 82 | .redirect(redirect::Policy::none()) 83 | .https_only(true) 84 | .user_agent(format!( 85 | "web:com.kachiclash:v{} (by /u/dand)", 86 | env!("CARGO_PKG_VERSION") 87 | )) 88 | .build()?; 89 | self.make_oauth_client(config) 90 | .exchange_code(auth_code) 91 | .request_async(&http_client) 92 | .await 93 | .map_err(|e| { 94 | let msg = format!("oauth code exchange error: {}", e); 95 | if let RequestTokenError::Parse(orig, body) = e { 96 | trace!("Request token response error: {}", orig); 97 | trace!( 98 | "Request token response body: {}", 99 | String::from_utf8(body).unwrap_or("not utf8".to_string()) 100 | ); 101 | } 102 | anyhow!(msg) 103 | }) 104 | } 105 | 106 | async fn get_logged_in_user_info( 107 | &self, 108 | access_token: &AccessToken, 109 | ) -> anyhow::Result> { 110 | let req = reqwest::Client::new() 111 | .get(self.logged_in_user_info_url()) 112 | .bearer_auth(access_token.secret()) 113 | .header("User-Agent", "KachiClash (http://kachiclash.com, 1)"); 114 | //debug!("sending request: {:?}", req); // Note: this logs sensitive data 115 | let res = req.send().await?; 116 | let status = res.status(); 117 | //debug!("response: {:?}", res); // Note: this logs sensitive data 118 | if status.is_success() { 119 | self.parse_user_info_response(res).await 120 | } else { 121 | debug!("body: {}", res.text().await?); 122 | Err(anyhow!( 123 | "getting logged in user info failed with http status: {}", 124 | status 125 | )) 126 | } 127 | } 128 | 129 | #[allow(dead_code)] 130 | async fn get_user_info( 131 | &self, 132 | access_token: &AccessToken, 133 | user_id: &str, 134 | ) -> anyhow::Result> { 135 | let req = reqwest::Client::new() 136 | .get(self.make_user_info_url(user_id).as_str()) 137 | .bearer_auth(access_token.secret()) 138 | .header("User-Agent", "KachiClash (http://kachiclash.com, 1)"); 139 | //debug!("sending request: {:?}", req); // Note: this logs sensitive data 140 | let res = req.send().await?; 141 | let status = res.status(); 142 | //debug!("response: {:?}", res); // Note: this logs sensitive data 143 | if status.is_success() { 144 | self.parse_user_info_response(res).await 145 | } else { 146 | debug!("body: {}", res.text().await?); 147 | Err(anyhow!( 148 | "getting user info for {} {} failed with http status: {}", 149 | self.service_name(), 150 | user_id, 151 | status 152 | )) 153 | } 154 | } 155 | } 156 | -------------------------------------------------------------------------------- /src/handlers/mod.rs: -------------------------------------------------------------------------------- 1 | extern crate askama; 2 | 3 | use crate::data::{BashoId, BashoInfo, DataError, Player, PlayerId}; 4 | use crate::AppState; 5 | 6 | use actix_identity::Identity; 7 | use actix_web::{error, web, HttpResponse}; 8 | use rusqlite::Connection; 9 | use std::error::Error; 10 | use std::fmt::{Display, Formatter}; 11 | 12 | pub mod admin; 13 | pub mod basho; 14 | pub mod heya; 15 | pub mod index; 16 | pub mod login; 17 | pub mod player; 18 | pub mod push; 19 | pub mod settings; 20 | pub mod stats; 21 | pub mod webhook; 22 | 23 | mod user_agent; 24 | 25 | type Result = std::result::Result; 26 | 27 | #[derive(Debug)] 28 | #[allow(dead_code)] 29 | pub enum HandlerError { 30 | NotFound(String), 31 | MustBeLoggedIn, 32 | ExternalServiceError, 33 | DatabaseError(DataError), 34 | CSRFError, 35 | Failure(anyhow::Error), 36 | ActixError(actix_web::Error), 37 | } 38 | 39 | impl Error for HandlerError {} 40 | 41 | impl Display for HandlerError { 42 | fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { 43 | match self { 44 | HandlerError::NotFound(thing) => write!(f, "{} not found", thing), 45 | HandlerError::MustBeLoggedIn => write!(f, "Must be logged in"), 46 | HandlerError::ExternalServiceError => write!(f, "External service error"), 47 | HandlerError::DatabaseError(_) => write!(f, "Database error"), 48 | HandlerError::CSRFError => write!(f, "CRSF error"), 49 | HandlerError::Failure(_) => write!(f, "Unexpected failure"), 50 | HandlerError::ActixError(e) => e.fmt(f), 51 | }?; 52 | Ok(()) 53 | } 54 | } 55 | 56 | impl error::ResponseError for HandlerError { 57 | fn error_response(&self) -> HttpResponse { 58 | debug!( 59 | "HandlerError {:?}, responding with error message: {}", 60 | self, self 61 | ); 62 | if let HandlerError::ActixError(e) = self { 63 | return e.error_response(); 64 | } 65 | match self { 66 | HandlerError::NotFound(_) => HttpResponse::NotFound(), 67 | HandlerError::ExternalServiceError 68 | | HandlerError::DatabaseError(_) 69 | | HandlerError::Failure(_) 70 | | HandlerError::ActixError(_) => HttpResponse::InternalServerError(), 71 | HandlerError::CSRFError | HandlerError::MustBeLoggedIn => HttpResponse::Forbidden(), 72 | } 73 | .content_type("text/plain") 74 | .body(self.to_string()) 75 | } 76 | } 77 | 78 | impl From for HandlerError { 79 | fn from(err: DataError) -> Self { 80 | match err { 81 | DataError::HeyaNotFound { slug: _, id: _ } => { 82 | debug!("{err:?}"); 83 | HandlerError::NotFound("heya".to_string()) 84 | } 85 | _ => Self::DatabaseError(err), 86 | } 87 | } 88 | } 89 | 90 | impl From for HandlerError { 91 | fn from(err: rusqlite::Error) -> Self { 92 | Self::DatabaseError(DataError::from(err)) 93 | } 94 | } 95 | 96 | impl From for HandlerError { 97 | fn from(err: anyhow::Error) -> Self { 98 | Self::Failure(err) 99 | } 100 | } 101 | 102 | impl From for HandlerError { 103 | fn from(_err: reqwest::Error) -> Self { 104 | Self::ExternalServiceError 105 | } 106 | } 107 | 108 | impl From for HandlerError { 109 | fn from(err: actix_web::Error) -> Self { 110 | Self::ActixError(err) 111 | } 112 | } 113 | 114 | impl From for HandlerError { 115 | fn from(value: actix_identity::error::LoginError) -> Self { 116 | Self::Failure(value.into()) 117 | } 118 | } 119 | 120 | struct BaseTemplate { 121 | player: Option, 122 | current_or_next_basho_id: BashoId, 123 | vapid_public_key: String, 124 | } 125 | 126 | impl BaseTemplate { 127 | fn new( 128 | db: &Connection, 129 | identity: Option<&Identity>, 130 | state: &web::Data, 131 | ) -> Result { 132 | let current_or_next_basho_id = BashoInfo::current_or_next_basho_id(db)?; 133 | let player = match identity { 134 | None => None, 135 | Some(id) => { 136 | let player_id = id.player_id()?; 137 | Some( 138 | Player::with_id(db, player_id, current_or_next_basho_id)?.ok_or_else(|| { 139 | error!("identity player id {} not found", player_id); 140 | HandlerError::NotFound("player".to_string()) 141 | })?, 142 | ) 143 | } 144 | }; 145 | let vapid_public_key = state.config.vapid_public_key.clone(); 146 | Ok(Self { 147 | player, 148 | current_or_next_basho_id, 149 | vapid_public_key, 150 | }) 151 | } 152 | 153 | fn for_admin( 154 | db: &Connection, 155 | identity: &Identity, 156 | state: &web::Data, 157 | ) -> Result { 158 | let base = BaseTemplate::new(db, Some(identity), state)?; 159 | if base.player.as_ref().is_some_and(|p| p.is_admin()) { 160 | Ok(base) 161 | } else { 162 | Err(HandlerError::MustBeLoggedIn) 163 | } 164 | } 165 | 166 | fn is_admin(&self) -> bool { 167 | match &self.player { 168 | Some(p) => p.is_admin(), 169 | None => false, 170 | } 171 | } 172 | } 173 | 174 | trait IdentityExt { 175 | fn player_id(&self) -> anyhow::Result; 176 | } 177 | 178 | impl IdentityExt for Identity { 179 | fn player_id(&self) -> anyhow::Result { 180 | Ok(self.id()?.parse()?) 181 | } 182 | } 183 | --------------------------------------------------------------------------------