mirror of
https://github.com/roleypoly/roleypoly.git
synced 2025-04-28 13:19:12 +00:00
Compare commits
477 commits
v20201002-
...
main
Author | SHA1 | Date | |
---|---|---|---|
cca0bb5c42 | |||
8c845d5d31 | |||
45a1f45055 | |||
33a8a0048f | |||
dc1e7718bc | |||
677d91b17b | |||
5f7f4c05a5 | |||
15db46c1b2 | |||
dc1a65941f | |||
f7e1c32626 | |||
dec4aa9619 | |||
175144cc7a | |||
a682ade1d3 | |||
0e4a228f1b | |||
c909b01767 | |||
edcd8f0f98 | |||
b427907f34 | |||
25107951de | |||
71d35ba36a | |||
f2fe4438d1 | |||
0da1e44147 | |||
9b42928608 | |||
a282f32c29 | |||
c0a11fc9ee | |||
54abcaeaa7 | |||
6f3f805b4a | |||
06737f4bc7 | |||
d116caccf2 | |||
2b1bb916cf | |||
9bdbc98c4f | |||
b5cfc13793 | |||
6df856ac49 | |||
9e057e8692 | |||
3c49529fe3 | |||
ce77046d86 | |||
9611b26378 | |||
f5fb729ce7 | |||
0a37eff047 | |||
5aa5a6ae1c | |||
0836d548b2 | |||
68b2b7323b | |||
8c61bfd4c7 | |||
5c5258ef5e | |||
fd7ed13e9d | |||
544ef1b2f0 | |||
7007cfea9d | |||
c7bfed8bae | |||
140f9d566b | |||
fcaf3af875 | |||
5123fba74d | |||
c28e53c6b4 | |||
e0a0d1d87a | |||
5404047bd2 | |||
7ba0b6316e | |||
b3f9f57035 | |||
|
0c19156a0a | ||
872e9b8734 | |||
9f870139ff | |||
|
5f23498967 | ||
|
2f82b4088f | ||
e7994d394b | |||
b24f3fd00a | |||
8d7d331c82 | |||
1cb04c8b5a | |||
be826b613e | |||
a008dbcc42 | |||
3291f9aacc | |||
b644a38aa7 | |||
8c07ed3123 | |||
20efb22605 | |||
0d96a4f973 | |||
f86eaae5e9 | |||
8eb4377044 | |||
4e2616c1a7 | |||
0c9f60ccd9 | |||
7a52653260 | |||
cba0d1f35a | |||
dac2af43a2 | |||
d5acea4abb | |||
76a03c2d2c | |||
26bc74bcbc | |||
3601d435b2 | |||
285e23c0ed | |||
066f68ffef | |||
dde05c402e | |||
3074db0a21 | |||
c1e0e65823 | |||
2d88c4bf23 | |||
9921410c9f | |||
3f45153b66 | |||
9c07ff0e54 | |||
6708f5c6fc | |||
62687ced78 | |||
b150462f2b | |||
4cc202b62a | |||
d52508a046 | |||
824fee0703 | |||
31ea2e2183 | |||
0ed5d696df | |||
e5d83bc133 | |||
8158ae38f1 | |||
acc604f83f | |||
5671a408c1 | |||
5dce2fc949 | |||
85ceb25c17 | |||
29adda5fd6 | |||
1cd5cd7378 | |||
57f58d7333 | |||
4d18c0da1e | |||
a3cfe6d78a | |||
3d8f1030dc | |||
51ef551d39 | |||
6d08548020 | |||
b6172b4af0 | |||
d8cdc1c62a | |||
30b9ea3a59 | |||
47fa58fd36 | |||
5e6b722290 | |||
d39b9db781 | |||
ab3f718e6d | |||
7d681d69d6 | |||
a37d481b18 | |||
9799114b7c | |||
10e095656f | |||
|
f632bfa6e5 | ||
|
ec53c65378 | ||
|
e7a6968abf | ||
94ab6d9907 | |||
ccff2684ad | |||
3748bb8695 | |||
9ee7fb4878 | |||
0706d62592 | |||
25055fc204 | |||
|
ff1a756b3d | ||
|
a6f29458f7 | ||
|
d640cc3e7d | ||
|
db10cfdd30 | ||
|
0a28c4f38c | ||
3190d41a9e | |||
|
7ec603cf70 | ||
55bc84e045 | |||
70cd0179c3 | |||
a9c94eefc3 | |||
884be92db3 | |||
a4fd37d71c | |||
57d83699d5 | |||
a5f819bc3e | |||
f4165f8055 | |||
93d2dba8e8 | |||
|
be57e762a1 | ||
00ac3ef87a | |||
117c73a8a1 | |||
bfc96b0750 | |||
a983492154 | |||
81b6de7f71 | |||
e0fcfc310e | |||
fa85b30cf0 | |||
42323a46f6 | |||
b6ae2abd2f | |||
10522e73c2 | |||
fd02dad62b | |||
8fbf8f2519 | |||
311a9c371e | |||
0c586404e8 | |||
410e27c2b3 | |||
f24d2fcc99 | |||
a931f8c69c | |||
e7702fbcac | |||
637be8bfa1 | |||
2d9d70734b | |||
5e8876a90c | |||
99952aa19f | |||
3a36d7a85d | |||
0a399938d6 | |||
8431df784f | |||
3388e091c1 | |||
6edfe7455f | |||
ed82a67594 | |||
2b467b8452 | |||
eb2537ae35 | |||
aeaf14bda1 | |||
fac361d277 | |||
a87ccd9c54 | |||
cd448b56c9 | |||
f65779f925 | |||
c9fce4d4cb | |||
558555293c | |||
ff6fe5282b | |||
e5e031fa27 | |||
da6037cf6d | |||
069930a55e | |||
2ff6588030 | |||
|
49e308507e | ||
|
bda44bd6ae | ||
|
d9092b2ddb | ||
|
1024562776 | ||
|
f93df2b151 | ||
|
cd927ada76 | ||
|
65f936be48 | ||
|
0626a3e4d3 | ||
46769cce55 | |||
|
e4f7b4dd97 | ||
bb82ce519b | |||
59bb223049 | |||
041fc19e95 | |||
14c9993bf6 | |||
6a5df41a31 | |||
6392722a6f | |||
19637c2ddb | |||
8299c548ba | |||
df05e23303 | |||
334462680e | |||
888988fce2 | |||
d70eb44963 | |||
25615ac38d | |||
20fec5ea24 | |||
5e065912de | |||
1495500f2b | |||
5ef9046e0b | |||
8c9243b736 | |||
7363b4d359 | |||
7f031bb960 | |||
779095386d | |||
25ce18b911 | |||
9699c313f9 | |||
d11e29d4f5 | |||
91c27f6b9a | |||
6d8f40e30e | |||
765a0f2b22 | |||
52a8dcdf76 | |||
|
226e212cb9 | ||
|
880dbe4912 | ||
d66fcd1cde | |||
7e007b7e86 | |||
b98eb10c12 | |||
5c36a56385 | |||
89d23cb214 | |||
04a3feceae | |||
62f57c8d67 | |||
f89cf38c7b | |||
839d98a2ff | |||
179139aa31 | |||
aa60952663 | |||
53d3c8ad2e | |||
83f3269b06 | |||
cc592a9da1 | |||
c1b9153bf4 | |||
455632d72d | |||
d61f5ae59e | |||
e758c09fbf | |||
d4e8e8330a | |||
b956f3fa66 | |||
b7f82593df | |||
95dad8effa | |||
4159622226 | |||
51ae64e664 | |||
0e33aa8112 | |||
990ac3f3ca | |||
24996d801e | |||
1a59972398 | |||
c7381c3d66 | |||
8ddf8e6aac | |||
2976b35505 | |||
ba52f7229d | |||
692467d47f | |||
6580abba1d | |||
b5585f5ee9 | |||
b20c7a08d4 | |||
034466f447 | |||
ee7ac47bc2 | |||
e4e4bb9024 | |||
3fe3cfc21f | |||
9cdefefbcc | |||
|
8f5f8bc99b | ||
447876be32 | |||
bb0987cab9 | |||
041fe49b05 | |||
e1120fd88a | |||
529a3a6f2d | |||
55c2f8615c | |||
89fbb01142 | |||
b7921a830a | |||
823a99b4eb | |||
9c935f2847 | |||
0b384bfe5c | |||
823760dc2f | |||
6d1037f25e | |||
c55ce3b828 | |||
a85c4d5ddd | |||
12d8e99513 | |||
22cbde52dd | |||
e25b9c96c6 | |||
|
961cdab975 | ||
|
036a11803d | ||
|
2660d1dad9 | ||
8fe3d1dcf2 | |||
3bca07c7d7 | |||
140da31193 | |||
f4bc1ba950 | |||
e0a2711459 | |||
d9508b0b41 | |||
|
16b614c180 | ||
|
404a7b651e | ||
c3e5eff1d7 | |||
a44983b088 | |||
6ddb3e3192 | |||
63a377867b | |||
18583f145a | |||
961989197c | |||
25a94089f8 | |||
39efe219c8 | |||
5d17105eba | |||
4dbca37917 | |||
f97ced4433 | |||
6ba252d34c | |||
473031150b | |||
afdf331070 | |||
22b15ec16a | |||
07e008fb49 | |||
181df2df40 | |||
1bcb571205 | |||
e4b80db558 | |||
853aa3ca00 | |||
ecda7e21d2 | |||
6ed7bda678 | |||
0e69066aad | |||
d8bda6fb43 | |||
441b24045d | |||
d5094f94f9 | |||
84291524dc | |||
3626c43a3d | |||
e305efbd1c | |||
754d2fc21e | |||
e954031d3e | |||
1b9eabaee5 | |||
25965e0ca8 | |||
e89ce7f10c | |||
465ca36f28 | |||
0af36d6f20 | |||
f2437a585a | |||
a3778c7a1e | |||
bbdea9c074 | |||
a0031d4c2b | |||
26f00eaf22 | |||
820f2005c4 | |||
8a57e10e78 | |||
5763b3f279 | |||
734dbecf6b | |||
991e691a8c | |||
ff973b9349 | |||
146269944a | |||
ed1db4d899 | |||
43d6b6565c | |||
95a5732457 | |||
0b769913fb | |||
ef742bba37 | |||
f4716584bb | |||
5ae666176b | |||
c25943dfa0 | |||
432922dd21 | |||
bd15d1f6fa | |||
ab8ae622eb | |||
4ef4badec3 | |||
9ba1334e2b | |||
b62166abda | |||
3ee0c64e7c | |||
7ad719895d | |||
e028b64ff8 | |||
8870f6b640 | |||
d4e9f38a65 | |||
344c6e1c52 | |||
d93592f1a2 | |||
2e1e63a789 | |||
899853e24c | |||
80db505a11 | |||
c23b84c4fa | |||
f2c4effd24 | |||
4d0fcc4ad6 | |||
dec76a40a5 | |||
05d7e5c145 | |||
b37e3de378 | |||
0e2c981560 | |||
7371b5e490 | |||
0340693234 | |||
558207872d | |||
e35b17e685 | |||
75882f4331 | |||
9310dc31f9 | |||
4dc0eeaee3 | |||
156bd8f06e | |||
aad0987dce | |||
9eeb946389 | |||
ab9fe30b42 | |||
460770407a | |||
00f0741e8b | |||
d3394412db | |||
c9cb4c95bc | |||
a23184efd2 | |||
bebfc862e8 | |||
d8a25024de | |||
3eba2d2de8 | |||
8d23accedc | |||
8ea7746dd5 | |||
1dd910a5f6 | |||
5fcac53be2 | |||
c8adad6c81 | |||
e61f827645 | |||
ba558ecf91 | |||
652e76241d | |||
3bc994da5f | |||
d29de5c7ec | |||
4c3f5de0f6 | |||
b3c384421b | |||
35e4c94e56 | |||
f163350057 | |||
91a7d83f3d | |||
|
e875ca796d | ||
682fe0842d | |||
4a558bb532 | |||
2f8fdf3b82 | |||
a0b4392b05 | |||
a33aa3841c | |||
97c09f4aa5 | |||
89f237cf22 | |||
c41fcabfd0 | |||
3d867c1db0 | |||
1bbc61c82f | |||
61f6c3b34b | |||
5b440ffa8d | |||
c7afd84e1e | |||
a9af2d10bd | |||
c26f3c9ef7 | |||
783915f057 | |||
291ec9576f | |||
9823670084 | |||
efe1e5ea5e | |||
ac830fc946 | |||
5977c35d38 | |||
cb5ff9602a | |||
9e6a942018 | |||
2bb7d8666d | |||
2d919c6053 | |||
e33e6f8574 | |||
00dff464df | |||
f31b32c54a | |||
cdafaa90db | |||
878f94ea93 | |||
67fd42f0d7 | |||
70fa51d4a1 | |||
ccf89d8480 | |||
f7e2d1afef | |||
4a4015f765 | |||
d0afb1488e | |||
9e6f8fd423 | |||
e66758eaa7 | |||
6373de8ab5 | |||
72ea639c5d | |||
d1bb55bb7c | |||
ec505739c8 | |||
a5e2fdc7a7 | |||
dd8841d0ae | |||
3f9ac5f275 | |||
f32b2a2a98 | |||
3a9ae9278a | |||
f1ea4640f3 | |||
d4727cd92a | |||
b834066479 | |||
|
101c476739 | ||
|
a8ceb8b3ed | ||
|
064f5bda47 | ||
|
e95bfc2f8d | ||
515a32fe44 | |||
d92f3680ff | |||
d5c21b8e65 | |||
a58d07f16a | |||
0002cefd7f | |||
4cef998233 |
584 changed files with 34973 additions and 14203 deletions
3
.babelrc.js
Normal file
3
.babelrc.js
Normal file
|
@ -0,0 +1,3 @@
|
|||
module.exports = {
|
||||
plugins: ['styled-components'],
|
||||
};
|
|
@ -1,10 +0,0 @@
|
|||
load("@io_bazel_rules_docker//container:container.bzl", "container_push")
|
||||
|
||||
container_push(
|
||||
name = "publish-dev-container",
|
||||
format = "Docker",
|
||||
image = "@dev-container//image:dockerfile_image.tar",
|
||||
registry = "docker.pkg.github.com",
|
||||
repository = "roleypoly/roleypoly/dev-container",
|
||||
tag = "{STABLE_GIT_BRANCH}",
|
||||
)
|
|
@ -1,14 +0,0 @@
|
|||
FROM mcr.microsoft.com/vscode/devcontainers/go:1.15
|
||||
|
||||
# Install Bazel
|
||||
ARG BAZEL_VERSION=3.5.0
|
||||
ARG BAZEL_DOWNLOAD_SHA=dev-mode
|
||||
RUN curl -fSsL -o /tmp/bazel-installer.sh https://github.com/bazelbuild/bazel/releases/download/${BAZEL_VERSION}/bazel-${BAZEL_VERSION}-installer-linux-x86_64.sh \
|
||||
&& ([ "${BAZEL_DOWNLOAD_SHA}" = "dev-mode" ] || echo "${BAZEL_DOWNLOAD_SHA} */tmp/bazel-installer.sh" | sha256sum --check - ) \
|
||||
&& /bin/bash /tmp/bazel-installer.sh --base=/usr/local/bazel \
|
||||
&& rm /tmp/bazel-installer.sh
|
||||
|
||||
# Install Node.js
|
||||
ARG INSTALL_NODE="true"
|
||||
ARG NODE_VERSION="lts/*"
|
||||
RUN if [ "${INSTALL_NODE}" = "true" ]; then su vscode -c "source /usr/local/share/nvm/nvm.sh && nvm install ${NODE_VERSION} 2>&1"; fi
|
|
@ -1,28 +1,27 @@
|
|||
{
|
||||
"name": "Roleypoly (Bazel, Go, Node)",
|
||||
"build": {
|
||||
"dockerfile": "Dockerfile",
|
||||
"args": {
|
||||
"BAZEL_VERSION": "3.5.0",
|
||||
}
|
||||
},
|
||||
"name": "Roleypoly (Node)",
|
||||
"image": "ghcr.io/roleypoly/dev-container:main",
|
||||
// Set *default* container specific settings.json values on container create.
|
||||
"settings": {
|
||||
"terminal.integrated.shell.linux": "/bin/bash"
|
||||
},
|
||||
// Add the IDs of extensions you want installed when the container is created.
|
||||
"extensions": [
|
||||
"bazelbuild.vscode-bazel",
|
||||
"dbaeumer.vscode-eslint",
|
||||
"golang.go",
|
||||
"hashicorp.terraform",
|
||||
"firsttris.vscode-jest-runner",
|
||||
"esbenp.prettier-vscode",
|
||||
"zxh404.vscode-proto3",
|
||||
"jpoissonnier.vscode-styled-components",
|
||||
"eg2.vscode-npm-script",
|
||||
"christian-kohler.npm-intellisense"
|
||||
]
|
||||
"christian-kohler.npm-intellisense",
|
||||
"ms-azuretools.vscode-docker",
|
||||
"eamodio.gitlens",
|
||||
"davidanson.vscode-markdownlint",
|
||||
"stylelint.vscode-stylelint",
|
||||
"pflannery.vscode-versionlens",
|
||||
"visualstudioexptteam.vscodeintellicode",
|
||||
"bungcip.better-toml"
|
||||
],
|
||||
// Use 'forwardPorts' to make a list of ports inside the container available locally.
|
||||
// "forwardPorts": [],
|
||||
// Use 'postCreateCommand' to run commands after the container is created.
|
||||
|
@ -30,5 +29,5 @@
|
|||
// Uncomment when using a ptrace-based debugger like C++, Go, and Rust
|
||||
// "runArgs": [ "--cap-add=SYS_PTRACE", "--security-opt", "seccomp=unconfined" ],
|
||||
// Uncomment to connect as a non-root user. See https://aka.ms/vscode-remote/containers/non-root.
|
||||
// "remoteUser": "vscode"
|
||||
"remoteUser": "vscode"
|
||||
}
|
2
.dockerignore
Normal file
2
.dockerignore
Normal file
|
@ -0,0 +1,2 @@
|
|||
node_modules
|
||||
.next
|
21
.env.example
Normal file
21
.env.example
Normal file
|
@ -0,0 +1,21 @@
|
|||
# Make an application at https://discord.com/developers/applications
|
||||
BOT_CLIENT_ID=000000000000000000
|
||||
BOT_CLIENT_SECRET=RnX8pXXXXXXXXXXXXXXXXXXXXXXXXXu-
|
||||
BOT_TOKEN=Mzk2MjI3MTM0MjI3NXXXXXXXXXXXXXXXXXXXXXPUlYoARXXXXXXXXXXXXXX
|
||||
DISCORD_PUBLIC_KEY=XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXx
|
||||
|
||||
# Comma separated; put your user ID here. Gives elevated permissions to everything.
|
||||
ROOT_USERS=62601275618889728
|
||||
|
||||
# Comma separated; list any bot IDs that are allowed to operate upon this bot.
|
||||
ALLOWED_BOTS=
|
||||
|
||||
# If 6600 or 6601 is taken, change this, and all other 6600/6601 references.
|
||||
PORT=6609
|
||||
UI_PORT=6601
|
||||
|
||||
# Again, probably right. Do not put a trailing /
|
||||
UI_PUBLIC_URI=http://localhost:6601
|
||||
API_PUBLIC_URI=http://localhost:6609
|
||||
|
||||
ALLOWED_CALLBACK_HOSTS=http://localhost:6601,https://stage.roleypoly.com,https://next.roleypoly.com,https://roleypoly.com,https://*.roleypoly.pages.dev
|
128
.eslintrc.js
Normal file
128
.eslintrc.js
Normal file
|
@ -0,0 +1,128 @@
|
|||
module.exports = {
|
||||
env: {
|
||||
browser: true,
|
||||
es6: true,
|
||||
node: true,
|
||||
},
|
||||
extends: ['prettier', 'prettier/@typescript-eslint'],
|
||||
parser: '@typescript-eslint/parser',
|
||||
parserOptions: {
|
||||
project: 'tsconfig.json',
|
||||
sourceType: 'module',
|
||||
},
|
||||
plugins: [
|
||||
'eslint-plugin-import',
|
||||
'eslint-plugin-jsdoc',
|
||||
'eslint-plugin-react',
|
||||
'@typescript-eslint',
|
||||
'@typescript-eslint/tslint',
|
||||
],
|
||||
rules: {
|
||||
'react/jsx-uses-react': 'off',
|
||||
'react/react-in-jsx-scope': 'off',
|
||||
'@typescript-eslint/await-thenable': 'error',
|
||||
'@typescript-eslint/consistent-type-assertions': 'error',
|
||||
'@typescript-eslint/indent': 'off',
|
||||
'@typescript-eslint/member-delimiter-style': [
|
||||
'off',
|
||||
{
|
||||
multiline: {
|
||||
delimiter: 'none',
|
||||
requireLast: true,
|
||||
},
|
||||
singleline: {
|
||||
delimiter: 'semi',
|
||||
requireLast: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
'@typescript-eslint/no-empty-function': 'error',
|
||||
'@typescript-eslint/no-floating-promises': 'error',
|
||||
'@typescript-eslint/no-misused-new': 'error',
|
||||
'@typescript-eslint/no-unnecessary-qualifier': 'error',
|
||||
'@typescript-eslint/no-unnecessary-type-assertion': 'error',
|
||||
'@typescript-eslint/no-unused-expressions': [
|
||||
'error',
|
||||
{
|
||||
allowTaggedTemplates: true,
|
||||
allowShortCircuit: true,
|
||||
},
|
||||
],
|
||||
'@typescript-eslint/prefer-namespace-keyword': 'error',
|
||||
'@typescript-eslint/quotes': 'off',
|
||||
'@typescript-eslint/semi': ['off', null],
|
||||
'@typescript-eslint/triple-slash-reference': [
|
||||
'error',
|
||||
{
|
||||
path: 'always',
|
||||
types: 'prefer-import',
|
||||
lib: 'always',
|
||||
},
|
||||
],
|
||||
'@typescript-eslint/type-annotation-spacing': 'off',
|
||||
'@typescript-eslint/unified-signatures': 'error',
|
||||
'arrow-parens': ['off', 'always'],
|
||||
'brace-style': ['off', 'off'],
|
||||
'comma-dangle': 'off',
|
||||
curly: ['error', 'multi-line'],
|
||||
'eol-last': 'off',
|
||||
eqeqeq: ['error', 'smart'],
|
||||
'id-blacklist': [
|
||||
'error',
|
||||
'any',
|
||||
'Number',
|
||||
'number',
|
||||
'String',
|
||||
'string',
|
||||
'Boolean',
|
||||
'boolean',
|
||||
'Undefined',
|
||||
'undefined',
|
||||
],
|
||||
'id-match': 'error',
|
||||
'import/no-deprecated': 'error',
|
||||
'jsdoc/check-alignment': 'error',
|
||||
'jsdoc/check-indentation': 'error',
|
||||
'jsdoc/newline-after-description': 'error',
|
||||
'linebreak-style': 'off',
|
||||
'max-len': 'off',
|
||||
'new-parens': 'off',
|
||||
'newline-per-chained-call': 'off',
|
||||
'no-caller': 'error',
|
||||
'no-cond-assign': 'error',
|
||||
'no-constant-condition': 'error',
|
||||
'no-control-regex': 'error',
|
||||
'no-duplicate-imports': 'error',
|
||||
'no-empty': 'error',
|
||||
'no-eval': 'error',
|
||||
'no-extra-semi': 'off',
|
||||
'no-fallthrough': 'error',
|
||||
'no-invalid-regexp': 'error',
|
||||
'no-irregular-whitespace': 'off',
|
||||
'no-multiple-empty-lines': 'off',
|
||||
'no-redeclare': 'error',
|
||||
'no-regex-spaces': 'error',
|
||||
'no-return-await': 'error',
|
||||
'no-throw-literal': 'error',
|
||||
'no-trailing-spaces': 'off',
|
||||
'no-underscore-dangle': 'error',
|
||||
'no-unused-labels': 'error',
|
||||
'no-var': 'error',
|
||||
'one-var': ['error', 'never'],
|
||||
'quote-props': 'off',
|
||||
radix: 'error',
|
||||
'react/jsx-curly-spacing': 'off',
|
||||
'react/jsx-equals-spacing': 'off',
|
||||
'react/jsx-wrap-multilines': 'off',
|
||||
'space-before-function-paren': 'off',
|
||||
'space-in-parens': ['off', 'never'],
|
||||
'spaced-comment': [
|
||||
'error',
|
||||
'always',
|
||||
{
|
||||
markers: ['/'],
|
||||
},
|
||||
],
|
||||
'use-isnan': 'error',
|
||||
},
|
||||
};
|
2
.github/FUNDING.yml
vendored
2
.github/FUNDING.yml
vendored
|
@ -1,7 +1,7 @@
|
|||
# These are supported funding model platforms
|
||||
|
||||
github: kayteh
|
||||
patreon: kata
|
||||
patreon: roleypoly
|
||||
open_collective: # Replace with a single Open Collective username
|
||||
ko_fi: roleypoly
|
||||
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
|
||||
|
|
21
.github/dependabot.yml
vendored
Normal file
21
.github/dependabot.yml
vendored
Normal file
|
@ -0,0 +1,21 @@
|
|||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: 'npm'
|
||||
directory: '/'
|
||||
schedule:
|
||||
interval: 'daily'
|
||||
|
||||
- package-ecosystem: 'github-actions'
|
||||
directory: '/'
|
||||
schedule:
|
||||
interval: 'daily'
|
||||
|
||||
- package-ecosystem: 'gomod'
|
||||
directory: '/'
|
||||
schedule:
|
||||
interval: 'daily'
|
||||
|
||||
- package-ecosystem: 'terraform'
|
||||
directory: '/terraform'
|
||||
schedule:
|
||||
interval: 'daily'
|
122
.github/workflows/build.yml
vendored
122
.github/workflows/build.yml
vendored
|
@ -1,59 +1,91 @@
|
|||
name: Bazel Build
|
||||
name: Roleypoly CI
|
||||
|
||||
on: push
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
bazel_build:
|
||||
name: Bazel Build
|
||||
node_test:
|
||||
runs-on: ubuntu-latest
|
||||
name: Node CI
|
||||
steps:
|
||||
- uses: actions/checkout@master
|
||||
|
||||
- name: Mount bazel cache
|
||||
uses: actions/cache@v1
|
||||
- uses: actions/setup-node@v2.5.1
|
||||
with:
|
||||
path: "/home/runner/.cache/bazel"
|
||||
key: bazel
|
||||
node-version: '20'
|
||||
cache: yarn
|
||||
|
||||
- name: Install bazelisk
|
||||
run: |
|
||||
curl -LO "https://github.com/bazelbuild/bazelisk/releases/download/v1.1.0/bazelisk-linux-amd64"
|
||||
mkdir -p "${GITHUB_WORKSPACE}/bin/"
|
||||
mv bazelisk-linux-amd64 "${GITHUB_WORKSPACE}/bin/bazel"
|
||||
chmod +x "${GITHUB_WORKSPACE}/bin/bazel"
|
||||
- run: yarn install --frozen-lockfile
|
||||
|
||||
- name: Test
|
||||
run: |
|
||||
"${GITHUB_WORKSPACE}/bin/bazel" test \
|
||||
--stamp \
|
||||
--workspace_status_command hack/workspace_status.sh \
|
||||
//src/...
|
||||
- run: yarn lint
|
||||
|
||||
- name: Docker Login
|
||||
run: |
|
||||
echo ${{github.token}} | docker login -u ${{github.actor}} --password-stdin docker.pkg.github.com
|
||||
# - run: yarn test
|
||||
|
||||
- name: Publish Artifacts
|
||||
run: |
|
||||
"${GITHUB_WORKSPACE}/bin/bazel" query //src/... |\
|
||||
grep +publish |\
|
||||
xargs -l1 "${GITHUB_WORKSPACE}/bin/bazel" run \
|
||||
--stamp \
|
||||
--workspace_status_command hack/workspace_status.sh
|
||||
worker_build:
|
||||
runs-on: ubuntu-latest
|
||||
name: Worker Build & Publish
|
||||
if: startsWith(github.ref, 'refs/heads/dependabot/') != true
|
||||
needs:
|
||||
- node_test
|
||||
strategy:
|
||||
matrix:
|
||||
worker:
|
||||
- api
|
||||
steps:
|
||||
- uses: actions/checkout@master
|
||||
|
||||
- name: Write Artifact Manifest
|
||||
run: |
|
||||
artifacts=$(${GITHUB_WORKSPACE}/bin/bazel query //src/... | grep +publish)
|
||||
publishedServices=${artifacts//$'//src/'/}
|
||||
publishedServices=${publishedServices//$':+publish'/}
|
||||
manifestJSON='{"services": {}}'
|
||||
for svc in $publishedServices; do
|
||||
manifestJSON=$(echo $manifestJSON | jq ".services+={\"$svc\":\"$(cat bazel-bin/src/$svc/+publish.digest)\"}")
|
||||
done
|
||||
echo $manifestJSON > manifest.json
|
||||
|
||||
- name: Upload Artifact Manifest
|
||||
uses: actions/upload-artifact@v2
|
||||
- uses: actions/setup-node@v2.5.1
|
||||
with:
|
||||
name: manifest.json
|
||||
path: manifest.json
|
||||
node-version: '20'
|
||||
cache: yarn
|
||||
|
||||
- id: 'auth'
|
||||
uses: 'google-github-actions/auth@v0'
|
||||
with:
|
||||
credentials_json: '${{ secrets.GCS_TF_KEY }}'
|
||||
|
||||
- name: Set up Cloud SDK
|
||||
uses: google-github-actions/setup-gcloud@v0
|
||||
with:
|
||||
project_id: ${{ secrets.GCS_PROJECT_ID }}
|
||||
export_default_credentials: true
|
||||
|
||||
- name: Check if already deployed
|
||||
id: check
|
||||
run: |
|
||||
gsutil stat gs://roleypoly-artifacts/workers/${{ github.sha }}/index.mjs \
|
||||
&& echo ::set-output name=skip::1 \
|
||||
|| echo ::set-output name=skip::0
|
||||
|
||||
- run: yarn install --frozen-lockfile
|
||||
if: steps.check.outputs.skip == '0'
|
||||
|
||||
- run: |
|
||||
yarn build:api
|
||||
if: steps.check.outputs.skip == '0'
|
||||
|
||||
- id: upload-file
|
||||
if: github.event_name == 'push' && steps.check.outputs.skip == '0'
|
||||
uses: google-github-actions/upload-cloud-storage@main
|
||||
with:
|
||||
path: packages/api/dist/index.mjs
|
||||
destination: roleypoly-artifacts/workers/${{ github.sha }}
|
||||
|
||||
trigger_deploy:
|
||||
name: Deploy to Stage
|
||||
needs:
|
||||
- worker_build
|
||||
if: github.ref == 'refs/heads/main'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Invoke Deploy workflow
|
||||
uses: benc-uk/workflow-dispatch@v1
|
||||
with:
|
||||
workflow: Deploy
|
||||
token: ${{ secrets.GITOPS_TOKEN }}
|
||||
inputs: |-
|
||||
{
|
||||
"environment": "stage",
|
||||
"worker_tag": "${{ github.sha }}"
|
||||
}
|
||||
|
|
85
.github/workflows/codeql-analysis.yml
vendored
85
.github/workflows/codeql-analysis.yml
vendored
|
@ -1,66 +1,51 @@
|
|||
name: "CodeQL"
|
||||
name: 'Code Scanning - Action'
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
# The branches below must be a subset of the branches above
|
||||
branches: [main]
|
||||
schedule:
|
||||
- cron: '0 1 * * 2'
|
||||
# ┌───────────── minute (0 - 59)
|
||||
# │ ┌───────────── hour (0 - 23)
|
||||
# │ │ ┌───────────── day of the month (1 - 31)
|
||||
# │ │ │ ┌───────────── month (1 - 12 or JAN-DEC)
|
||||
# │ │ │ │ ┌───────────── day of the week (0 - 6 or SUN-SAT)
|
||||
# │ │ │ │ │
|
||||
# │ │ │ │ │
|
||||
# │ │ │ │ │
|
||||
# * * * * *
|
||||
- cron: '30 1 * * 0'
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
name: Analyze
|
||||
CodeQL-Build:
|
||||
# CodeQL runs on ubuntu-latest, windows-latest, and macos-latest
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
# Override automatic language detection by changing the below list
|
||||
# Supported options are ['csharp', 'cpp', 'go', 'java', 'javascript', 'python']
|
||||
language: ['go', 'javascript']
|
||||
# Learn more...
|
||||
# https://docs.github.com/en/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#overriding-automatic-language-detection
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v2
|
||||
with:
|
||||
# We must fetch at least the immediate parents so that if this is
|
||||
# a pull request then we can checkout the head.
|
||||
fetch-depth: 2
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v2
|
||||
|
||||
# If this run was triggered by a pull request event, then checkout
|
||||
# the head of the pull request instead of the merge commit.
|
||||
- run: git checkout HEAD^2
|
||||
if: ${{ github.event_name == 'pull_request' }}
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v1
|
||||
# Override language selection by uncommenting this and choosing your languages
|
||||
# with:
|
||||
# languages: go, javascript, csharp, python, cpp, java
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v1
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
# If you wish to specify custom queries, you can do so here or in a config file.
|
||||
# By default, queries listed here will override any specified in a config file.
|
||||
# Prefix the list here with "+" to use these queries and those in the config file.
|
||||
# queries: ./path/to/local/query, your-org/your-repo/queries@main
|
||||
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
|
||||
# If this step fails, then you should remove it and run the build manually (see below).
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@v1
|
||||
|
||||
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
|
||||
# If this step fails, then you should remove it and run the build manually (see below)
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@v1
|
||||
# ℹ️ Command-line programs to run using the OS shell.
|
||||
# 📚 https://git.io/JvXDl
|
||||
|
||||
# ℹ️ Command-line programs to run using the OS shell.
|
||||
# 📚 https://git.io/JvXDl
|
||||
# ✏️ If the Autobuild fails above, remove it and uncomment the following
|
||||
# three lines and modify them (or add more) to build your code if your
|
||||
# project uses a compiled language
|
||||
|
||||
# ✏️ If the Autobuild fails above, remove it and uncomment the following three lines
|
||||
# and modify them (or add more) to build your code if your project
|
||||
# uses a compiled language
|
||||
#- run: |
|
||||
# make bootstrap
|
||||
# make release
|
||||
|
||||
#- run: |
|
||||
# make bootstrap
|
||||
# make release
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v1
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v1
|
||||
|
|
128
.github/workflows/deploy.yml
vendored
Normal file
128
.github/workflows/deploy.yml
vendored
Normal file
|
@ -0,0 +1,128 @@
|
|||
name: Deploy
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
environment:
|
||||
description: 'One of: stage, prod'
|
||||
required: true
|
||||
default: stage
|
||||
bot_tag:
|
||||
description: 'tag/digest reference to a UI container build'
|
||||
required: false
|
||||
default: ':main'
|
||||
worker_tag:
|
||||
description: 'bucket key to fetch worker from'
|
||||
required: false
|
||||
default: '' # Empty will try using current main branch hash
|
||||
|
||||
jobs:
|
||||
deploy_terraform:
|
||||
name: Deploy Terraform
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@master
|
||||
|
||||
- uses: hashicorp/setup-terraform@v1.3.2
|
||||
with:
|
||||
terraform_version: ^1.1.4
|
||||
|
||||
- name: Set up Cloud SDK
|
||||
uses: google-github-actions/setup-gcloud@v0
|
||||
with:
|
||||
project_id: ${{ secrets.GCS_PROJECT_ID }}
|
||||
service_account_key: ${{ secrets.GCS_TF_KEY }}
|
||||
export_default_credentials: true
|
||||
|
||||
- name: Get Google Secrets (they keep them in a box under a tree)
|
||||
id: secrets
|
||||
uses: google-github-actions/get-secretmanager-secrets@main
|
||||
with:
|
||||
secrets: |-
|
||||
secretJSON:${{ secrets.GCS_PROJECT_ID }}/${{github.event.inputs.environment}}-tfvars
|
||||
|
||||
- name: Pull necessary artifacts
|
||||
working-directory: ./terraform
|
||||
run: |
|
||||
currentHash=${{ github.sha }}
|
||||
targetArtifact=${{ github.event.inputs.worker_tag }}
|
||||
selected="${targetArtifact:-$currentHash}"
|
||||
|
||||
mkdir worker-dist
|
||||
gsutil cp -r "gs://roleypoly-artifacts/workers/$selected/*" worker-dist/
|
||||
|
||||
- name: Terraform init
|
||||
working-directory: ./terraform
|
||||
run: |
|
||||
terraform init --backend-config "prefix=${{github.event.inputs.environment}}"
|
||||
|
||||
- name: Write *.auto.tfvars.json files
|
||||
working-directory: ./terraform
|
||||
run: |
|
||||
echo \
|
||||
'{"bot_tag": "${{github.event.inputs.bot_tag}}", "worker_tag": "${{github.event.inputs.worker_tag}}", "path_to_worker": "./worker-dist/index.mjs"}' \
|
||||
| jq . \
|
||||
| tee tags.auto.tfvars.json
|
||||
|
||||
echo ${SECRET_TFVARS} > secrets.auto.tfvars.json
|
||||
env:
|
||||
SECRET_TFVARS: ${{ steps.secrets.outputs.secretJSON }}
|
||||
|
||||
- name: Terraform plan
|
||||
working-directory: ./terraform
|
||||
run: |
|
||||
terraform plan \
|
||||
-var-file variables/global.tfvars \
|
||||
-var-file variables/${{github.event.inputs.environment}}.tfvars \
|
||||
-out=./deployment.tfplan
|
||||
|
||||
- name: Terraform apply
|
||||
working-directory: ./terraform
|
||||
run: |
|
||||
terraform apply \
|
||||
-auto-approve \
|
||||
deployment.tfplan
|
||||
|
||||
- name: Yell Success at Discord
|
||||
if: success()
|
||||
run: |
|
||||
DATA='{
|
||||
"embeds": [
|
||||
{
|
||||
"title": "Roleypoly Deployment Success",
|
||||
"description": "Roleypoly was successfully deployed at '$(date)'",
|
||||
"color": 4634182,
|
||||
"author": {
|
||||
"name": "Deployment Notification",
|
||||
"url": "https://github.com/roleypoly/roleypoly/actions/runs/${{ github.run_id }}"
|
||||
},
|
||||
"footer": {
|
||||
"text": "GitHub Actions"
|
||||
}
|
||||
}
|
||||
]
|
||||
}'
|
||||
|
||||
curl -X POST -H "content-type: application/json" --data "$DATA" ${{ secrets.DEPLOYMENT_WEBHOOK_URL }}
|
||||
|
||||
- name: Yell Failure at Discord
|
||||
if: failure()
|
||||
run: |
|
||||
DATA='{
|
||||
"embeds": [
|
||||
{
|
||||
"title": "Roleypoly Deployment Failed",
|
||||
"description": "Roleypoly failed to be deployed at '$(date)'",
|
||||
"color": 15291219,
|
||||
"author": {
|
||||
"name": "Deployment Notification",
|
||||
"url": "https://github.com/roleypoly/roleypoly/actions/runs/${{ github.run_id }}"
|
||||
},
|
||||
"footer": {
|
||||
"text": "GitHub Actions"
|
||||
}
|
||||
}
|
||||
]
|
||||
}'
|
||||
|
||||
curl -X POST -H "content-type: application/json" --data "$DATA" ${{ secrets.DEPLOYMENT_WEBHOOK_URL }}
|
63
.github/workflows/dev-container.yml
vendored
63
.github/workflows/dev-container.yml
vendored
|
@ -1,34 +1,53 @@
|
|||
name: Build Dev Container
|
||||
name: Dev Container
|
||||
|
||||
on:
|
||||
push:
|
||||
paths:
|
||||
- .devcontainer/Dockerfile
|
||||
- hack/dockerfiles/dev-container.Dockerfile
|
||||
- .github/workflows/dev-container.yml
|
||||
schedule:
|
||||
- cron: "0 12 * * 2" # 12 noon every tuesday
|
||||
|
||||
- cron: '0 12 * * 2' # 12 noon every tuesday
|
||||
jobs:
|
||||
dev_container_build:
|
||||
name: Bazel Build (Dev Container)
|
||||
docker_build:
|
||||
name: Docker Build & Publish
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@master
|
||||
- name: Mount bazel cache
|
||||
uses: actions/cache@v1
|
||||
|
||||
- uses: actions/cache@v2.1.6
|
||||
with:
|
||||
path: "/home/runner/.cache/bazel"
|
||||
key: bazel
|
||||
path: /tmp/.buildx-cache
|
||||
key: ${{ runner.os }}-buildx-${{ github.sha }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-buildx-
|
||||
|
||||
- name: Install bazelisk
|
||||
run: |
|
||||
curl -LO "https://github.com/bazelbuild/bazelisk/releases/download/v1.1.0/bazelisk-linux-amd64"
|
||||
mkdir -p "${GITHUB_WORKSPACE}/bin/"
|
||||
mv bazelisk-linux-amd64 "${GITHUB_WORKSPACE}/bin/bazel"
|
||||
chmod +x "${GITHUB_WORKSPACE}/bin/bazel"
|
||||
- name: Docker meta
|
||||
id: docker_meta
|
||||
uses: crazy-max/ghaction-docker-meta@v1
|
||||
with:
|
||||
images: ghcr.io/roleypoly/dev-container
|
||||
tag-sha: true
|
||||
|
||||
- name: Build & Publish Dev Container
|
||||
run: |
|
||||
"${GITHUB_WORKSPACE}/bin/bazel" run \
|
||||
--stamp \
|
||||
--workspace_status_command hack/workspace_status.sh\
|
||||
//.devcontainer:publish-dev-container
|
||||
- name: Set up Docker Buildx
|
||||
id: buildx
|
||||
uses: docker/setup-buildx-action@v1
|
||||
with:
|
||||
install: true
|
||||
|
||||
- name: Login to GitHub Packages Docker Registry
|
||||
uses: docker/login-action@v1
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: roleypoly
|
||||
password: ${{ secrets.GHCR_PAT }}
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v2
|
||||
with:
|
||||
context: .
|
||||
file: ./hack/dockerfiles/dev-container.Dockerfile
|
||||
push: true
|
||||
cache-from: type=local,src=/tmp/.buildx-cache
|
||||
cache-to: type=local,dest=/tmp/.buildx-cache
|
||||
tags: ${{ steps.docker_meta.outputs.tags }}
|
||||
labels: ${{ steps.docker_meta.outputs.labels }}
|
||||
|
|
27
.github/workflows/release.yml
vendored
27
.github/workflows/release.yml
vendored
|
@ -1,27 +0,0 @@
|
|||
name: Release Workflow
|
||||
|
||||
on: workflow_dispatch
|
||||
|
||||
jobs:
|
||||
commit_release_tag:
|
||||
name: Commit Roleypoly Release Tag
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@master
|
||||
with:
|
||||
ssh-key: ${{secrets.DEPLOY_KEY}}
|
||||
|
||||
# - name: Setup Git
|
||||
# uses: webfactory/ssh-agent@v0.2.0
|
||||
# with:
|
||||
# ssh-private-key: ${{ secrets.DEPLOY_KEY }}
|
||||
|
||||
- name: Push changes
|
||||
id: push
|
||||
run: |
|
||||
TAG=$(date +v%Y%m%d-%H%M%S)
|
||||
git config --local user.email "gh-automation@roleypoly.com"
|
||||
git config --local user.name "Roleypoly Release Automation"
|
||||
git tag $TAG
|
||||
git push origin $TAG
|
||||
echo "::set-output release_tag=${TAG}"
|
11
.gitignore
vendored
11
.gitignore
vendored
|
@ -1,7 +1,8 @@
|
|||
bazel-bin
|
||||
bazel-out
|
||||
bazel-roleypoly
|
||||
bazel-testlogs
|
||||
node_modules
|
||||
.env
|
||||
docker-compose.yaml
|
||||
*.log
|
||||
storybook-static
|
||||
.next
|
||||
worker
|
||||
.devdbs
|
||||
dist
|
||||
|
|
1
.husky/.gitignore
vendored
Normal file
1
.husky/.gitignore
vendored
Normal file
|
@ -0,0 +1 @@
|
|||
_
|
4
.husky/pre-commit
Executable file
4
.husky/pre-commit
Executable file
|
@ -0,0 +1,4 @@
|
|||
#!/bin/sh
|
||||
. "$(dirname $0)/_/husky.sh"
|
||||
|
||||
yarn lint-staged
|
1
.nvmrc
Normal file
1
.nvmrc
Normal file
|
@ -0,0 +1 @@
|
|||
16.13.2
|
9
.prettierignore
Normal file
9
.prettierignore
Normal file
|
@ -0,0 +1,9 @@
|
|||
bazel-*
|
||||
dist
|
||||
storybook-static
|
||||
.next
|
||||
worker
|
||||
**/dist
|
||||
terraform
|
||||
.husky/_
|
||||
.mf
|
9
.prettierrc.js
Normal file
9
.prettierrc.js
Normal file
|
@ -0,0 +1,9 @@
|
|||
module.exports = {
|
||||
printWidth: 90,
|
||||
useTabs: false,
|
||||
tabWidth: 2,
|
||||
singleQuote: true,
|
||||
trailingComma: 'es5',
|
||||
bracketSpacing: true,
|
||||
semi: true,
|
||||
};
|
3
.stylelintignore
Normal file
3
.stylelintignore
Normal file
|
@ -0,0 +1,3 @@
|
|||
bazel-*
|
||||
dist
|
||||
storybook-static
|
16
.stylelintrc
Normal file
16
.stylelintrc
Normal file
|
@ -0,0 +1,16 @@
|
|||
{
|
||||
"customSyntax": "@stylelint/postcss-css-in-js",
|
||||
"extends": [
|
||||
"stylelint-config-recommended",
|
||||
"stylelint-config-styled-components"
|
||||
],
|
||||
"rules": {
|
||||
"color-function-notation": "modern",
|
||||
"shorthand-property-no-redundant-values": true,
|
||||
"font-weight-notation": "numeric",
|
||||
"alpha-value-notation": "percentage",
|
||||
"hue-degree-notation": "angle",
|
||||
"function-calc-no-unspaced-operator": true,
|
||||
"length-zero-no-unit": true
|
||||
}
|
||||
}
|
17
.vscode/settings.json
vendored
17
.vscode/settings.json
vendored
|
@ -1,10 +1,15 @@
|
|||
{
|
||||
"go.inferGopath": false,
|
||||
"editor.tabSize": 2,
|
||||
"editor.insertSpaces": true,
|
||||
"editor.formatOnSave": true,
|
||||
"bazel.buildifierFixOnFormat": true,
|
||||
"[starlark]": {
|
||||
"editor.tabSize": 4
|
||||
}
|
||||
},
|
||||
"bazel.buildifierFixOnFormat": true,
|
||||
"editor.formatOnSave": true,
|
||||
"editor.insertSpaces": true,
|
||||
"editor.tabSize": 2,
|
||||
"search.exclude": {
|
||||
"**/.yarn": true,
|
||||
"**/.pnp.*": true
|
||||
},
|
||||
"typescript.enablePromptUseWorkspaceTsdk": true,
|
||||
"typescript.preferences.importModuleSpecifier": "non-relative"
|
||||
}
|
||||
|
|
42
BUILD.bazel
42
BUILD.bazel
|
@ -1,42 +0,0 @@
|
|||
load("@bazel_gazelle//:def.bzl", "gazelle")
|
||||
load("@build_bazel_rules_nodejs//:index.bzl", "nodejs_binary")
|
||||
|
||||
# gazelle:prefix github.com/roleypoly/roleypoly
|
||||
# gazelle:exclude hack/**
|
||||
gazelle(name = "gazelle")
|
||||
|
||||
exports_files(
|
||||
["tsconfig.json"],
|
||||
visibility = ["//visibility:public"],
|
||||
)
|
||||
|
||||
filegroup(
|
||||
name = "node_modules",
|
||||
srcs = glob(
|
||||
include = [
|
||||
"node_modules/**/*.js",
|
||||
"node_modules/**/*.d.ts",
|
||||
"node_modules/**/*.json",
|
||||
"node_modules/.bin/*",
|
||||
],
|
||||
exclude = [
|
||||
# Files under test & docs may contain file names that
|
||||
# are not legal Bazel labels (e.g.,
|
||||
# node_modules/ecstatic/test/public/中文/檔案.html)
|
||||
"node_modules/**/test/**",
|
||||
"node_modules/**/docs/**",
|
||||
# Files with spaces in the name are not legal Bazel labels
|
||||
"node_modules/**/* */**",
|
||||
"node_modules/**/* *",
|
||||
],
|
||||
),
|
||||
)
|
||||
|
||||
# Create a tsc_wrapped compiler rule to use in the ts_library
|
||||
# compiler attribute when using self-managed dependencies
|
||||
nodejs_binary(
|
||||
name = "@bazel/typescript/tsc_wrapped",
|
||||
entry_point = "@npm//:node_modules/@bazel/typescript/internal/tsc_wrapped/tsc_wrapped.js",
|
||||
# Point bazel to your node_modules to find the entry point
|
||||
node_modules = "//:node_modules",
|
||||
)
|
109
README.md
109
README.md
|
@ -4,26 +4,117 @@ https://roleypoly.com
|
|||
|
||||
Tame your Discord roles.
|
||||
|
||||
### Need Help with Roleypoly?
|
||||
## Need Help with Roleypoly? 💁♀️
|
||||
|
||||
📚 [Please read through our community documentation.](https://github.com/roleypoly/community-docs)
|
||||
|
||||
😕 [Still confused? Talk to us on Discord!](https://discord.gg/PWQUVsd)
|
||||
|
||||
## Developing
|
||||
|
||||
Roleypoly is a distributed system built with Go, React, Terraform, and Bazel.
|
||||
Roleypoly is a distributed system built with TypeScript, React, Terraform, and Go.
|
||||
|
||||
This repo is currently being re-architected into a monorepo, so most processes might not be documented.
|
||||
This app is heavily edge computing-based with the backend being deployed via Cloudflare Workers, UI server on Google Cloud Run with 8 regions, and the mention responder in Google Compute Engine.
|
||||
|
||||
### Extra Development Docs
|
||||
|
||||
- 🏭 [Infrastructure](docs/infrastructure.md)
|
||||
- 🧾 [User Stories](docs/user-stories.md)
|
||||
|
||||
### Quickstart
|
||||
|
||||
This repo can be quickly setup with [VSCode Dev Containers](https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-containers) or [GitHub Codespaces](https://github.com/codespaces). This will setup a fully featured Docker container for developing VSCode, including extensions.
|
||||
#### Option 1 🚀: E2E Dockerized Emulation
|
||||
|
||||
If you'd like to not use either of those, a docker image can be built from `.devcontainers/Dockerfile`, or used normally via `docker.pkg.github.com/roleypoly/roleypoly/dev-container`. This use case is not actively investigated, but with tinkering, will work. Feel free to document this process and open a PR :)
|
||||
This is the fastest way to start. You must be using MacOS or Linux (WSL2 is ok!) for this to be successful.
|
||||
|
||||
### Things to Know
|
||||
- Setup `.env` using [`.env.example`][envexample] as a template and guide.
|
||||
- When setting up your Discord Application, be sure to set `http://localhost:6609/login-callback` as the OAuth2 callback URL.
|
||||
- Run: `yarn install`
|
||||
- Run: `docker-compose up`
|
||||
- This starts the UI and API servers in hot-reload dev/emulation mode. All changes to TS/TSX files should be properly captured and reloaded for you!
|
||||
- Develop you a Roleypoly!
|
||||
|
||||
Bazel can make some tasks far harder normal. Ideally, these are automated over.
|
||||
#### Option 2 🐱👤: Local Emulation
|
||||
|
||||
- **Updating `go.mod`?**
|
||||
- Run `hack/gazelle.sh` to regenerate `deps.bzl`.
|
||||
- With pre-requisites:
|
||||
- Node.js 14, Yarn
|
||||
- Setup `.env` using [`.env.example`][envexample] as a template and guide.
|
||||
- When setting up your Discord Application, be sure to set `http://localhost:6609/login-callback` as the OAuth2 callback URL.
|
||||
- Run: `yarn install`
|
||||
- Run: `yarn start`
|
||||
- This starts the Web UI, Storybook, and API servers in hot-reload dev/emulation mode. All changes to TS/TSX files should be properly captured and reloaded for you!
|
||||
- Develop you a Roleypoly!
|
||||
|
||||
#### Option 3 🐄🤠: Wrangler (No emulation)
|
||||
|
||||
**Outdated. This won't work, but could give you an idea of what to do.**
|
||||
|
||||
This is probably extremely painful and requires you to have a Cloudflare account.
|
||||
|
||||
- With pre-requisites:
|
||||
- Cloudflare Account
|
||||
- Node.js 14, Yarn
|
||||
- `npm i -g @cloudflare/wrangler`
|
||||
- Do `wrangler init`, `wrangler login`, etc...
|
||||
- Setup Wrangler for the project
|
||||
|
||||
- Change `account_id` to your Cloudflare Account ID in `wrangler.toml`
|
||||
- Add a `dev` environment to `wrangler.toml`, using [`.env.example`][envexample] as a reference for how values should be set
|
||||
|
||||
- When setting up your Discord Application, be sure to set `http://localhost:8787/login-callback` as the OAuth2 callback URL.
|
||||
|
||||
```toml
|
||||
[env.dev]
|
||||
|
||||
[env.dev.vars]
|
||||
BOT_CLIENT_ID = ...
|
||||
UI_PUBLIC_URI = "http://localhost:6601"
|
||||
API_PUBLIC_URI = "http://localhost:8787"
|
||||
ROOT_USERS = ...
|
||||
```
|
||||
|
||||
- `wrangler secret put BOT_TOKEN -e dev`
|
||||
- `wrangler secret put BOT_CLIENT_SECRET -e dev`
|
||||
- Setup KV Namespaces -- Please follow the instructions listed after the command runs.
|
||||
- `wrangler kvnamespace create -e dev KV_SESSIONS --preview`
|
||||
- `wrangler kvnamespace create -e dev KV_GUILD_DATA --preview`
|
||||
- `wrangler kvnamespace create -e dev KV_GUILDS --preview`
|
||||
|
||||
- Setup `.env` using [`.env.example`][envexample] as a template and guide.
|
||||
- Run `yarn install`
|
||||
- Run both `wrangler dev -e dev` and `yarn start:web`
|
||||
- This starts the Web UI and API servers in hot-reload dev mode. All changes to TS/TSX files should be properly captured and reloaded for you!
|
||||
- Develop you a Roleypoly
|
||||
- And get a beer or heated plant because oh no.
|
||||
|
||||
### Developing Design System Components
|
||||
|
||||
For working with the [Roleypoly Design System](https://ui.roleypoly.com), use the below steps as reference. Code lives in `src/design-system` among elsewhere.
|
||||
|
||||
Run:
|
||||
|
||||
- `yarn` to install deps
|
||||
- `yarn start:design-system` to open storybook
|
||||
- `yarn test:design-system` to test
|
||||
|
||||
### Developing Web UI
|
||||
|
||||
For working with the Next.js frontend components, use the below steps as reference. Code lives in `src/web` among elsewhere.
|
||||
|
||||
Run:
|
||||
|
||||
- `yarn` to install deps
|
||||
- `yarn start:web` to run Next.js dev server
|
||||
- `yarn test:web` to test
|
||||
|
||||
### Developing API Components
|
||||
|
||||
For working with the API, use the below steps as reference. Code lives in `src/api`.
|
||||
|
||||
Run:
|
||||
|
||||
- `yarn` to install deps
|
||||
- `yarn start:api` to start an emulated worker
|
||||
- `yarn test:api` to test
|
||||
|
||||
[envexample]: .env.example
|
||||
|
|
100
WORKSPACE
100
WORKSPACE
|
@ -1,100 +0,0 @@
|
|||
workspace(
|
||||
name = "roleypoly",
|
||||
managed_directories = {
|
||||
"@npm": ["node_modules"],
|
||||
},
|
||||
)
|
||||
|
||||
### BAZEL
|
||||
load("@bazel_tools//tools/build_defs/repo:http.bzl", "http_archive")
|
||||
|
||||
http_archive(
|
||||
name = "io_bazel_rules_go",
|
||||
sha256 = "08c3cd71857d58af3cda759112437d9e63339ac9c6e0042add43f4d94caf632d",
|
||||
urls = [
|
||||
"https://mirror.bazel.build/github.com/bazelbuild/rules_go/releases/download/v0.24.2/rules_go-v0.24.2.tar.gz",
|
||||
"https://github.com/bazelbuild/rules_go/releases/download/v0.24.2/rules_go-v0.24.2.tar.gz",
|
||||
],
|
||||
)
|
||||
|
||||
http_archive(
|
||||
name = "bazel_gazelle",
|
||||
sha256 = "d4113967ab451dd4d2d767c3ca5f927fec4b30f3b2c6f8135a2033b9c05a5687",
|
||||
urls = [
|
||||
"https://mirror.bazel.build/github.com/bazelbuild/bazel-gazelle/releases/download/v0.22.0/bazel-gazelle-v0.22.0.tar.gz",
|
||||
"https://github.com/bazelbuild/bazel-gazelle/releases/download/v0.22.0/bazel-gazelle-v0.22.0.tar.gz",
|
||||
],
|
||||
)
|
||||
|
||||
http_archive(
|
||||
name = "build_bazel_rules_nodejs",
|
||||
sha256 = "b16a03bf63952ae436185c74a5c63bec03c010ed422e230db526af55441a02dd",
|
||||
urls = ["https://github.com/bazelbuild/rules_nodejs/releases/download/2.1.0/rules_nodejs-2.1.0.tar.gz"],
|
||||
)
|
||||
|
||||
http_archive(
|
||||
name = "io_bazel_rules_docker",
|
||||
sha256 = "4521794f0fba2e20f3bf15846ab5e01d5332e587e9ce81629c7f96c793bb7036",
|
||||
strip_prefix = "rules_docker-0.14.4",
|
||||
urls = ["https://github.com/bazelbuild/rules_docker/releases/download/v0.14.4/rules_docker-v0.14.4.tar.gz"],
|
||||
)
|
||||
|
||||
### GO
|
||||
load("@io_bazel_rules_go//go:deps.bzl", "go_register_toolchains", "go_rules_dependencies")
|
||||
load("@bazel_gazelle//:deps.bzl", "gazelle_dependencies")
|
||||
load("//:deps.bzl", "go_repositories")
|
||||
|
||||
# gazelle:repository_macro deps.bzl%go_repositories
|
||||
go_repositories()
|
||||
|
||||
go_rules_dependencies()
|
||||
|
||||
go_register_toolchains()
|
||||
|
||||
gazelle_dependencies()
|
||||
|
||||
### NODE
|
||||
load("@build_bazel_rules_nodejs//:index.bzl", "node_repositories")
|
||||
|
||||
node_repositories(
|
||||
package_json = ["//:package.json"],
|
||||
)
|
||||
|
||||
### DOCKER/CONTAINER
|
||||
load(
|
||||
"@io_bazel_rules_docker//repositories:repositories.bzl",
|
||||
container_repositories = "repositories",
|
||||
)
|
||||
|
||||
container_repositories()
|
||||
|
||||
load(
|
||||
"@io_bazel_rules_docker//go:image.bzl",
|
||||
_go_image_repos = "repositories",
|
||||
)
|
||||
|
||||
_go_image_repos()
|
||||
|
||||
load("@io_bazel_rules_docker//repositories:deps.bzl", container_deps = "deps")
|
||||
|
||||
container_deps()
|
||||
|
||||
load("@io_bazel_rules_docker//repositories:pip_repositories.bzl", "pip_deps")
|
||||
|
||||
pip_deps()
|
||||
|
||||
# Dev Container stuff
|
||||
load("@io_bazel_rules_docker//contrib:dockerfile_build.bzl", "dockerfile_image")
|
||||
load("@io_bazel_rules_docker//container:container.bzl", "container_pull")
|
||||
|
||||
container_pull(
|
||||
name = "devcontainergo",
|
||||
registry = "mcr.microsoft.com",
|
||||
repository = "vscode/devcontainers/go",
|
||||
tag = "1.15",
|
||||
)
|
||||
|
||||
dockerfile_image(
|
||||
name = "dev-container",
|
||||
dockerfile = "//.devcontainer:Dockerfile",
|
||||
)
|
813
deps.bzl
813
deps.bzl
|
@ -1,813 +0,0 @@
|
|||
load("@bazel_gazelle//:deps.bzl", "go_repository")
|
||||
|
||||
def go_repositories():
|
||||
go_repository(
|
||||
name = "co_honnef_go_tools",
|
||||
importpath = "honnef.co/go/tools",
|
||||
sum = "h1:3JgtbtFHMiCmsznwGVTUWbgGov+pVqnlf1dEJTNAXeM=",
|
||||
version = "v0.0.1-2019.2.3",
|
||||
)
|
||||
go_repository(
|
||||
name = "com_github_alecthomas_template",
|
||||
importpath = "github.com/alecthomas/template",
|
||||
sum = "h1:cAKDfWh5VpdgMhJosfJnn5/FoN2SRZ4p7fJNX58YPaU=",
|
||||
version = "v0.0.0-20160405071501-a0175ee3bccc",
|
||||
)
|
||||
go_repository(
|
||||
name = "com_github_alecthomas_units",
|
||||
importpath = "github.com/alecthomas/units",
|
||||
sum = "h1:qet1QNfXsQxTZqLG4oE62mJzwPIB8+Tee4RNCL9ulrY=",
|
||||
version = "v0.0.0-20151022065526-2efee857e7cf",
|
||||
)
|
||||
go_repository(
|
||||
name = "com_github_armon_consul_api",
|
||||
importpath = "github.com/armon/consul-api",
|
||||
sum = "h1:G1bPvciwNyF7IUmKXNt9Ak3m6u9DE1rF+RmtIkBpVdA=",
|
||||
version = "v0.0.0-20180202201655-eb2c6b5be1b6",
|
||||
)
|
||||
go_repository(
|
||||
name = "com_github_beorn7_perks",
|
||||
importpath = "github.com/beorn7/perks",
|
||||
sum = "h1:HWo1m869IqiPhD389kmkxeTalrjNbbJTC8LXupb+sl0=",
|
||||
version = "v1.0.0",
|
||||
)
|
||||
go_repository(
|
||||
name = "com_github_burntsushi_toml",
|
||||
importpath = "github.com/BurntSushi/toml",
|
||||
sum = "h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ=",
|
||||
version = "v0.3.1",
|
||||
)
|
||||
go_repository(
|
||||
name = "com_github_bwmarrin_discordgo",
|
||||
importpath = "github.com/bwmarrin/discordgo",
|
||||
sum = "h1:uBxY1HmlVCsW1IuaPjpCGT6A2DBwRn0nvOguQIxDdFM=",
|
||||
version = "v0.22.0",
|
||||
)
|
||||
go_repository(
|
||||
name = "com_github_cespare_xxhash",
|
||||
importpath = "github.com/cespare/xxhash",
|
||||
sum = "h1:a6HrQnmkObjyL+Gs60czilIUGqrzKutQD6XZog3p+ko=",
|
||||
version = "v1.1.0",
|
||||
)
|
||||
go_repository(
|
||||
name = "com_github_client9_misspell",
|
||||
importpath = "github.com/client9/misspell",
|
||||
sum = "h1:ta993UF76GwbvJcIo3Y68y/M3WxlpEHPWIGDkJYwzJI=",
|
||||
version = "v0.3.4",
|
||||
)
|
||||
go_repository(
|
||||
name = "com_github_coreos_bbolt",
|
||||
importpath = "github.com/coreos/bbolt",
|
||||
sum = "h1:wZwiHHUieZCquLkDL0B8UhzreNWsPHooDAG3q34zk0s=",
|
||||
version = "v1.3.2",
|
||||
)
|
||||
go_repository(
|
||||
name = "com_github_coreos_etcd",
|
||||
importpath = "github.com/coreos/etcd",
|
||||
sum = "h1:jFneRYjIvLMLhDLCzuTuU4rSJUjRplcJQ7pD7MnhC04=",
|
||||
version = "v3.3.10+incompatible",
|
||||
)
|
||||
go_repository(
|
||||
name = "com_github_coreos_go_semver",
|
||||
importpath = "github.com/coreos/go-semver",
|
||||
sum = "h1:3Jm3tLmsgAYcjC+4Up7hJrFBPr+n7rAqYeSw/SZazuY=",
|
||||
version = "v0.2.0",
|
||||
)
|
||||
go_repository(
|
||||
name = "com_github_coreos_go_systemd",
|
||||
importpath = "github.com/coreos/go-systemd",
|
||||
sum = "h1:Wf6HqHfScWJN9/ZjdUKyjop4mf3Qdd+1TvvltAvM3m8=",
|
||||
version = "v0.0.0-20190321100706-95778dfbb74e",
|
||||
)
|
||||
go_repository(
|
||||
name = "com_github_coreos_pkg",
|
||||
importpath = "github.com/coreos/pkg",
|
||||
sum = "h1:lBNOc5arjvs8E5mO2tbpBpLoyyu8B6e44T7hJy6potg=",
|
||||
version = "v0.0.0-20180928190104-399ea9e2e55f",
|
||||
)
|
||||
go_repository(
|
||||
name = "com_github_cpuguy83_go_md2man_v2",
|
||||
importpath = "github.com/cpuguy83/go-md2man/v2",
|
||||
sum = "h1:EoUDS0afbrsXAZ9YQ9jdu/mZ2sXgT1/2yyNng4PGlyM=",
|
||||
version = "v2.0.0",
|
||||
)
|
||||
go_repository(
|
||||
name = "com_github_data_dog_go_sqlmock",
|
||||
importpath = "github.com/DATA-DOG/go-sqlmock",
|
||||
sum = "h1:Shsta01QNfFxHCfpW6YH2STWB0MudeXXEWMr20OEh60=",
|
||||
version = "v1.5.0",
|
||||
)
|
||||
go_repository(
|
||||
name = "com_github_davecgh_go_spew",
|
||||
importpath = "github.com/davecgh/go-spew",
|
||||
sum = "h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=",
|
||||
version = "v1.1.1",
|
||||
)
|
||||
go_repository(
|
||||
name = "com_github_dghubble_trie",
|
||||
importpath = "github.com/dghubble/trie",
|
||||
sum = "h1:euE/xPG0HIg6XiNXYrAHxX9aVwD1gw/yM2kptLOOj6k=",
|
||||
version = "v0.0.0-20200716043226-5a94efb202d5",
|
||||
)
|
||||
go_repository(
|
||||
name = "com_github_dgrijalva_jwt_go",
|
||||
importpath = "github.com/dgrijalva/jwt-go",
|
||||
sum = "h1:7qlOGliEKZXTDg6OTjfoBKDXWrumCAMpl/TFQ4/5kLM=",
|
||||
version = "v3.2.0+incompatible",
|
||||
)
|
||||
go_repository(
|
||||
name = "com_github_dgryski_go_sip13",
|
||||
importpath = "github.com/dgryski/go-sip13",
|
||||
sum = "h1:RMLoZVzv4GliuWafOuPuQDKSm1SJph7uCRnnS61JAn4=",
|
||||
version = "v0.0.0-20181026042036-e10d5fee7954",
|
||||
)
|
||||
go_repository(
|
||||
name = "com_github_facebook_ent",
|
||||
importpath = "github.com/facebook/ent",
|
||||
sum = "h1:ds9HENceKzpGBgCRlkZNq6TqBIegwKcF3e5reuV9Z0M=",
|
||||
version = "v0.4.3",
|
||||
)
|
||||
go_repository(
|
||||
name = "com_github_fsnotify_fsnotify",
|
||||
importpath = "github.com/fsnotify/fsnotify",
|
||||
sum = "h1:IXs+QLmnXW2CcXuY+8Mzv/fWEsPGWxqefPtCP5CnV9I=",
|
||||
version = "v1.4.7",
|
||||
)
|
||||
go_repository(
|
||||
name = "com_github_ghodss_yaml",
|
||||
importpath = "github.com/ghodss/yaml",
|
||||
sum = "h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk=",
|
||||
version = "v1.0.0",
|
||||
)
|
||||
go_repository(
|
||||
name = "com_github_go_bindata_go_bindata",
|
||||
importpath = "github.com/go-bindata/go-bindata",
|
||||
sum = "h1:WNHfSP1q2vuAa9vF54RrhCl4nqxCjVcXhlbsRXbGOSY=",
|
||||
version = "v1.0.1-0.20190711162640-ee3c2418e368",
|
||||
)
|
||||
go_repository(
|
||||
name = "com_github_go_kit_kit",
|
||||
importpath = "github.com/go-kit/kit",
|
||||
sum = "h1:Wz+5lgoB0kkuqLEc6NVmwRknTKP6dTGbSqvhZtBI/j0=",
|
||||
version = "v0.8.0",
|
||||
)
|
||||
go_repository(
|
||||
name = "com_github_go_logfmt_logfmt",
|
||||
importpath = "github.com/go-logfmt/logfmt",
|
||||
sum = "h1:MP4Eh7ZCb31lleYCFuwm0oe4/YGak+5l1vA2NOE80nA=",
|
||||
version = "v0.4.0",
|
||||
)
|
||||
go_repository(
|
||||
name = "com_github_go_logr_logr",
|
||||
importpath = "github.com/go-logr/logr",
|
||||
sum = "h1:M1Tv3VzNlEHg6uyACnRdtrploV2P7wZqH8BoQMtz0cg=",
|
||||
version = "v0.1.0",
|
||||
)
|
||||
go_repository(
|
||||
name = "com_github_go_openapi_inflect",
|
||||
importpath = "github.com/go-openapi/inflect",
|
||||
sum = "h1:9jCH9scKIbHeV9m12SmPilScz6krDxKRasNNSNPXu/4=",
|
||||
version = "v0.19.0",
|
||||
)
|
||||
go_repository(
|
||||
name = "com_github_go_sql_driver_mysql",
|
||||
importpath = "github.com/go-sql-driver/mysql",
|
||||
sum = "h1:L6V0ANsMIMdLgXly241UXhXNFWYgXbgjHupTAAURrV0=",
|
||||
version = "v1.5.1-0.20200311113236-681ffa848bae",
|
||||
)
|
||||
go_repository(
|
||||
name = "com_github_go_stack_stack",
|
||||
importpath = "github.com/go-stack/stack",
|
||||
sum = "h1:5SgMzNM5HxrEjV0ww2lTmX6E2Izsfxas4+YHWRs3Lsk=",
|
||||
version = "v1.8.0",
|
||||
)
|
||||
go_repository(
|
||||
name = "com_github_gogo_protobuf",
|
||||
importpath = "github.com/gogo/protobuf",
|
||||
sum = "h1:/s5zKNz0uPFCZ5hddgPdo2TK2TVrUNMn0OOX8/aZMTE=",
|
||||
version = "v1.2.1",
|
||||
)
|
||||
go_repository(
|
||||
name = "com_github_golang_glog",
|
||||
importpath = "github.com/golang/glog",
|
||||
sum = "h1:VKtxabqXZkF25pY9ekfRL6a582T4P37/31XEstQ5p58=",
|
||||
version = "v0.0.0-20160126235308-23def4e6c14b",
|
||||
)
|
||||
go_repository(
|
||||
name = "com_github_golang_groupcache",
|
||||
importpath = "github.com/golang/groupcache",
|
||||
sum = "h1:1r7pUrabqp18hOBcwBwiTsbnFeTZHV9eER/QT5JVZxY=",
|
||||
version = "v0.0.0-20200121045136-8c9f03a8e57e",
|
||||
)
|
||||
go_repository(
|
||||
name = "com_github_golang_mock",
|
||||
importpath = "github.com/golang/mock",
|
||||
sum = "h1:G5FRp8JnTd7RQH5kemVNlMeyXQAztQ3mOWV95KxsXH8=",
|
||||
version = "v1.1.1",
|
||||
)
|
||||
go_repository(
|
||||
name = "com_github_golang_protobuf",
|
||||
importpath = "github.com/golang/protobuf",
|
||||
sum = "h1:6nsPYzhq5kReh6QImI3k5qWzO4PEbvbIW2cwSfR/6xs=",
|
||||
version = "v1.3.2",
|
||||
)
|
||||
go_repository(
|
||||
name = "com_github_google_btree",
|
||||
importpath = "github.com/google/btree",
|
||||
sum = "h1:0udJVsspx3VBr5FwtLhQQtuAsVc79tTq0ocGIPAU6qo=",
|
||||
version = "v1.0.0",
|
||||
)
|
||||
go_repository(
|
||||
name = "com_github_google_go_cmp",
|
||||
importpath = "github.com/google/go-cmp",
|
||||
sum = "h1:crn/baboCvb5fXaQ0IJ1SGTsTVrWpDsCWC8EGETZijY=",
|
||||
version = "v0.3.0",
|
||||
)
|
||||
go_repository(
|
||||
name = "com_github_google_go_github_v32",
|
||||
importpath = "github.com/google/go-github/v32",
|
||||
sum = "h1:GWkQOdXqviCPx7Q7Fj+KyPoGm4SwHRh8rheoPhd27II=",
|
||||
version = "v32.1.0",
|
||||
)
|
||||
go_repository(
|
||||
name = "com_github_google_go_querystring",
|
||||
importpath = "github.com/google/go-querystring",
|
||||
sum = "h1:Xkwi/a1rcvNg1PPYe5vI8GbeBY/jrVuDX5ASuANWTrk=",
|
||||
version = "v1.0.0",
|
||||
)
|
||||
go_repository(
|
||||
name = "com_github_google_gofuzz",
|
||||
importpath = "github.com/google/gofuzz",
|
||||
sum = "h1:A8PeW59pxE9IoFRqBp37U+mSNaQoZ46F1f0f863XSXw=",
|
||||
version = "v1.0.0",
|
||||
)
|
||||
go_repository(
|
||||
name = "com_github_google_renameio",
|
||||
importpath = "github.com/google/renameio",
|
||||
sum = "h1:GOZbcHa3HfsPKPlmyPyN2KEohoMXOhdMbHrvbpl2QaA=",
|
||||
version = "v0.1.0",
|
||||
)
|
||||
go_repository(
|
||||
name = "com_github_google_uuid",
|
||||
importpath = "github.com/google/uuid",
|
||||
sum = "h1:EVhdT+1Kseyi1/pUmXKaFxYsDNy9RQYkMWRH68J/W7Y=",
|
||||
version = "v1.1.2",
|
||||
)
|
||||
go_repository(
|
||||
name = "com_github_gorilla_websocket",
|
||||
importpath = "github.com/gorilla/websocket",
|
||||
sum = "h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc=",
|
||||
version = "v1.4.2",
|
||||
)
|
||||
go_repository(
|
||||
name = "com_github_grpc_ecosystem_go_grpc_middleware",
|
||||
importpath = "github.com/grpc-ecosystem/go-grpc-middleware",
|
||||
sum = "h1:Iju5GlWwrvL6UBg4zJJt3btmonfrMlCDdsejg4CZE7c=",
|
||||
version = "v1.0.0",
|
||||
)
|
||||
go_repository(
|
||||
name = "com_github_grpc_ecosystem_go_grpc_prometheus",
|
||||
importpath = "github.com/grpc-ecosystem/go-grpc-prometheus",
|
||||
sum = "h1:Ovs26xHkKqVztRpIrF/92BcuyuQ/YW4NSIpoGtfXNho=",
|
||||
version = "v1.2.0",
|
||||
)
|
||||
go_repository(
|
||||
name = "com_github_grpc_ecosystem_grpc_gateway",
|
||||
importpath = "github.com/grpc-ecosystem/grpc-gateway",
|
||||
sum = "h1:bM6ZAFZmc/wPFaRDi0d5L7hGEZEx/2u+Tmr2evNHDiI=",
|
||||
version = "v1.9.0",
|
||||
)
|
||||
go_repository(
|
||||
name = "com_github_hashicorp_hcl",
|
||||
importpath = "github.com/hashicorp/hcl",
|
||||
sum = "h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=",
|
||||
version = "v1.0.0",
|
||||
)
|
||||
go_repository(
|
||||
name = "com_github_inconshreveable_mousetrap",
|
||||
importpath = "github.com/inconshreveable/mousetrap",
|
||||
sum = "h1:Z8tu5sraLXCXIcARxBp/8cbvlwVa7Z1NHg9XEKhtSvM=",
|
||||
version = "v1.0.0",
|
||||
)
|
||||
go_repository(
|
||||
name = "com_github_jessevdk_go_flags",
|
||||
importpath = "github.com/jessevdk/go-flags",
|
||||
sum = "h1:4IU2WS7AumrZ/40jfhf4QVDMsQwqA7VEHozFRrGARJA=",
|
||||
version = "v1.4.0",
|
||||
)
|
||||
go_repository(
|
||||
name = "com_github_joho_godotenv",
|
||||
importpath = "github.com/joho/godotenv",
|
||||
sum = "h1:Zjp+RcGpHhGlrMbJzXTrZZPrWj+1vfm90La1wgB6Bhc=",
|
||||
version = "v1.3.0",
|
||||
)
|
||||
go_repository(
|
||||
name = "com_github_jonboulle_clockwork",
|
||||
importpath = "github.com/jonboulle/clockwork",
|
||||
sum = "h1:VKV+ZcuP6l3yW9doeqz6ziZGgcynBVQO+obU0+0hcPo=",
|
||||
version = "v0.1.0",
|
||||
)
|
||||
go_repository(
|
||||
name = "com_github_json_iterator_go",
|
||||
importpath = "github.com/json-iterator/go",
|
||||
sum = "h1:Kz6Cvnvv2wGdaG/V8yMvfkmNiXq9Ya2KUv4rouJJr68=",
|
||||
version = "v1.1.10",
|
||||
)
|
||||
go_repository(
|
||||
name = "com_github_julienschmidt_httprouter",
|
||||
importpath = "github.com/julienschmidt/httprouter",
|
||||
sum = "h1:U0609e9tgbseu3rBINet9P48AI/D3oJs4dN7jwJOQ1U=",
|
||||
version = "v1.3.0",
|
||||
)
|
||||
go_repository(
|
||||
name = "com_github_kisielk_errcheck",
|
||||
importpath = "github.com/kisielk/errcheck",
|
||||
sum = "h1:reN85Pxc5larApoH1keMBiu2GWtPqXQ1nc9gx+jOU+E=",
|
||||
version = "v1.2.0",
|
||||
)
|
||||
go_repository(
|
||||
name = "com_github_kisielk_gotool",
|
||||
importpath = "github.com/kisielk/gotool",
|
||||
sum = "h1:AV2c/EiW3KqPNT9ZKl07ehoAGi4C5/01Cfbblndcapg=",
|
||||
version = "v1.0.0",
|
||||
)
|
||||
go_repository(
|
||||
name = "com_github_konsorten_go_windows_terminal_sequences",
|
||||
importpath = "github.com/konsorten/go-windows-terminal-sequences",
|
||||
sum = "h1:mweAR1A6xJ3oS2pRaGiHgQ4OO8tzTaLawm8vnODuwDk=",
|
||||
version = "v1.0.1",
|
||||
)
|
||||
go_repository(
|
||||
name = "com_github_kr_logfmt",
|
||||
importpath = "github.com/kr/logfmt",
|
||||
sum = "h1:T+h1c/A9Gawja4Y9mFVWj2vyii2bbUNDw3kt9VxK2EY=",
|
||||
version = "v0.0.0-20140226030751-b84e30acd515",
|
||||
)
|
||||
go_repository(
|
||||
name = "com_github_kr_pretty",
|
||||
importpath = "github.com/kr/pretty",
|
||||
sum = "h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=",
|
||||
version = "v0.1.0",
|
||||
)
|
||||
go_repository(
|
||||
name = "com_github_kr_pty",
|
||||
importpath = "github.com/kr/pty",
|
||||
sum = "h1:VkoXIwSboBpnk99O/KFauAEILuNHv5DVFKZMBN/gUgw=",
|
||||
version = "v1.1.1",
|
||||
)
|
||||
go_repository(
|
||||
name = "com_github_kr_text",
|
||||
importpath = "github.com/kr/text",
|
||||
sum = "h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=",
|
||||
version = "v0.1.0",
|
||||
)
|
||||
go_repository(
|
||||
name = "com_github_lampjaw_discordclient",
|
||||
importpath = "github.com/lampjaw/discordclient",
|
||||
sum = "h1:Y2o9fEOoAYjCw8IDyxUVaBq44AUbOLyPnYSPpM6Ef3M=",
|
||||
version = "v0.0.0-20200923011548-6558fc9e89df",
|
||||
)
|
||||
go_repository(
|
||||
name = "com_github_lib_pq",
|
||||
importpath = "github.com/lib/pq",
|
||||
sum = "h1:9xohqzkUwzR4Ga4ivdTcawVS89YSDVxXMa3xJX3cGzg=",
|
||||
version = "v1.8.0",
|
||||
)
|
||||
go_repository(
|
||||
name = "com_github_magiconair_properties",
|
||||
importpath = "github.com/magiconair/properties",
|
||||
sum = "h1:LLgXmsheXeRoUOBOjtwPQCWIYqM/LU1ayDtDePerRcY=",
|
||||
version = "v1.8.0",
|
||||
)
|
||||
go_repository(
|
||||
name = "com_github_mattn_go_runewidth",
|
||||
importpath = "github.com/mattn/go-runewidth",
|
||||
sum = "h1:Lm995f3rfxdpd6TSmuVCHVb/QhupuXlYr8sCI/QdE+0=",
|
||||
version = "v0.0.9",
|
||||
)
|
||||
go_repository(
|
||||
name = "com_github_mattn_go_sqlite3",
|
||||
importpath = "github.com/mattn/go-sqlite3",
|
||||
sum = "h1:j7a/xn1U6TKA/PHHxqZuzh64CdtRc7rU9M+AvkOl5bA=",
|
||||
version = "v1.14.3",
|
||||
)
|
||||
go_repository(
|
||||
name = "com_github_matttproud_golang_protobuf_extensions",
|
||||
importpath = "github.com/matttproud/golang_protobuf_extensions",
|
||||
sum = "h1:4hp9jkHxhMHkqkrB3Ix0jegS5sx/RkqARlsWZ6pIwiU=",
|
||||
version = "v1.0.1",
|
||||
)
|
||||
go_repository(
|
||||
name = "com_github_mitchellh_go_homedir",
|
||||
importpath = "github.com/mitchellh/go-homedir",
|
||||
sum = "h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y=",
|
||||
version = "v1.1.0",
|
||||
)
|
||||
go_repository(
|
||||
name = "com_github_mitchellh_mapstructure",
|
||||
importpath = "github.com/mitchellh/mapstructure",
|
||||
sum = "h1:SzB1nHZ2Xi+17FP0zVQBHIZqvwRN9408fJO8h+eeNA8=",
|
||||
version = "v1.3.3",
|
||||
)
|
||||
go_repository(
|
||||
name = "com_github_modern_go_concurrent",
|
||||
importpath = "github.com/modern-go/concurrent",
|
||||
sum = "h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=",
|
||||
version = "v0.0.0-20180306012644-bacd9c7ef1dd",
|
||||
)
|
||||
go_repository(
|
||||
name = "com_github_modern_go_reflect2",
|
||||
importpath = "github.com/modern-go/reflect2",
|
||||
sum = "h1:9f412s+6RmYXLWZSEzVVgPGK7C2PphHj5RJrvfx9AWI=",
|
||||
version = "v1.0.1",
|
||||
)
|
||||
go_repository(
|
||||
name = "com_github_mwitkow_go_conntrack",
|
||||
importpath = "github.com/mwitkow/go-conntrack",
|
||||
sum = "h1:F9x/1yl3T2AeKLr2AMdilSD8+f9bvMnNN8VS5iDtovc=",
|
||||
version = "v0.0.0-20161129095857-cc309e4a2223",
|
||||
)
|
||||
go_repository(
|
||||
name = "com_github_oklog_ulid",
|
||||
importpath = "github.com/oklog/ulid",
|
||||
sum = "h1:EGfNDEx6MqHz8B3uNV6QAib1UR2Lm97sHi3ocA6ESJ4=",
|
||||
version = "v1.3.1",
|
||||
)
|
||||
go_repository(
|
||||
name = "com_github_olekukonko_tablewriter",
|
||||
importpath = "github.com/olekukonko/tablewriter",
|
||||
sum = "h1:vHD/YYe1Wolo78koG299f7V/VAS08c6IpCLn+Ejf/w8=",
|
||||
version = "v0.0.4",
|
||||
)
|
||||
go_repository(
|
||||
name = "com_github_oneofone_xxhash",
|
||||
importpath = "github.com/OneOfOne/xxhash",
|
||||
sum = "h1:KMrpdQIwFcEqXDklaen+P1axHaj9BSKzvpUUfnHldSE=",
|
||||
version = "v1.2.2",
|
||||
)
|
||||
go_repository(
|
||||
name = "com_github_pelletier_go_toml",
|
||||
importpath = "github.com/pelletier/go-toml",
|
||||
sum = "h1:T5zMGML61Wp+FlcbWjRDT7yAxhJNAiPPLOFECq181zc=",
|
||||
version = "v1.2.0",
|
||||
)
|
||||
go_repository(
|
||||
name = "com_github_pkg_errors",
|
||||
importpath = "github.com/pkg/errors",
|
||||
sum = "h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=",
|
||||
version = "v0.9.1",
|
||||
)
|
||||
go_repository(
|
||||
name = "com_github_pmezard_go_difflib",
|
||||
importpath = "github.com/pmezard/go-difflib",
|
||||
sum = "h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=",
|
||||
version = "v1.0.0",
|
||||
)
|
||||
go_repository(
|
||||
name = "com_github_prometheus_client_golang",
|
||||
importpath = "github.com/prometheus/client_golang",
|
||||
sum = "h1:9iH4JKXLzFbOAdtqv/a+j8aewx2Y8lAjAydhbaScPF8=",
|
||||
version = "v0.9.3",
|
||||
)
|
||||
go_repository(
|
||||
name = "com_github_prometheus_client_model",
|
||||
importpath = "github.com/prometheus/client_model",
|
||||
sum = "h1:S/YWwWx/RA8rT8tKFRuGUZhuA90OyIBpPCXkcbwU8DE=",
|
||||
version = "v0.0.0-20190129233127-fd36f4220a90",
|
||||
)
|
||||
go_repository(
|
||||
name = "com_github_prometheus_common",
|
||||
importpath = "github.com/prometheus/common",
|
||||
sum = "h1:7etb9YClo3a6HjLzfl6rIQaU+FDfi0VSX39io3aQ+DM=",
|
||||
version = "v0.4.0",
|
||||
)
|
||||
go_repository(
|
||||
name = "com_github_prometheus_procfs",
|
||||
importpath = "github.com/prometheus/procfs",
|
||||
sum = "h1:sofwID9zm4tzrgykg80hfFph1mryUeLRsUfoocVVmRY=",
|
||||
version = "v0.0.0-20190507164030-5867b95ac084",
|
||||
)
|
||||
go_repository(
|
||||
name = "com_github_prometheus_tsdb",
|
||||
importpath = "github.com/prometheus/tsdb",
|
||||
sum = "h1:YZcsG11NqnK4czYLrWd9mpEuAJIHVQLwdrleYfszMAA=",
|
||||
version = "v0.7.1",
|
||||
)
|
||||
go_repository(
|
||||
name = "com_github_rogpeppe_fastuuid",
|
||||
importpath = "github.com/rogpeppe/fastuuid",
|
||||
sum = "h1:gu+uRPtBe88sKxUCEXRoeCvVG90TJmwhiqRpvdhQFng=",
|
||||
version = "v0.0.0-20150106093220-6724a57986af",
|
||||
)
|
||||
go_repository(
|
||||
name = "com_github_rogpeppe_go_internal",
|
||||
importpath = "github.com/rogpeppe/go-internal",
|
||||
sum = "h1:RR9dF3JtopPvtkroDZuVD7qquD0bnHlKSqaQhgwt8yk=",
|
||||
version = "v1.3.0",
|
||||
)
|
||||
go_repository(
|
||||
name = "com_github_russross_blackfriday_v2",
|
||||
importpath = "github.com/russross/blackfriday/v2",
|
||||
sum = "h1:lPqVAte+HuHNfhJ/0LC98ESWRz8afy9tM/0RK8m9o+Q=",
|
||||
version = "v2.0.1",
|
||||
)
|
||||
go_repository(
|
||||
name = "com_github_segmentio_ksuid",
|
||||
importpath = "github.com/segmentio/ksuid",
|
||||
sum = "h1:FoResxvleQwYiPAVKe1tMUlEirodZqlqglIuFsdDntY=",
|
||||
version = "v1.0.3",
|
||||
)
|
||||
go_repository(
|
||||
name = "com_github_shurcool_sanitized_anchor_name",
|
||||
importpath = "github.com/shurcooL/sanitized_anchor_name",
|
||||
sum = "h1:PdmoCO6wvbs+7yrJyMORt4/BmY5IYyJwS/kOiWx8mHo=",
|
||||
version = "v1.0.0",
|
||||
)
|
||||
go_repository(
|
||||
name = "com_github_sirupsen_logrus",
|
||||
importpath = "github.com/sirupsen/logrus",
|
||||
sum = "h1:juTguoYk5qI21pwyTXY3B3Y5cOTH3ZUyZCg1v/mihuo=",
|
||||
version = "v1.2.0",
|
||||
)
|
||||
go_repository(
|
||||
name = "com_github_soheilhy_cmux",
|
||||
importpath = "github.com/soheilhy/cmux",
|
||||
sum = "h1:0HKaf1o97UwFjHH9o5XsHUOF+tqmdA7KEzXLpiyaw0E=",
|
||||
version = "v0.1.4",
|
||||
)
|
||||
go_repository(
|
||||
name = "com_github_spaolacci_murmur3",
|
||||
importpath = "github.com/spaolacci/murmur3",
|
||||
sum = "h1:qLC7fQah7D6K1B0ujays3HV9gkFtllcxhzImRR7ArPQ=",
|
||||
version = "v0.0.0-20180118202830-f09979ecbc72",
|
||||
)
|
||||
go_repository(
|
||||
name = "com_github_spf13_afero",
|
||||
importpath = "github.com/spf13/afero",
|
||||
sum = "h1:m8/z1t7/fwjysjQRYbP0RD+bUIF/8tJwPdEZsI83ACI=",
|
||||
version = "v1.1.2",
|
||||
)
|
||||
go_repository(
|
||||
name = "com_github_spf13_cast",
|
||||
importpath = "github.com/spf13/cast",
|
||||
sum = "h1:oget//CVOEoFewqQxwr0Ej5yjygnqGkvggSE/gB35Q8=",
|
||||
version = "v1.3.0",
|
||||
)
|
||||
go_repository(
|
||||
name = "com_github_spf13_cobra",
|
||||
importpath = "github.com/spf13/cobra",
|
||||
sum = "h1:6m/oheQuQ13N9ks4hubMG6BnvwOeaJrqSPLahSnczz8=",
|
||||
version = "v1.0.0",
|
||||
)
|
||||
go_repository(
|
||||
name = "com_github_spf13_jwalterweatherman",
|
||||
importpath = "github.com/spf13/jwalterweatherman",
|
||||
sum = "h1:XHEdyB+EcvlqZamSM4ZOMGlc93t6AcsBEu9Gc1vn7yk=",
|
||||
version = "v1.0.0",
|
||||
)
|
||||
go_repository(
|
||||
name = "com_github_spf13_pflag",
|
||||
importpath = "github.com/spf13/pflag",
|
||||
sum = "h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=",
|
||||
version = "v1.0.5",
|
||||
)
|
||||
go_repository(
|
||||
name = "com_github_spf13_viper",
|
||||
importpath = "github.com/spf13/viper",
|
||||
sum = "h1:yXHLWeravcrgGyFSyCgdYpXQ9dR9c/WED3pg1RhxqEU=",
|
||||
version = "v1.4.0",
|
||||
)
|
||||
go_repository(
|
||||
name = "com_github_stretchr_objx",
|
||||
importpath = "github.com/stretchr/objx",
|
||||
sum = "h1:Hbg2NidpLE8veEBkEZTL3CvlkUIVzuU9jDplZO54c48=",
|
||||
version = "v0.2.0",
|
||||
)
|
||||
go_repository(
|
||||
name = "com_github_stretchr_testify",
|
||||
importpath = "github.com/stretchr/testify",
|
||||
sum = "h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0=",
|
||||
version = "v1.6.1",
|
||||
)
|
||||
go_repository(
|
||||
name = "com_github_tmc_grpc_websocket_proxy",
|
||||
importpath = "github.com/tmc/grpc-websocket-proxy",
|
||||
sum = "h1:LnC5Kc/wtumK+WB441p7ynQJzVuNRJiqddSIE3IlSEQ=",
|
||||
version = "v0.0.0-20190109142713-0ad062ec5ee5",
|
||||
)
|
||||
go_repository(
|
||||
name = "com_github_ugorji_go",
|
||||
importpath = "github.com/ugorji/go",
|
||||
sum = "h1:j4s+tAvLfL3bZyefP2SEWmhBzmuIlH/eqNuPdFPgngw=",
|
||||
version = "v1.1.4",
|
||||
)
|
||||
go_repository(
|
||||
name = "com_github_xiang90_probing",
|
||||
importpath = "github.com/xiang90/probing",
|
||||
sum = "h1:eY9dn8+vbi4tKz5Qo6v2eYzo7kUS51QINcR5jNpbZS8=",
|
||||
version = "v0.0.0-20190116061207-43a291ad63a2",
|
||||
)
|
||||
go_repository(
|
||||
name = "com_github_xordataexchange_crypt",
|
||||
importpath = "github.com/xordataexchange/crypt",
|
||||
sum = "h1:ESFSdwYZvkeru3RtdrYueztKhOBCSAAzS4Gf+k0tEow=",
|
||||
version = "v0.0.3-0.20170626215501-b2862e3d0a77",
|
||||
)
|
||||
go_repository(
|
||||
name = "com_github_yuin_goldmark",
|
||||
importpath = "github.com/yuin/goldmark",
|
||||
sum = "h1:ruQGxdhGHe7FWOJPT0mKs5+pD2Xs1Bm/kdGlHO04FmM=",
|
||||
version = "v1.2.1",
|
||||
)
|
||||
go_repository(
|
||||
name = "com_google_cloud_go",
|
||||
importpath = "cloud.google.com/go",
|
||||
sum = "h1:e0WKqKTd5BnrG8aKH3J3h+QvEIQtSUcf2n5UZ5ZgLtQ=",
|
||||
version = "v0.26.0",
|
||||
)
|
||||
go_repository(
|
||||
name = "in_gopkg_alecthomas_kingpin_v2",
|
||||
importpath = "gopkg.in/alecthomas/kingpin.v2",
|
||||
sum = "h1:jMFz6MfLP0/4fUyZle81rXUoxOBFi19VUFKVDOQfozc=",
|
||||
version = "v2.2.6",
|
||||
)
|
||||
go_repository(
|
||||
name = "in_gopkg_check_v1",
|
||||
importpath = "gopkg.in/check.v1",
|
||||
sum = "h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY=",
|
||||
version = "v1.0.0-20180628173108-788fd7840127",
|
||||
)
|
||||
go_repository(
|
||||
name = "in_gopkg_errgo_v2",
|
||||
importpath = "gopkg.in/errgo.v2",
|
||||
sum = "h1:0vLT13EuvQ0hNvakwLuFZ/jYrLp5F3kcWHXdRggjCE8=",
|
||||
version = "v2.1.0",
|
||||
)
|
||||
go_repository(
|
||||
name = "in_gopkg_resty_v1",
|
||||
importpath = "gopkg.in/resty.v1",
|
||||
sum = "h1:CuXP0Pjfw9rOuY6EP+UvtNvt5DSqHpIxILZKT/quCZI=",
|
||||
version = "v1.12.0",
|
||||
)
|
||||
go_repository(
|
||||
name = "in_gopkg_yaml_v2",
|
||||
importpath = "gopkg.in/yaml.v2",
|
||||
sum = "h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw=",
|
||||
version = "v2.2.2",
|
||||
)
|
||||
go_repository(
|
||||
name = "in_gopkg_yaml_v3",
|
||||
importpath = "gopkg.in/yaml.v3",
|
||||
sum = "h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo=",
|
||||
version = "v3.0.0-20200313102051-9f266ea9e77c",
|
||||
)
|
||||
go_repository(
|
||||
name = "io_etcd_go_bbolt",
|
||||
importpath = "go.etcd.io/bbolt",
|
||||
sum = "h1:Z/90sZLPOeCy2PwprqkFa25PdkusRzaj9P8zm/KNyvk=",
|
||||
version = "v1.3.2",
|
||||
)
|
||||
go_repository(
|
||||
name = "io_k8s_klog",
|
||||
importpath = "k8s.io/klog",
|
||||
sum = "h1:Pt+yjF5aB1xDSVbau4VsWe+dQNzA0qv1LlXdC2dF6Q8=",
|
||||
version = "v1.0.0",
|
||||
)
|
||||
go_repository(
|
||||
name = "io_opencensus_go",
|
||||
importpath = "go.opencensus.io",
|
||||
sum = "h1:LYy1Hy3MJdrCdMwwzxA/dRok4ejH+RwNGbuoD9fCjto=",
|
||||
version = "v0.22.4",
|
||||
)
|
||||
go_repository(
|
||||
name = "org_golang_google_appengine",
|
||||
importpath = "google.golang.org/appengine",
|
||||
sum = "h1:/wp5JvzpHIxhs/dumFmF7BXTf3Z+dd4uXta4kVyO508=",
|
||||
version = "v1.4.0",
|
||||
)
|
||||
go_repository(
|
||||
name = "org_golang_google_genproto",
|
||||
importpath = "google.golang.org/genproto",
|
||||
sum = "h1:i1Ppqkc3WQXikh8bXiwHqAN5Rv3/qDCcRk0/Otx73BY=",
|
||||
version = "v0.0.0-20190425155659-357c62f0e4bb",
|
||||
)
|
||||
go_repository(
|
||||
name = "org_golang_google_grpc",
|
||||
importpath = "google.golang.org/grpc",
|
||||
sum = "h1:G+97AoqBnmZIT91cLG/EkCoK9NSelj64P8bOHHNmGn0=",
|
||||
version = "v1.21.0",
|
||||
)
|
||||
go_repository(
|
||||
name = "org_golang_x_crypto",
|
||||
importpath = "golang.org/x/crypto",
|
||||
sum = "h1:psW17arqaxU48Z5kZ0CQnkZWQJsqcURM6tKiBApRjXI=",
|
||||
version = "v0.0.0-20200622213623-75b288015ac9",
|
||||
)
|
||||
go_repository(
|
||||
name = "org_golang_x_exp",
|
||||
importpath = "golang.org/x/exp",
|
||||
sum = "h1:c2HOrn5iMezYjSlGPncknSEr/8x5LELb/ilJbXi9DEA=",
|
||||
version = "v0.0.0-20190121172915-509febef88a4",
|
||||
)
|
||||
go_repository(
|
||||
name = "org_golang_x_lint",
|
||||
importpath = "golang.org/x/lint",
|
||||
sum = "h1:5hukYrvBGR8/eNkX5mdUezrA6JiaEZDtJb9Ei+1LlBs=",
|
||||
version = "v0.0.0-20190930215403-16217165b5de",
|
||||
)
|
||||
go_repository(
|
||||
name = "org_golang_x_mod",
|
||||
importpath = "golang.org/x/mod",
|
||||
sum = "h1:RM4zey1++hCTbCVQfnWeKs9/IEsaBLA8vTkd0WVtmH4=",
|
||||
version = "v0.3.0",
|
||||
)
|
||||
go_repository(
|
||||
name = "org_golang_x_net",
|
||||
importpath = "golang.org/x/net",
|
||||
sum = "h1:VvcQYSHwXgi7W+TpUR6A9g6Up98WAHf3f/ulnJ62IyA=",
|
||||
version = "v0.0.0-20200822124328-c89045814202",
|
||||
)
|
||||
go_repository(
|
||||
name = "org_golang_x_oauth2",
|
||||
importpath = "golang.org/x/oauth2",
|
||||
sum = "h1:vEDujvNQGv4jgYKudGeI/+DAX4Jffq6hpD55MmoEvKs=",
|
||||
version = "v0.0.0-20180821212333-d2e6202438be",
|
||||
)
|
||||
go_repository(
|
||||
name = "org_golang_x_sync",
|
||||
importpath = "golang.org/x/sync",
|
||||
sum = "h1:qwRHBd0NqMbJxfbotnDhm2ByMI1Shq4Y6oRJo21SGJA=",
|
||||
version = "v0.0.0-20200625203802-6e8e738ad208",
|
||||
)
|
||||
go_repository(
|
||||
name = "org_golang_x_sys",
|
||||
importpath = "golang.org/x/sys",
|
||||
sum = "h1:xhmwyvizuTgC2qz7ZlMluP20uW+C3Rm0FD/WLDX8884=",
|
||||
version = "v0.0.0-20200323222414-85ca7c5b95cd",
|
||||
)
|
||||
go_repository(
|
||||
name = "org_golang_x_text",
|
||||
importpath = "golang.org/x/text",
|
||||
sum = "h1:tW2bmiBqwgJj/UpqtC8EpXEZVYOwU0yG4iWbprSVAcs=",
|
||||
version = "v0.3.2",
|
||||
)
|
||||
go_repository(
|
||||
name = "org_golang_x_time",
|
||||
importpath = "golang.org/x/time",
|
||||
sum = "h1:SvFZT6jyqRaOeXpc5h/JSfZenJ2O330aBsf7JfSUXmQ=",
|
||||
version = "v0.0.0-20190308202827-9d24e82272b4",
|
||||
)
|
||||
go_repository(
|
||||
name = "org_golang_x_tools",
|
||||
importpath = "golang.org/x/tools",
|
||||
sum = "h1:xLt+iB5ksWcZVxqc+g9K41ZHy+6MKWfXCDsjSThnsPA=",
|
||||
version = "v0.0.0-20200904185747-39188db58858",
|
||||
)
|
||||
go_repository(
|
||||
name = "org_golang_x_xerrors",
|
||||
importpath = "golang.org/x/xerrors",
|
||||
sum = "h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE=",
|
||||
version = "v0.0.0-20200804184101-5ec99f83aff1",
|
||||
)
|
||||
go_repository(
|
||||
name = "org_uber_go_atomic",
|
||||
importpath = "go.uber.org/atomic",
|
||||
sum = "h1:OI5t8sDa1Or+q8AeE+yKeB/SDYioSHAgcVljj9JIETY=",
|
||||
version = "v1.5.0",
|
||||
)
|
||||
go_repository(
|
||||
name = "org_uber_go_dig",
|
||||
importpath = "go.uber.org/dig",
|
||||
sum = "h1:yLmDDj9/zuDjv3gz8GQGviXMs9TfysIUMUilCpgzUJY=",
|
||||
version = "v1.10.0",
|
||||
)
|
||||
go_repository(
|
||||
name = "org_uber_go_fx",
|
||||
importpath = "go.uber.org/fx",
|
||||
sum = "h1:CFNTr1oin5OJ0VCZ8EycL3wzF29Jz2g0xe55RFsf2a4=",
|
||||
version = "v1.13.1",
|
||||
)
|
||||
go_repository(
|
||||
name = "org_uber_go_goleak",
|
||||
importpath = "go.uber.org/goleak",
|
||||
sum = "h1:G3eWbSNIskeRqtsN/1uI5B+eP73y3JUuBsv9AZjehb4=",
|
||||
version = "v0.10.0",
|
||||
)
|
||||
go_repository(
|
||||
name = "org_uber_go_multierr",
|
||||
importpath = "go.uber.org/multierr",
|
||||
sum = "h1:f3WCSC2KzAcBXGATIxAB1E2XuCpNU255wNKZ505qi3E=",
|
||||
version = "v1.4.0",
|
||||
)
|
||||
go_repository(
|
||||
name = "org_uber_go_tools",
|
||||
importpath = "go.uber.org/tools",
|
||||
sum = "h1:0mgffUl7nfd+FpvXMVz4IDEaUSmT1ysygQC7qYo7sG4=",
|
||||
version = "v0.0.0-20190618225709-2cfd321de3ee",
|
||||
)
|
||||
go_repository(
|
||||
name = "org_uber_go_zap",
|
||||
importpath = "go.uber.org/zap",
|
||||
sum = "h1:ORx85nbTijNz8ljznvCMR1ZBIPKFn3jQrag10X2AsuM=",
|
||||
version = "v1.10.0",
|
||||
)
|
14
docker-compose.yaml
Normal file
14
docker-compose.yaml
Normal file
|
@ -0,0 +1,14 @@
|
|||
# This is the Docker Compose for setting up a local dev environment.
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
dev:
|
||||
image: node:14
|
||||
volumes:
|
||||
- '.:/src'
|
||||
ports:
|
||||
- 6609:6609
|
||||
- 6601:6601
|
||||
- 6006:6006
|
||||
working_dir: /src
|
||||
command: yarn start
|
37
docs/getting-started.md
Normal file
37
docs/getting-started.md
Normal file
|
@ -0,0 +1,37 @@
|
|||
# Roleypoly Developer Guide
|
||||
|
||||
If you would like to help build Roleypoly, this guide will help get you started.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Node.js 14+ & Yarn
|
||||
- Wrangler CLI
|
||||
- (Optional): Terraform 0.14+
|
||||
- (Optional): Go 1.15+
|
||||
|
||||
## What things are built with
|
||||
|
||||
- **Backend/API**
|
||||
- Node.js & Typescript
|
||||
- Cloudflare Workers
|
||||
- **Frontend**
|
||||
- Next.js & React & Typescript
|
||||
- Storybooks
|
||||
- Homegrown Atomic Design System
|
||||
- **Discord Bot**
|
||||
- Go
|
||||
- Google Cloud Run
|
||||
- **CI/CD**
|
||||
- GitHub Actions
|
||||
- Terraform
|
||||
|
||||
## How does stuff fit together
|
||||
|
||||
As for infrastructure:
|
||||
|
||||
- CI/CD process deploys all pieces.
|
||||
- Discord Bot is deployed on a Google Cloud VM
|
||||
- Backend is deployed via a Cloudflare Worker
|
||||
- UI is deployed via Google Cloud Run
|
||||
|
||||
Biggest thing to note: this "discord bot" is an optional piece of the system, and should always remain as such. Giving it responsibility has actual engineering and dollar cost.
|
84
docs/infrastructure.md
Normal file
84
docs/infrastructure.md
Normal file
|
@ -0,0 +1,84 @@
|
|||
# Roleypoly Infrastructure
|
||||
|
||||
## Ring 0
|
||||
|
||||
Any of these missing is a service outage.
|
||||
|
||||
### Backend
|
||||
|
||||
Backend is a Cloudflare Worker deployment. Edge computing, fuck yeah!
|
||||
|
||||
Hosts:
|
||||
|
||||
- `api-${env}.roleypoly.com/*`
|
||||
- ex. for stage: `api-stage.roleypoly.com/*`
|
||||
- `api.roleypoly.com/*` (only in prod)
|
||||
|
||||
It uses 3 KV namespaces per environment:
|
||||
|
||||
- Sessions
|
||||
|
||||
- Store of data and tokens mapped to sessions
|
||||
- All data subject to 6 hour TTL
|
||||
|
||||
- GuildData
|
||||
|
||||
- Store of guild data, e.g. categories, etc
|
||||
- All data is permanent (maybe doubly persisted to Firestore)
|
||||
|
||||
- Guilds
|
||||
- Cache of Discord guild + guild member data
|
||||
- All data subject to a 5 minute TTL
|
||||
|
||||
### App UI
|
||||
|
||||
The Next.js server is a docker container hosted on Google Cloud Run in multiple regions with a load balancer. Region focuses knowing traffic: US (x3), EU (x2), AP (x3). There is a running maximum of 10 containers per region, and a minimum of 0.
|
||||
|
||||
Regions:
|
||||
|
||||
- `us-east4` (South Carolina)
|
||||
- `us-central1` (Iowa)
|
||||
- `us-west1` (Oregon)
|
||||
- `europe-west2` (London)
|
||||
- `europe-west3` (Frankfurt)
|
||||
- `australia-southeast1` (Sydney)
|
||||
- `asia-northeast1` (Tokyo)
|
||||
- `asia-southeast1` (Singapore)
|
||||
|
||||
Staging is only deployed to `us-east4`.
|
||||
|
||||
Hosts:
|
||||
|
||||
- `web-${env}.roleypoly.com`
|
||||
- ex. for stage: `web-stage.roleypoly.com`
|
||||
- `roleypoly.com` (only in prod, after release)
|
||||
- `next.roleypoly.com` (only in prod, pre-release)
|
||||
- `beta.roleypoly.com` (only in stage)
|
||||
|
||||
## Ring 1
|
||||
|
||||
Ephemeral services that can be tolerably lost, and would not be considered an outage, only degraded.
|
||||
|
||||
### v1 Migration Service
|
||||
|
||||
Hosted on v1 infrastructure, e.g. DigitalOcean. It is a connector and JSON API to the old v1 PgSQL data.
|
||||
|
||||
Host: `migration.v1.roleypoly.com` (Locked down to specific authorization tokens)
|
||||
|
||||
Sunset: Roleypoly Next + 3 months
|
||||
|
||||
### Bot (Mention Responder)
|
||||
|
||||
Bot doesn't do much, it's on a modestly sized Compute Engine VM using container hosting. It has no access to anything related to the real deployment of Roleypoly :)
|
||||
|
||||
Region: `us-east4` (South Carolina)
|
||||
|
||||
## Ring 2
|
||||
|
||||
Not end user applications. These will never be considered an "outage" if they are lost.
|
||||
|
||||
### Design System Storybook
|
||||
|
||||
Design system is a Vercel deployment
|
||||
|
||||
Host: `ui.roleypoly.com`
|
127
docs/user-stories.md
Normal file
127
docs/user-stories.md
Normal file
|
@ -0,0 +1,127 @@
|
|||
# User Stories
|
||||
|
||||
Loose doc defining end-to-end functionality for Roleypoly. Each slice has a cloud function or bot handler attached.
|
||||
|
||||
Legend
|
||||
|
||||
- (Hot) - Denotes cachable data stored for a short time, 2 minutes.
|
||||
- Typical use cases: User roles; anything volatile.
|
||||
- (Warm) - Denotes cachable data stored for a medium time, 10 minutes.
|
||||
- Typical use cases: Guild roles; anything unlikely to change, but not painful to query.
|
||||
- (Cold) - Denotes cachable data stored for a long time, 1 hour.
|
||||
- Typical use cases: Guild lists for a session; anything very unlikely to change, and commonly used.
|
||||
- (Permanent) - Denotes data that is permanent.
|
||||
- Typical use cases: Guild customization data (categories, message)
|
||||
- (Sec) - Security-minded data. Should never reach end-users.
|
||||
- Typical use cases: Access tokens
|
||||
|
||||
## Primary
|
||||
|
||||
### Logged-in Index
|
||||
|
||||
As a user, I'd like to see all the servers I am in that can be used with the app.
|
||||
|
||||
- Type: Function
|
||||
- Auth Level: User
|
||||
- Flow Type: JSON API
|
||||
- Data:
|
||||
- User Current Guilds (Cold)
|
||||
|
||||
### Server Role Picker View
|
||||
|
||||
As a user, I'd like to see all of the roles I can select in a previously set up server.
|
||||
|
||||
- Type: Function
|
||||
- Auth Level: User
|
||||
- Flow Type: JSON API
|
||||
- Data:
|
||||
- User Current Guilds (Cold)
|
||||
- Guild Roles (Warm)
|
||||
- User Roles (Hot)
|
||||
- Guild Customization (Cold) (Permanent)
|
||||
|
||||
### Server Role Picker Action
|
||||
|
||||
As a user, I'd like to select roles that have been selected in the role picker view for a server.
|
||||
|
||||
- Type: Function
|
||||
- Auth Level: User
|
||||
- Flow Type: JSON API
|
||||
- Data:
|
||||
- User Current Guilds (Cold)
|
||||
- Guild Roles (Warm)
|
||||
- User Roles (Hot)
|
||||
|
||||
### Server Editor View
|
||||
|
||||
As an admin, I'd like to see all of the settings and options for a server.
|
||||
|
||||
- Type: Function
|
||||
- Auth Level: Admin
|
||||
- Flow Type: JSON API
|
||||
- Data:
|
||||
- User Current Guilds (Cold)
|
||||
- Guild Roles (Warm)
|
||||
- Guild Customization (Cold) (Permanent)
|
||||
|
||||
### Server Editor Action
|
||||
|
||||
As an admin, I'd like to save settings and options that I have set within the editor view.
|
||||
|
||||
- Type: Function
|
||||
- Auth Level: Admin
|
||||
- Flow Type: JSON API
|
||||
- Data:
|
||||
- User Current Guilds (Cold)
|
||||
- Guild Roles (Warm)
|
||||
- Guild Customization (Cold) (Permanent)
|
||||
|
||||
### Session Pre-warming
|
||||
|
||||
As a user, I'd like to warm the cache with my current guild list after I log in.
|
||||
|
||||
- Type: Function
|
||||
- Auth Level: User
|
||||
- Flow Type: Bounces
|
||||
- Data:
|
||||
- User Current Guilds (Cold)
|
||||
|
||||
### Login
|
||||
|
||||
As a guest, I'd like to login with Discord so I can be authenticated as a user.
|
||||
|
||||
- Type: Function
|
||||
- Auth Level: Guest
|
||||
- Flow Type: OAuth, Bounces
|
||||
- Data:
|
||||
- Access Tokens (Sec)
|
||||
|
||||
### Logout
|
||||
|
||||
As a user, I'd like to revoke my authentication details.
|
||||
|
||||
- Type: Function
|
||||
- Auth Level: User
|
||||
- Flow Type: OAuth
|
||||
- Data:
|
||||
- Access Tokens (Sec)
|
||||
|
||||
### Bot Mention
|
||||
|
||||
As a discord user, I'd like to mention Roleypoly's bot account to get a link to my editor view.
|
||||
|
||||
- Type: Bot Responder
|
||||
- Auth Level: N/A
|
||||
- Flow Type: Command
|
||||
- Data:
|
||||
- None
|
||||
|
||||
### Bot Join
|
||||
|
||||
As a discord server admin, I'd like to follow the flow for adding Roleypoly to my server.
|
||||
|
||||
- Type: Function
|
||||
- Auth Level: Guest
|
||||
- Flow Type: OAuth
|
||||
- Data:
|
||||
- None
|
17
go.mod
17
go.mod
|
@ -1,17 +0,0 @@
|
|||
module github.com/roleypoly/roleypoly
|
||||
|
||||
go 1.15
|
||||
|
||||
require (
|
||||
github.com/bwmarrin/discordgo v0.22.0
|
||||
github.com/dghubble/trie v0.0.0-20200716043226-5a94efb202d5
|
||||
github.com/facebook/ent v0.4.3
|
||||
github.com/google/go-github/v32 v32.1.0
|
||||
github.com/joho/godotenv v1.3.0
|
||||
github.com/julienschmidt/httprouter v1.3.0
|
||||
github.com/lampjaw/discordclient v0.0.0-20200923011548-6558fc9e89df
|
||||
github.com/segmentio/ksuid v1.0.3
|
||||
go.uber.org/fx v1.13.1
|
||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be
|
||||
k8s.io/klog v1.0.0
|
||||
)
|
260
go.sum
260
go.sum
|
@ -1,260 +0,0 @@
|
|||
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
|
||||
github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ=
|
||||
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
||||
github.com/DATA-DOG/go-sqlmock v1.5.0 h1:Shsta01QNfFxHCfpW6YH2STWB0MudeXXEWMr20OEh60=
|
||||
github.com/DATA-DOG/go-sqlmock v1.5.0/go.mod h1:f/Ixk793poVmq4qj/V1dPUg2JEAKC73Q5eFN3EC/SaM=
|
||||
github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU=
|
||||
github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
|
||||
github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
|
||||
github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8=
|
||||
github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q=
|
||||
github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8=
|
||||
github.com/bwmarrin/discordgo v0.22.0 h1:uBxY1HmlVCsW1IuaPjpCGT6A2DBwRn0nvOguQIxDdFM=
|
||||
github.com/bwmarrin/discordgo v0.22.0/go.mod h1:c1WtWUGN6nREDmzIpyTp/iD3VYt4Fpx+bVyfBG7JE+M=
|
||||
github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc=
|
||||
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
|
||||
github.com/coreos/bbolt v1.3.2/go.mod h1:iRUV2dpdMOn7Bo10OQBFzIJO9kkE559Wcmn+qkEiiKk=
|
||||
github.com/coreos/etcd v3.3.10+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE=
|
||||
github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk=
|
||||
github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
|
||||
github.com/coreos/pkg v0.0.0-20180928190104-399ea9e2e55f/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.0/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dghubble/trie v0.0.0-20200716043226-5a94efb202d5 h1:euE/xPG0HIg6XiNXYrAHxX9aVwD1gw/yM2kptLOOj6k=
|
||||
github.com/dghubble/trie v0.0.0-20200716043226-5a94efb202d5/go.mod h1:xNBeoT4V92/aNvuC3IJ2g59uxuKP4/kzvkpoHrb7v4A=
|
||||
github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ=
|
||||
github.com/dgryski/go-sip13 v0.0.0-20181026042036-e10d5fee7954/go.mod h1:vAd38F8PWV+bWy6jNmig1y/TA+kYO4g3RSRF0IAv0no=
|
||||
github.com/facebook/ent v0.4.3 h1:ds9HENceKzpGBgCRlkZNq6TqBIegwKcF3e5reuV9Z0M=
|
||||
github.com/facebook/ent v0.4.3/go.mod h1:4e/LKv3FFjj/867jPJYCxycZg0aGeEIgkiQ8jv2j6iQ=
|
||||
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
|
||||
github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
|
||||
github.com/go-bindata/go-bindata v1.0.1-0.20190711162640-ee3c2418e368/go.mod h1:7xCgX1lzlrXPHkfvn3EhumqHkmSlzt8at9q7v0ax19c=
|
||||
github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
|
||||
github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE=
|
||||
github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk=
|
||||
github.com/go-logr/logr v0.1.0/go.mod h1:ixOQHD9gLJUVQQ2ZOR7zLEifBX6tGkNJF4QyIY7sIas=
|
||||
github.com/go-openapi/inflect v0.19.0 h1:9jCH9scKIbHeV9m12SmPilScz6krDxKRasNNSNPXu/4=
|
||||
github.com/go-openapi/inflect v0.19.0/go.mod h1:lHpZVlpIQqLyKwJ4N+YSc9hchQy/i12fJykb83CRBH4=
|
||||
github.com/go-sql-driver/mysql v1.5.1-0.20200311113236-681ffa848bae/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg=
|
||||
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
|
||||
github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
|
||||
github.com/gogo/protobuf v1.2.1/go.mod h1:hp+jE20tsWTFYpLwKvXlhS1hjn+gTNwPg2I6zVXpSg4=
|
||||
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
|
||||
github.com/golang/groupcache v0.0.0-20190129154638-5b532d6fd5ef/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||
github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||
github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
|
||||
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
|
||||
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
|
||||
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||
github.com/google/go-github v17.0.0+incompatible h1:N0LgJ1j65A7kfXrZnUDaYCs/Sf4rEjNlfyDHW9dolSY=
|
||||
github.com/google/go-github/v32 v32.1.0 h1:GWkQOdXqviCPx7Q7Fj+KyPoGm4SwHRh8rheoPhd27II=
|
||||
github.com/google/go-github/v32 v32.1.0/go.mod h1:rIEpZD9CTDQwDK9GDrtMTycQNA4JU3qBsCizh3q2WCI=
|
||||
github.com/google/go-querystring v1.0.0 h1:Xkwi/a1rcvNg1PPYe5vI8GbeBY/jrVuDX5ASuANWTrk=
|
||||
github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
|
||||
github.com/google/uuid v1.1.2 h1:EVhdT+1Kseyi1/pUmXKaFxYsDNy9RQYkMWRH68J/W7Y=
|
||||
github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/websocket v1.4.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ=
|
||||
github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc=
|
||||
github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/grpc-ecosystem/go-grpc-middleware v1.0.0/go.mod h1:FiyG127CGDf3tlThmgyCl78X/SZQqEOJBCDaAfeWzPs=
|
||||
github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk=
|
||||
github.com/grpc-ecosystem/grpc-gateway v1.9.0/go.mod h1:vNeuVxBJEsws4ogUvrchl83t/GYV9WGTSLVdBhOQFDY=
|
||||
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
|
||||
github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=
|
||||
github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
|
||||
github.com/joho/godotenv v1.3.0 h1:Zjp+RcGpHhGlrMbJzXTrZZPrWj+1vfm90La1wgB6Bhc=
|
||||
github.com/joho/godotenv v1.3.0/go.mod h1:7hK45KPybAkOC6peb+G5yklZfMxEjkZhHbwpqxOKXbg=
|
||||
github.com/jonboulle/clockwork v0.1.0/go.mod h1:Ii8DK3G1RaLaWxj9trq07+26W01tbo22gdxWY5EU2bo=
|
||||
github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
|
||||
github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w=
|
||||
github.com/julienschmidt/httprouter v1.3.0 h1:U0609e9tgbseu3rBINet9P48AI/D3oJs4dN7jwJOQ1U=
|
||||
github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM=
|
||||
github.com/kisielk/errcheck v1.1.0/go.mod h1:EZBBE59ingxPouuu3KfxchcWSUPOHkagtvWXihfKN4Q=
|
||||
github.com/kisielk/errcheck v1.2.0/go.mod h1:/BMXB+zMLi60iA8Vv6Ksmxu/1UDYcXs4uQLJ+jE2L00=
|
||||
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
||||
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
|
||||
github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc=
|
||||
github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/lampjaw/discordclient v0.0.0-20200923011548-6558fc9e89df h1:Y2o9fEOoAYjCw8IDyxUVaBq44AUbOLyPnYSPpM6Ef3M=
|
||||
github.com/lampjaw/discordclient v0.0.0-20200923011548-6558fc9e89df/go.mod h1:lOfqvGl1HcXws86Sczusw1DyV5d0KHPtTTtdjneekto=
|
||||
github.com/lib/pq v1.8.0/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||
github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ=
|
||||
github.com/mattn/go-runewidth v0.0.7/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
|
||||
github.com/mattn/go-runewidth v0.0.9 h1:Lm995f3rfxdpd6TSmuVCHVb/QhupuXlYr8sCI/QdE+0=
|
||||
github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
|
||||
github.com/mattn/go-sqlite3 v1.14.3/go.mod h1:WVKg1VTActs4Qso6iwGbiFih2UIHo0ENGwNd0Lj+XmI=
|
||||
github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
|
||||
github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
|
||||
github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
|
||||
github.com/mitchellh/mapstructure v1.3.3/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
|
||||
github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
|
||||
github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
|
||||
github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U=
|
||||
github.com/olekukonko/tablewriter v0.0.4 h1:vHD/YYe1Wolo78koG299f7V/VAS08c6IpCLn+Ejf/w8=
|
||||
github.com/olekukonko/tablewriter v0.0.4/go.mod h1:zq6QwlOf5SlnkVbMSr5EoBv3636FWnp+qbPhuoO21uA=
|
||||
github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic=
|
||||
github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw=
|
||||
github.com/prometheus/client_golang v0.9.3/go.mod h1:/TN21ttK/J9q6uSwhBd54HahCDft0ttaMvbicHlPoso=
|
||||
github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo=
|
||||
github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
|
||||
github.com/prometheus/common v0.0.0-20181113130724-41aa239b4cce/go.mod h1:daVV7qP5qjZbuso7PdcryaAu0sAZbrN9i7WWcTMWvro=
|
||||
github.com/prometheus/common v0.4.0/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4=
|
||||
github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk=
|
||||
github.com/prometheus/procfs v0.0.0-20190507164030-5867b95ac084/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA=
|
||||
github.com/prometheus/tsdb v0.7.1/go.mod h1:qhTCs0VvXwvX/y3TZrWD7rabWM+ijKTux40TwIPHuXU=
|
||||
github.com/rogpeppe/fastuuid v0.0.0-20150106093220-6724a57986af/go.mod h1:XWv6SoW27p1b0cqNHllgS5HIMJraePCO15w5zCzIWYg=
|
||||
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
|
||||
github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/segmentio/ksuid v1.0.3 h1:FoResxvleQwYiPAVKe1tMUlEirodZqlqglIuFsdDntY=
|
||||
github.com/segmentio/ksuid v1.0.3/go.mod h1:/XUiZBD3kVx5SmUOl55voK5yeAbBNNIed+2O73XgrPE=
|
||||
github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
|
||||
github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
|
||||
github.com/soheilhy/cmux v0.1.4/go.mod h1:IM3LyeVVIOuxMH7sFAkER9+bJ4dT7Ms6E4xg4kGIyLM=
|
||||
github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
|
||||
github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ=
|
||||
github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE=
|
||||
github.com/spf13/cobra v1.0.0 h1:6m/oheQuQ13N9ks4hubMG6BnvwOeaJrqSPLahSnczz8=
|
||||
github.com/spf13/cobra v1.0.0/go.mod h1:/6GTrnGXV9HjY+aR4k0oJ5tcvakLuG6EuKReYlHNrgE=
|
||||
github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo=
|
||||
github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=
|
||||
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
|
||||
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/spf13/viper v1.4.0/go.mod h1:PTJ7Z/lr49W6bUbkmS1V3by4uWynFiR9p7+dSq/yZzE=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE=
|
||||
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||
github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0=
|
||||
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/tmc/grpc-websocket-proxy v0.0.0-20190109142713-0ad062ec5ee5/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U=
|
||||
github.com/ugorji/go v1.1.4/go.mod h1:uQMGLiO92mf5W77hV/PUCpI3pbzQx3CRekS0kk+RGrc=
|
||||
github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU=
|
||||
github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q=
|
||||
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
go.etcd.io/bbolt v1.3.2/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU=
|
||||
go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
|
||||
go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE=
|
||||
go.uber.org/atomic v1.5.0 h1:OI5t8sDa1Or+q8AeE+yKeB/SDYioSHAgcVljj9JIETY=
|
||||
go.uber.org/atomic v1.5.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ=
|
||||
go.uber.org/dig v1.10.0 h1:yLmDDj9/zuDjv3gz8GQGviXMs9TfysIUMUilCpgzUJY=
|
||||
go.uber.org/dig v1.10.0/go.mod h1:X34SnWGr8Fyla9zQNO2GSO2D+TIuqB14OS8JhYocIyw=
|
||||
go.uber.org/fx v1.13.1 h1:CFNTr1oin5OJ0VCZ8EycL3wzF29Jz2g0xe55RFsf2a4=
|
||||
go.uber.org/fx v1.13.1/go.mod h1:bREWhavnedxpJeTq9pQT53BbvwhUv7TcpsOqcH4a+3w=
|
||||
go.uber.org/goleak v0.10.0 h1:G3eWbSNIskeRqtsN/1uI5B+eP73y3JUuBsv9AZjehb4=
|
||||
go.uber.org/goleak v0.10.0/go.mod h1:VCZuO8V8mFPlL0F5J5GK1rtHV3DrFcQ1R8ryq7FK0aI=
|
||||
go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0=
|
||||
go.uber.org/multierr v1.4.0 h1:f3WCSC2KzAcBXGATIxAB1E2XuCpNU255wNKZ505qi3E=
|
||||
go.uber.org/multierr v1.4.0/go.mod h1:VgVr7evmIr6uPjLBxg28wmKNXyqE9akIJ5XnfpiKl+4=
|
||||
go.uber.org/tools v0.0.0-20190618225709-2cfd321de3ee h1:0mgffUl7nfd+FpvXMVz4IDEaUSmT1ysygQC7qYo7sG4=
|
||||
go.uber.org/tools v0.0.0-20190618225709-2cfd321de3ee/go.mod h1:vJERXedbb3MVM5f9Ejo0C68/HhF8uaILCdgjnY+goOA=
|
||||
go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q=
|
||||
golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
|
||||
golang.org/x/crypto v0.0.0-20181030102418-4d3f4d9ffa16/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9 h1:psW17arqaxU48Z5kZ0CQnkZWQJsqcURM6tKiBApRjXI=
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
||||
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
|
||||
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||
golang.org/x/lint v0.0.0-20190409202823-959b441ac422/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||
golang.org/x/lint v0.0.0-20190930215403-16217165b5de h1:5hukYrvBGR8/eNkX5mdUezrA6JiaEZDtJb9Ei+1LlBs=
|
||||
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||
golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc=
|
||||
golang.org/x/mod v0.3.0 h1:RM4zey1++hCTbCVQfnWeKs9/IEsaBLA8vTkd0WVtmH4=
|
||||
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20181220203305-927f97764cc3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190522155817-f3200d17e092/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200822124328-c89045814202 h1:VvcQYSHwXgi7W+TpUR6A9g6Up98WAHf3f/ulnJ62IyA=
|
||||
golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
|
||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be h1:vEDujvNQGv4jgYKudGeI/+DAX4Jffq6hpD55MmoEvKs=
|
||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20181107165924-66b7b1311ac8/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd h1:xhmwyvizuTgC2qz7ZlMluP20uW+C3Rm0FD/WLDX8884=
|
||||
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
golang.org/x/tools v0.0.0-20180221164845-07fd8470d635/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20181030221726-6c7e314b6563/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
|
||||
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||
golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
|
||||
golang.org/x/tools v0.0.0-20191029041327-9cc4af7d6b2c/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20191029190741-b9c20aec41a5/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20191030062658-86caa796c7ab/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20191114200427-caa0b0f7d508/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20200904185747-39188db58858 h1:xLt+iB5ksWcZVxqc+g9K41ZHy+6MKWfXCDsjSThnsPA=
|
||||
golang.org/x/tools v0.0.0-20200904185747-39188db58858/go.mod h1:Cj7w3i3Rnn0Xh82ur9kSqwfTHTeVxaDqrfMjpcNT6bE=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE=
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
|
||||
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
||||
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
|
||||
google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
|
||||
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
|
||||
google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
|
||||
google.golang.org/grpc v1.21.0/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM=
|
||||
gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY=
|
||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
|
||||
gopkg.in/resty.v1 v1.12.0/go.mod h1:mDo4pnntr5jdWRML875a/NmxYqAlA73dVijT2AXvQQo=
|
||||
gopkg.in/yaml.v2 v2.0.0-20170812160011-eb3733d160e7/go.mod h1:JAlM8MvJe8wmxCU4Bli9HhUf9+ttbYbLASfIpnQbh74=
|
||||
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||
honnef.co/go/tools v0.0.1-2019.2.3 h1:3JgtbtFHMiCmsznwGVTUWbgGov+pVqnlf1dEJTNAXeM=
|
||||
honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
|
||||
k8s.io/klog v1.0.0 h1:Pt+yjF5aB1xDSVbau4VsWe+dQNzA0qv1LlXdC2dF6Q8=
|
||||
k8s.io/klog v1.0.0/go.mod h1:4Bi6QPql/J/LkTDqv7R/cd3hPo4k2DG6Ptcz060Ez5I=
|
8
hack/dockerfiles/dev-container.Dockerfile
Normal file
8
hack/dockerfiles/dev-container.Dockerfile
Normal file
|
@ -0,0 +1,8 @@
|
|||
FROM mcr.microsoft.com/vscode/devcontainers/go:1.15
|
||||
|
||||
# Install Node.js
|
||||
ARG NODE_VERSION="lts/*"
|
||||
RUN su vscode -c "source /usr/local/share/nvm/nvm.sh && nvm install ${NODE_VERSION} 2>&1"
|
||||
|
||||
# Install Wrangler
|
||||
RUN su vscode -c "npm install -g wrangler"
|
|
@ -1,12 +0,0 @@
|
|||
#!/bin/sh
|
||||
|
||||
cd `dirname $(realpath $0)`
|
||||
|
||||
bazel run //:gazelle
|
||||
bazel run //:gazelle -- update-repos -from_file=./go.mod --to_macro=deps.bzl%go_repositories -prune=true
|
||||
|
||||
sleep 0.5
|
||||
echo "Fixing deps.bzl..."
|
||||
head -n2 ../deps.bzl > ../deps.bzl~
|
||||
tail -n+3 ../deps.bzl | sed '/^$/d' >> ../deps.bzl~
|
||||
mv ../deps.bzl~ ../deps.bzl
|
|
@ -1,7 +0,0 @@
|
|||
package hacknotused
|
||||
|
||||
//go:generate sh gazelle.sh
|
||||
|
||||
func noop() {
|
||||
|
||||
}
|
4
hack/jestSetup.ts
Normal file
4
hack/jestSetup.ts
Normal file
|
@ -0,0 +1,4 @@
|
|||
import enableHooks from 'jest-react-hooks-shallow';
|
||||
|
||||
// pass an instance of jest to `enableHooks()`
|
||||
enableHooks(jest);
|
|
@ -1,4 +0,0 @@
|
|||
#!/bin/bash
|
||||
echo "STABLE_GIT_COMMIT $(git rev-parse --short HEAD)"
|
||||
echo "STABLE_GIT_BRANCH $(git rev-parse --abbrev-ref HEAD)"
|
||||
echo "BUILD_DATE $(date -Iseconds)"
|
76
package.json
76
package.json
|
@ -2,28 +2,76 @@
|
|||
"name": "roleypoly",
|
||||
"version": "1.0.0",
|
||||
"description": "https://roleypoly.com",
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/roleypoly/roleypoly.git"
|
||||
},
|
||||
"author": "Katalina Okano <git@kat.cafe>",
|
||||
"license": "MIT",
|
||||
"homepage": "https://github.com/roleypoly/roleypoly#readme",
|
||||
"bugs": {
|
||||
"url": "https://github.com/roleypoly/roleypoly/issues"
|
||||
},
|
||||
"homepage": "https://github.com/roleypoly/roleypoly#readme",
|
||||
"dependencies": {
|
||||
"@improbable-eng/grpc-web": "0.13.0",
|
||||
"google-protobuf": "3.13.0"
|
||||
"author": "Katalina Okano <git@kat.cafe>",
|
||||
"license": "MIT",
|
||||
"private": true,
|
||||
"workspaces": [
|
||||
"packages/*"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "run-p -c build:*",
|
||||
"build:api": "yarn workspace @roleypoly/api run build",
|
||||
"build:design-system": "yarn workspace @roleypoly/design-system run build",
|
||||
"build:web": "yarn workspace @roleypoly/web run build",
|
||||
"create-component": "yarn workspace @roleypoly/design-system run create-component",
|
||||
"lint": "run-p -c lint:* --",
|
||||
"lint:eslint": "eslint",
|
||||
"lint:prettier": "cross-env prettier -c '**/*.{ts,tsx,css,yml,yaml,md,json,js,jsx,sh,gitignore,mdx,Dockerfile}'",
|
||||
"lint:stylelint": "cross-env stylelint 'packages/{web,design-system}/**/*.{ts,tsx}'",
|
||||
"lint:terraform": "terraform fmt -check -recursive",
|
||||
"lint:types": "tsc --noEmit",
|
||||
"lint:types-api": "yarn workspace @roleypoly/api run lint:types",
|
||||
"postinstall": "is-ci || husky install",
|
||||
"start": "run-p -c start:*",
|
||||
"start:api": "yarn workspace @roleypoly/api start",
|
||||
"start:bot": "yarn workspace @roleypoly/bot start",
|
||||
"start:design-system": "yarn workspace @roleypoly/design-system start",
|
||||
"start:web": "yarn workspace @roleypoly/web start",
|
||||
"test": "run-p -c test:* --",
|
||||
"test:api": "yarn workspace @roleypoly/api run test",
|
||||
"test:design-system": "yarn workspace @roleypoly/design-system run test",
|
||||
"test:misc-utils": "yarn workspace @roleypoly/misc-utils run test",
|
||||
"test:web": "yarn workspace @roleypoly/web run test"
|
||||
},
|
||||
"dependencies": {},
|
||||
"devDependencies": {
|
||||
"@bazel/typescript": "^2.2.0",
|
||||
"prettier": "^2.1.1",
|
||||
"typescript": "^4.0.2",
|
||||
"@roleypoly/ts-protoc-gen": "^1.0.1-promises.1",
|
||||
"@types/google-protobuf": "3.7.3"
|
||||
"@stylelint/postcss-css-in-js": "^0.37.2",
|
||||
"husky": "^7.0.4",
|
||||
"is-ci": "^3.0.1",
|
||||
"jest-react-hooks-shallow": "^1.5.1",
|
||||
"lint-staged": "^12.3.2",
|
||||
"npm-run-all": "^4.1.5",
|
||||
"postcss": "^8.4.5",
|
||||
"postcss-syntax": "^0.36.2",
|
||||
"prettier": "^2.5.1",
|
||||
"prettier-plugin-organize-imports": "^2.3.4",
|
||||
"prettier-plugin-pkg": "^0.11.1",
|
||||
"prettier-plugin-sh": "^0.8.1",
|
||||
"stylelint": "^14.3.0",
|
||||
"stylelint-config-recommended": "^6.0.0",
|
||||
"stylelint-config-styled-components": "^0.1.1",
|
||||
"typescript": "^4.5.5"
|
||||
},
|
||||
"lint-staged": {
|
||||
"*.{ts,tsx,js,jsx}": [
|
||||
"prettier --write"
|
||||
],
|
||||
"*.{json,Dockerfile,sh,md,env,mdx,yml,html}": [
|
||||
"prettier --write"
|
||||
],
|
||||
".*/*.{json,Dockerfile,sh,md,env,mdx,yml,html}": [
|
||||
"prettier --write"
|
||||
],
|
||||
".husky/pre-commit": [
|
||||
"prettier --write"
|
||||
]
|
||||
}
|
||||
}
|
2
packages/api/.gitignore
vendored
Normal file
2
packages/api/.gitignore
vendored
Normal file
|
@ -0,0 +1,2 @@
|
|||
dist
|
||||
.mf
|
1
packages/api/.nvmrc
Normal file
1
packages/api/.nvmrc
Normal file
|
@ -0,0 +1 @@
|
|||
17
|
252
packages/api/historic/audit-log.ts~
Normal file
252
packages/api/historic/audit-log.ts~
Normal file
|
@ -0,0 +1,252 @@
|
|||
import { uiPublicURI } from '@roleypoly/api/utils/config';
|
||||
import {
|
||||
Category,
|
||||
DiscordUser,
|
||||
Embed,
|
||||
GuildData,
|
||||
GuildDataUpdate,
|
||||
GuildSlug,
|
||||
WebhookValidationStatus,
|
||||
} from '@roleypoly/types';
|
||||
import { userAgent } from '@roleypoly/worker-utils';
|
||||
import deepEqual from 'deep-equal';
|
||||
import { sortBy, uniq } from 'lodash';
|
||||
|
||||
type WebhookPayload = {
|
||||
username: string;
|
||||
avatar_url: string;
|
||||
embeds: Embed[];
|
||||
provider: {
|
||||
name: string;
|
||||
url: string;
|
||||
};
|
||||
};
|
||||
|
||||
type ChangeHandler = (
|
||||
oldValue: GuildDataUpdate[keyof GuildDataUpdate],
|
||||
newValue: GuildData[keyof GuildDataUpdate]
|
||||
) => Embed[];
|
||||
|
||||
const changeHandlers: Record<keyof GuildDataUpdate, ChangeHandler> = {
|
||||
message: (oldValue, newValue) => [
|
||||
{
|
||||
timestamp: new Date().toISOString(),
|
||||
color: 0x453e3d,
|
||||
fields: [
|
||||
{
|
||||
name: 'Old Message',
|
||||
value: oldValue as string,
|
||||
inline: false,
|
||||
},
|
||||
{
|
||||
name: 'New Message',
|
||||
value: newValue as string,
|
||||
inline: false,
|
||||
},
|
||||
],
|
||||
title: `Server message was updated...`,
|
||||
},
|
||||
],
|
||||
auditLogWebhook: (oldValue, newValue) => [
|
||||
{
|
||||
timestamp: new Date().toISOString(),
|
||||
color: 0x5d5352,
|
||||
fields: [
|
||||
{
|
||||
name: 'Old Webhook ID',
|
||||
value: !oldValue ? '*unset*' : (oldValue as string).split('/')[5],
|
||||
inline: false,
|
||||
},
|
||||
{
|
||||
name: 'New Webhook ID',
|
||||
value: !newValue ? '*unset*' : (newValue as string).split('/')[5],
|
||||
inline: false,
|
||||
},
|
||||
],
|
||||
title: `Audit Log webhook URL was changed...`,
|
||||
},
|
||||
],
|
||||
categories: (oldValue, newValue) => [
|
||||
{
|
||||
timestamp: new Date().toISOString(),
|
||||
color: 0xab9b9a,
|
||||
fields: [
|
||||
{
|
||||
name: 'Changed Categories',
|
||||
value: getChangedCategories(
|
||||
oldValue as Category[],
|
||||
newValue as Category[]
|
||||
).join('\n'),
|
||||
inline: false,
|
||||
},
|
||||
],
|
||||
title: `Categories were changed...`,
|
||||
},
|
||||
],
|
||||
accessControl: (oldValue, newValue) => [
|
||||
{
|
||||
timestamp: new Date().toISOString(),
|
||||
color: 0xab9b9a,
|
||||
fields: [
|
||||
{
|
||||
name: 'Changed Access Control',
|
||||
value: getChangedAccessControl(
|
||||
oldValue as GuildDataUpdate['accessControl'],
|
||||
newValue as GuildDataUpdate['accessControl']
|
||||
).join('\n'),
|
||||
inline: false,
|
||||
},
|
||||
],
|
||||
title: `Access Control was changed...`,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const sendAuditLog = async (
|
||||
guild: GuildData,
|
||||
guildUpdate: GuildDataUpdate,
|
||||
user: DiscordUser
|
||||
) => {
|
||||
const auditLogWebhooks = uniq([
|
||||
guild.auditLogWebhook || '',
|
||||
guildUpdate.auditLogWebhook || '',
|
||||
]).filter((webhook) => webhook !== '');
|
||||
|
||||
if (auditLogWebhooks.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const keys = Object.keys(guildUpdate) as (keyof GuildDataUpdate)[];
|
||||
const webhookPayload: WebhookPayload = {
|
||||
username: 'Roleypoly (Audit Log)',
|
||||
avatar_url: `https://next.roleypoly.com/logo192.png`, //TODO: change to roleypoly.com when swapped.
|
||||
embeds: [
|
||||
{
|
||||
fields: [],
|
||||
timestamp: new Date().toISOString(),
|
||||
title: `${user.username}#${user.discriminator} has edited Roleypoly settings`,
|
||||
color: 0x332d2d,
|
||||
author: {
|
||||
name: user.username,
|
||||
icon_url: `https://cdn.discordapp.com/avatars/${user.id}/${user.avatar}.png`,
|
||||
},
|
||||
},
|
||||
],
|
||||
provider: {
|
||||
name: 'Roleypoly',
|
||||
url: uiPublicURI,
|
||||
},
|
||||
};
|
||||
|
||||
for (let key of keys) {
|
||||
if (deepEqual(guildUpdate[key], guild[key])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const handler = changeHandlers[key];
|
||||
if (!handler) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const changeFields = handler(guild[key], guildUpdate[key]);
|
||||
webhookPayload.embeds.push(...changeFields);
|
||||
}
|
||||
|
||||
if (webhookPayload.embeds.length === 1) {
|
||||
// No changes, don't bother sending
|
||||
return;
|
||||
}
|
||||
|
||||
// Colors are in order already, so use them to order the embeds.
|
||||
webhookPayload.embeds = sortBy(webhookPayload.embeds, 'color');
|
||||
|
||||
const doWebhook = (webhook: string) =>
|
||||
fetch(webhook, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(webhookPayload),
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
'user-agent': userAgent,
|
||||
},
|
||||
});
|
||||
|
||||
await Promise.all(auditLogWebhooks.map((webhook) => doWebhook(webhook)));
|
||||
};
|
||||
|
||||
export const validateAuditLogWebhook = async (
|
||||
guild: GuildSlug,
|
||||
webhook: string | null
|
||||
): Promise<WebhookValidationStatus> => {
|
||||
if (!webhook) {
|
||||
return WebhookValidationStatus.NoneSet;
|
||||
}
|
||||
|
||||
const url = new URL(webhook);
|
||||
|
||||
if (
|
||||
url.hostname !== 'discord.com' ||
|
||||
url.protocol !== 'https:' ||
|
||||
url.pathname.startsWith('api/webhooks/')
|
||||
) {
|
||||
return WebhookValidationStatus.NotDiscordURL;
|
||||
}
|
||||
|
||||
const response = await fetch(webhook, { method: 'GET' });
|
||||
if (response.status !== 200) {
|
||||
return WebhookValidationStatus.DoesNotExist;
|
||||
}
|
||||
|
||||
const webhookData = await response.json();
|
||||
|
||||
if (webhookData.guild_id !== guild.id) {
|
||||
return WebhookValidationStatus.NotSameGuild;
|
||||
}
|
||||
|
||||
return WebhookValidationStatus.Ok;
|
||||
};
|
||||
|
||||
const getChangedCategories = (oldCategories: Category[], newCategories: Category[]) => {
|
||||
const addedCategories = newCategories.filter(
|
||||
(c) => !oldCategories.find((o) => o.id === c.id)
|
||||
);
|
||||
const removedCategories = oldCategories.filter(
|
||||
(c) => !newCategories.find((o) => o.id === c.id)
|
||||
);
|
||||
const changedCategories = newCategories.filter(
|
||||
(c) =>
|
||||
oldCategories.find((o) => o.id === c.id) &&
|
||||
!deepEqual(
|
||||
oldCategories.find((o) => o.id === c.id),
|
||||
newCategories.find((o) => o.id === c.id)
|
||||
)
|
||||
);
|
||||
|
||||
return [
|
||||
...addedCategories.map((c) => `➕ **Added** ${c.name}`),
|
||||
...removedCategories.map((c) => `➖ **Removed** ${c.name}`),
|
||||
...changedCategories.map((c) => `🔧 **Changed** ${c.name}`),
|
||||
];
|
||||
};
|
||||
|
||||
const getChangedAccessControl = (
|
||||
oldAccessControl: GuildDataUpdate['accessControl'],
|
||||
newAccessControl: GuildDataUpdate['accessControl']
|
||||
) => {
|
||||
const pendingChanged = newAccessControl.blockPending !== oldAccessControl.blockPending;
|
||||
|
||||
return [
|
||||
`✅ Allowed roles: ${
|
||||
newAccessControl.allowList.map((role) => `<@&${role}>`).join(', ') || `*all roles*`
|
||||
}`,
|
||||
`❌ Blocked roles: ${
|
||||
newAccessControl.blockList.map((role) => `<@&${role}>`).join(', ') || `*no roles*`
|
||||
}`,
|
||||
...(pendingChanged
|
||||
? [
|
||||
`🔧 Pending/Welcome Screening users are ${
|
||||
newAccessControl.blockPending ? 'blocked ❌' : 'allowed ✔'
|
||||
}`,
|
||||
]
|
||||
: []),
|
||||
];
|
||||
};
|
104
packages/api/historic/interactions-pick-role.ts~
Normal file
104
packages/api/historic/interactions-pick-role.ts~
Normal file
|
@ -0,0 +1,104 @@
|
|||
import { CategoryType, Member, RoleSafety } from '@roleypoly/types';
|
||||
import { AuthType, discordFetch, respond } from '@roleypoly/worker-utils';
|
||||
import { difference, keyBy } from 'lodash';
|
||||
import { interactionsEndpoint } from '../utils/api-tools';
|
||||
import { botToken } from '../utils/config';
|
||||
import {
|
||||
getGuild,
|
||||
getGuildData,
|
||||
getGuildMember,
|
||||
updateGuildMember,
|
||||
} from '../utils/guild';
|
||||
import { conflict, invalid, notAuthenticated, notFound, ok } from '../utils/responses';
|
||||
|
||||
export const InteractionsPickRole = interactionsEndpoint(
|
||||
async (request: Request): Promise<Response> => {
|
||||
const mode = request.method === 'PUT' ? 'add' : 'remove';
|
||||
const reqURL = new URL(request.url);
|
||||
const [, , guildID, userID, roleID] = reqURL.pathname.split('/');
|
||||
if (!guildID || !userID || !roleID) {
|
||||
return invalid();
|
||||
}
|
||||
|
||||
const guildP = getGuild(guildID);
|
||||
const guildDataP = getGuildData(guildID);
|
||||
const guildMemberP = getGuildMember(
|
||||
{ serverID: guildID, userID },
|
||||
{ skipCachePull: true }
|
||||
);
|
||||
|
||||
const [guild, guildData, guildMember] = await Promise.all([
|
||||
guildP,
|
||||
guildDataP,
|
||||
guildMemberP,
|
||||
]);
|
||||
|
||||
if (!guild || !guildData || !guildMember) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
let memberRoles = guildMember.roles;
|
||||
|
||||
if (
|
||||
(mode === 'add' && memberRoles.includes(roleID)) ||
|
||||
(mode !== 'add' && !memberRoles.includes(roleID))
|
||||
) {
|
||||
return conflict();
|
||||
}
|
||||
|
||||
const roleMap = keyBy(guild.roles, 'id');
|
||||
|
||||
const category = guildData.categories.find((category) =>
|
||||
category.roles.includes(roleID)
|
||||
);
|
||||
// No category? illegal.
|
||||
if (!category) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
// Category is hidden, this is illegal
|
||||
if (category.hidden) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
// Role is unsafe, super illegal.
|
||||
if (roleMap[roleID].safety !== RoleSafety.Safe) {
|
||||
return notAuthenticated();
|
||||
}
|
||||
|
||||
// In add mode, if the category is a single-mode, remove the other roles in the category.
|
||||
if (mode === 'add' && category.type === CategoryType.Single) {
|
||||
memberRoles = difference(memberRoles, category.roles);
|
||||
}
|
||||
|
||||
if (mode === 'add') {
|
||||
memberRoles = [...memberRoles, roleID];
|
||||
} else {
|
||||
memberRoles = memberRoles.filter((id) => id !== roleID);
|
||||
}
|
||||
|
||||
const patchMemberRoles = await discordFetch<Member>(
|
||||
`/guilds/${guildID}/members/${userID}`,
|
||||
botToken,
|
||||
AuthType.Bot,
|
||||
{
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
'x-audit-log-reason': `Picked their roles via slash command`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
roles: memberRoles,
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
if (!patchMemberRoles) {
|
||||
return respond({ error: 'discord rejected the request' }, { status: 500 });
|
||||
}
|
||||
|
||||
await updateGuildMember({ serverID: guildID, userID });
|
||||
|
||||
return ok();
|
||||
}
|
||||
);
|
33
packages/api/historic/interactions-pickable-roles.ts~
Normal file
33
packages/api/historic/interactions-pickable-roles.ts~
Normal file
|
@ -0,0 +1,33 @@
|
|||
import { Category, CategorySlug } from '@roleypoly/types';
|
||||
import { respond } from '@roleypoly/worker-utils';
|
||||
import { interactionsEndpoint } from '../utils/api-tools';
|
||||
import { getGuildData } from '../utils/guild';
|
||||
import { notFound } from '../utils/responses';
|
||||
|
||||
export const InteractionsPickableRoles = interactionsEndpoint(
|
||||
async (request: Request): Promise<Response> => {
|
||||
const reqURL = new URL(request.url);
|
||||
const [, , serverID] = reqURL.pathname.split('/');
|
||||
|
||||
const guildData = await getGuildData(serverID);
|
||||
if (!guildData) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
const roleMap: Record<Category['name'], CategorySlug> = {};
|
||||
|
||||
for (let category of guildData.categories) {
|
||||
if (category.hidden) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// TODO: role safety?
|
||||
roleMap[category.name] = {
|
||||
roles: category.roles,
|
||||
type: category.type,
|
||||
};
|
||||
}
|
||||
|
||||
return respond(roleMap);
|
||||
}
|
||||
);
|
16
packages/api/historic/interactions/hello-world.ts~
Normal file
16
packages/api/historic/interactions/hello-world.ts~
Normal file
|
@ -0,0 +1,16 @@
|
|||
import {
|
||||
InteractionCallbackType,
|
||||
InteractionRequestCommand,
|
||||
InteractionResponse,
|
||||
} from '@roleypoly/types';
|
||||
|
||||
export const helloWorld = async (
|
||||
interaction: InteractionRequestCommand
|
||||
): Promise<InteractionResponse> => {
|
||||
return {
|
||||
type: InteractionCallbackType.CHANNEL_MESSAGE_WITH_SOURCE,
|
||||
data: {
|
||||
content: `Hey there, ${interaction.member?.nick || interaction.user?.username}`,
|
||||
},
|
||||
};
|
||||
};
|
86
packages/api/historic/interactions/pick-role.ts~
Normal file
86
packages/api/historic/interactions/pick-role.ts~
Normal file
|
@ -0,0 +1,86 @@
|
|||
import { selectRole } from '@roleypoly/interactions/utils/api';
|
||||
import {
|
||||
asyncPreflightEphemeral,
|
||||
asyncResponse,
|
||||
} from '@roleypoly/interactions/utils/interactions';
|
||||
import { invalid, mustBeInGuild } from '@roleypoly/interactions/utils/responses';
|
||||
import {
|
||||
InteractionCallbackType,
|
||||
InteractionFlags,
|
||||
InteractionRequestCommand,
|
||||
InteractionResponse,
|
||||
} from '@roleypoly/types';
|
||||
|
||||
export const pickRole = (mode: 'add' | 'remove') =>
|
||||
asyncResponse(
|
||||
async (interaction: InteractionRequestCommand): Promise<InteractionResponse> => {
|
||||
if (!interaction.guild_id) {
|
||||
return mustBeInGuild();
|
||||
}
|
||||
|
||||
const userID = interaction.member?.user?.id;
|
||||
if (!userID) {
|
||||
return mustBeInGuild();
|
||||
}
|
||||
|
||||
const roleID = interaction.data.options?.find(
|
||||
(option) => option.name === 'role'
|
||||
)?.value;
|
||||
if (!roleID) {
|
||||
return invalid();
|
||||
}
|
||||
|
||||
const code = await selectRole(mode, interaction.guild_id, userID, roleID);
|
||||
|
||||
if (code === 409) {
|
||||
return {
|
||||
type: InteractionCallbackType.CHANNEL_MESSAGE_WITH_SOURCE,
|
||||
data: {
|
||||
content: `:x: You ${mode === 'add' ? 'already' : "don't"} have that role.`,
|
||||
flags: InteractionFlags.EPHEMERAL,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (code === 404) {
|
||||
return {
|
||||
type: InteractionCallbackType.CHANNEL_MESSAGE_WITH_SOURCE,
|
||||
data: {
|
||||
content: `:x: <@&${roleID}> isn't pickable.`,
|
||||
flags: InteractionFlags.EPHEMERAL,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (code === 403) {
|
||||
return {
|
||||
type: InteractionCallbackType.CHANNEL_MESSAGE_WITH_SOURCE,
|
||||
data: {
|
||||
content: `:x: <@&${roleID}> has unsafe permissions.`,
|
||||
flags: InteractionFlags.EPHEMERAL,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (code !== 200) {
|
||||
return {
|
||||
type: InteractionCallbackType.CHANNEL_MESSAGE_WITH_SOURCE,
|
||||
data: {
|
||||
content: `:x: Something went wrong, please try again later.`,
|
||||
flags: InteractionFlags.EPHEMERAL,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: InteractionCallbackType.CHANNEL_MESSAGE_WITH_SOURCE,
|
||||
data: {
|
||||
content: `:white_check_mark: You ${
|
||||
mode === 'add' ? 'got' : 'removed'
|
||||
} the role: <@&${roleID}>`,
|
||||
flags: InteractionFlags.EPHEMERAL,
|
||||
},
|
||||
};
|
||||
},
|
||||
asyncPreflightEphemeral
|
||||
);
|
63
packages/api/historic/interactions/pickable-roles.ts~
Normal file
63
packages/api/historic/interactions/pickable-roles.ts~
Normal file
|
@ -0,0 +1,63 @@
|
|||
import { getPickableRoles } from '@roleypoly/interactions/utils/api';
|
||||
import { uiPublicURI } from '@roleypoly/interactions/utils/config';
|
||||
import {
|
||||
asyncPreflightEphemeral,
|
||||
asyncResponse,
|
||||
} from '@roleypoly/interactions/utils/interactions';
|
||||
import { mustBeInGuild } from '@roleypoly/interactions/utils/responses';
|
||||
import {
|
||||
CategoryType,
|
||||
Embed,
|
||||
InteractionCallbackType,
|
||||
InteractionFlags,
|
||||
InteractionRequestCommand,
|
||||
InteractionResponse,
|
||||
} from '@roleypoly/types';
|
||||
|
||||
export const pickableRoles = asyncResponse(
|
||||
async (interaction: InteractionRequestCommand): Promise<InteractionResponse> => {
|
||||
if (!interaction.guild_id) {
|
||||
return mustBeInGuild();
|
||||
}
|
||||
|
||||
const pickableRoles = await getPickableRoles(interaction.guild_id);
|
||||
const embed: Embed = {
|
||||
color: 0xab9b9a,
|
||||
fields: [],
|
||||
title: 'You can pick any of these roles with /pick-role',
|
||||
};
|
||||
|
||||
for (let categoryName in pickableRoles) {
|
||||
const { roles, type } = pickableRoles[categoryName];
|
||||
|
||||
embed.fields.push({
|
||||
name: `${categoryName}${type === CategoryType.Single ? ' *(pick one)*' : ''}`,
|
||||
value: roles.map((role) => `<@&${role}>`).join('\n'),
|
||||
inline: true,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
type: InteractionCallbackType.CHANNEL_MESSAGE_WITH_SOURCE,
|
||||
data: {
|
||||
embeds: [embed],
|
||||
flags: InteractionFlags.EPHEMERAL,
|
||||
components: [
|
||||
{
|
||||
type: 1,
|
||||
components: [
|
||||
// Link to Roleypoly
|
||||
{
|
||||
type: 2,
|
||||
label: 'Pick roles on your browser',
|
||||
url: `${uiPublicURI}/s/${interaction.guild_id}`,
|
||||
style: 5,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
},
|
||||
asyncPreflightEphemeral
|
||||
);
|
56
packages/api/historic/interactions/roleypoly.ts~
Normal file
56
packages/api/historic/interactions/roleypoly.ts~
Normal file
|
@ -0,0 +1,56 @@
|
|||
import { uiPublicURI } from '@roleypoly/interactions/utils/config';
|
||||
import {
|
||||
Embed,
|
||||
InteractionCallbackType,
|
||||
InteractionFlags,
|
||||
InteractionRequestCommand,
|
||||
InteractionResponse,
|
||||
} from '@roleypoly/types';
|
||||
|
||||
export const roleypoly = async (
|
||||
interaction: InteractionRequestCommand
|
||||
): Promise<InteractionResponse> => {
|
||||
if (interaction.guild_id) {
|
||||
return {
|
||||
type: InteractionCallbackType.CHANNEL_MESSAGE_WITH_SOURCE,
|
||||
data: {
|
||||
embeds: [
|
||||
{
|
||||
color: 0x453e3d,
|
||||
title: `:beginner: Hey there, ${
|
||||
interaction.member?.nick || interaction.member?.user?.username || 'friend'
|
||||
}!`,
|
||||
description: `Try these slash commands, or pick roles from your browser!`,
|
||||
fields: [
|
||||
{ name: 'See all the roles', value: '/pickable-roles' },
|
||||
{ name: 'Pick a role', value: '/pick-role' },
|
||||
{ name: 'Remove a role', value: '/remove-role' },
|
||||
],
|
||||
} as Embed,
|
||||
],
|
||||
components: [
|
||||
{
|
||||
type: 1,
|
||||
components: [
|
||||
// Link to Roleypoly
|
||||
{
|
||||
type: 2,
|
||||
label: `Pick roles on ${new URL(uiPublicURI).hostname}`,
|
||||
url: `${uiPublicURI}/s/${interaction.guild_id}`,
|
||||
style: 5,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
flags: InteractionFlags.EPHEMERAL,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: InteractionCallbackType.CHANNEL_MESSAGE_WITH_SOURCE,
|
||||
data: {
|
||||
content: `:beginner: Hey! I don't know what server you're in, so check out ${uiPublicURI}`,
|
||||
},
|
||||
};
|
||||
};
|
11
packages/api/jest.config.js
Normal file
11
packages/api/jest.config.js
Normal file
|
@ -0,0 +1,11 @@
|
|||
module.exports = {
|
||||
preset: 'ts-jest/presets/default-esm',
|
||||
name: 'api',
|
||||
testEnvironment: 'miniflare',
|
||||
globals: {
|
||||
'ts-jest': {
|
||||
tsconfig: '<rootDir>/tsconfig.test.json',
|
||||
useESM: true,
|
||||
},
|
||||
},
|
||||
};
|
31
packages/api/package.json
Normal file
31
packages/api/package.json
Normal file
|
@ -0,0 +1,31 @@
|
|||
{
|
||||
"name": "@roleypoly/api",
|
||||
"version": "0.1.0",
|
||||
"license": "MIT",
|
||||
"main": "./src/index.ts",
|
||||
"scripts": {
|
||||
"build": "yarn build:dev --minify",
|
||||
"build:dev": "esbuild --bundle --sourcemap --platform=node --format=esm --outdir=dist --out-extension:.js=.mjs ./src/index.ts",
|
||||
"lint:types": "tsc --noEmit",
|
||||
"posttest": "rm .env",
|
||||
"pretest": "cp ../../.env.example .env && yarn build",
|
||||
"start": "miniflare --watch --debug",
|
||||
"test": "jest"
|
||||
},
|
||||
"dependencies": {},
|
||||
"devDependencies": {
|
||||
"@cloudflare/workers-types": "^3.3.1",
|
||||
"@roleypoly/misc-utils": "*",
|
||||
"@roleypoly/types": "*",
|
||||
"@types/node": "^17.0.13",
|
||||
"esbuild": "^0.14.16",
|
||||
"itty-router": "^2.4.10",
|
||||
"jest-environment-miniflare": "^2.2.0",
|
||||
"lodash": "^4.17.21",
|
||||
"miniflare": "^2.2.0",
|
||||
"normalize-url": "^4.5.1",
|
||||
"ts-jest": "^27.1.3",
|
||||
"tweetnacl": "^1.0.3",
|
||||
"ulid-workers": "^2.1.0"
|
||||
}
|
||||
}
|
5
packages/api/src/guilds/audit-logging.spec.ts
Normal file
5
packages/api/src/guilds/audit-logging.spec.ts
Normal file
|
@ -0,0 +1,5 @@
|
|||
it('works', () => {
|
||||
expect(true).toBeTruthy();
|
||||
});
|
||||
|
||||
export {};
|
1
packages/api/src/guilds/audit-logging.ts
Normal file
1
packages/api/src/guilds/audit-logging.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export {};
|
208
packages/api/src/guilds/getters.spec.ts
Normal file
208
packages/api/src/guilds/getters.spec.ts
Normal file
|
@ -0,0 +1,208 @@
|
|||
jest.mock('../utils/discord');
|
||||
|
||||
import { CategoryType, Features, Guild, GuildData, RoleSafety } from '@roleypoly/types';
|
||||
import { APIGuild, discordFetch } from '../utils/discord';
|
||||
import { configContext } from '../utils/testHelpers';
|
||||
import { getGuild, getGuildData, getGuildMember, getPickableRoles } from './getters';
|
||||
|
||||
const mockDiscordFetch = discordFetch as jest.Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
mockDiscordFetch.mockReset();
|
||||
});
|
||||
|
||||
describe('getGuild', () => {
|
||||
it('gets a guild from discord', async () => {
|
||||
const [config] = configContext();
|
||||
const guild: APIGuild = {
|
||||
id: '123',
|
||||
name: 'test',
|
||||
icon: 'test',
|
||||
roles: [],
|
||||
};
|
||||
|
||||
mockDiscordFetch.mockReturnValue(guild);
|
||||
|
||||
const result = await getGuild(config, '123');
|
||||
|
||||
expect(result).toMatchObject(guild);
|
||||
});
|
||||
|
||||
it('gets a guild from cache automatically', async () => {
|
||||
const [config] = configContext();
|
||||
|
||||
const guild: APIGuild = {
|
||||
id: '123',
|
||||
name: 'test',
|
||||
icon: 'test',
|
||||
roles: [],
|
||||
};
|
||||
|
||||
await config.kv.guilds.put('guild/123', guild, config.retention.guild);
|
||||
mockDiscordFetch.mockReturnValue({ ...guild, name: 'test2' });
|
||||
|
||||
const result = await getGuild(config, '123');
|
||||
|
||||
expect(result).toMatchObject(guild);
|
||||
expect(result!.name).toBe('test');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getGuildData', () => {
|
||||
it('gets guild data from store', async () => {
|
||||
const [config] = configContext();
|
||||
|
||||
const guildData: GuildData = {
|
||||
id: '123',
|
||||
message: 'Hello world!',
|
||||
categories: [],
|
||||
features: Features.None,
|
||||
auditLogWebhook: null,
|
||||
accessControl: {
|
||||
allowList: [],
|
||||
blockList: [],
|
||||
blockPending: true,
|
||||
},
|
||||
};
|
||||
|
||||
await config.kv.guildData.put('123', guildData);
|
||||
|
||||
const result = await getGuildData(config, '123');
|
||||
|
||||
expect(result).toMatchObject(guildData);
|
||||
});
|
||||
|
||||
it('adds fields that are missing from the stored data', async () => {
|
||||
const [config] = configContext();
|
||||
|
||||
const guildData: Partial<GuildData> = {
|
||||
id: '123',
|
||||
message: 'Hello world!',
|
||||
categories: [],
|
||||
features: Features.None,
|
||||
};
|
||||
|
||||
await config.kv.guildData.put('123', guildData);
|
||||
const result = await getGuildData(config, '123');
|
||||
|
||||
expect(result).toMatchObject({
|
||||
...guildData,
|
||||
auditLogWebhook: null,
|
||||
accessControl: expect.any(Object),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getGuildMember', () => {
|
||||
it('gets a member from discord', async () => {
|
||||
const [config] = configContext();
|
||||
|
||||
const member = {
|
||||
roles: [],
|
||||
pending: false,
|
||||
nick: 'test',
|
||||
};
|
||||
|
||||
mockDiscordFetch.mockReturnValue(member);
|
||||
|
||||
const result = await getGuildMember(config, '123', '123');
|
||||
|
||||
expect(result).toMatchObject(member);
|
||||
});
|
||||
|
||||
it('gets a member from cache automatically', async () => {
|
||||
const [config] = configContext();
|
||||
|
||||
const member = {
|
||||
roles: [],
|
||||
pending: false,
|
||||
nick: 'test2',
|
||||
};
|
||||
|
||||
await config.kv.guilds.put('member/123/123', member, config.retention.guild);
|
||||
mockDiscordFetch.mockReturnValue({ ...member, nick: 'test' });
|
||||
|
||||
const result = await getGuildMember(config, '123', '123');
|
||||
|
||||
expect(result).toMatchObject(member);
|
||||
expect(result!.nick).toBe('test2');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getPickableRoles', () => {
|
||||
it('returns all pickable roles for a given guild', async () => {
|
||||
const guildData: GuildData = {
|
||||
id: '123',
|
||||
message: 'Hello world!',
|
||||
categories: [
|
||||
{
|
||||
id: '123',
|
||||
name: 'test',
|
||||
position: 0,
|
||||
roles: ['role-1', 'role-2', 'role-unsafe'],
|
||||
hidden: false,
|
||||
type: CategoryType.Multi,
|
||||
},
|
||||
{
|
||||
id: '123',
|
||||
name: 'test',
|
||||
position: 0,
|
||||
roles: ['role-3', 'role-4'],
|
||||
hidden: true,
|
||||
type: CategoryType.Multi,
|
||||
},
|
||||
],
|
||||
features: Features.None,
|
||||
auditLogWebhook: null,
|
||||
accessControl: {
|
||||
allowList: [],
|
||||
blockList: [],
|
||||
blockPending: true,
|
||||
},
|
||||
};
|
||||
|
||||
const guild: Guild = {
|
||||
id: '123',
|
||||
name: 'test',
|
||||
icon: '',
|
||||
roles: [
|
||||
{
|
||||
id: 'role-1',
|
||||
name: 'test',
|
||||
position: 0,
|
||||
managed: false,
|
||||
color: 0,
|
||||
safety: RoleSafety.Safe,
|
||||
permissions: '0',
|
||||
},
|
||||
{
|
||||
id: 'role-3',
|
||||
name: 'test',
|
||||
position: 0,
|
||||
managed: false,
|
||||
color: 0,
|
||||
safety: RoleSafety.Safe,
|
||||
permissions: '0',
|
||||
},
|
||||
{
|
||||
id: 'role-unsafe',
|
||||
name: 'test',
|
||||
position: 0,
|
||||
managed: false,
|
||||
color: 0,
|
||||
safety: RoleSafety.DangerousPermissions,
|
||||
permissions: '0',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = getPickableRoles(guildData, guild);
|
||||
|
||||
expect(result).toMatchObject([
|
||||
{
|
||||
category: guildData.categories[0],
|
||||
roles: [guild.roles[0]],
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
196
packages/api/src/guilds/getters.ts
Normal file
196
packages/api/src/guilds/getters.ts
Normal file
|
@ -0,0 +1,196 @@
|
|||
import { Config } from '@roleypoly/api/src/utils/config';
|
||||
import {
|
||||
APIGuild,
|
||||
APIMember,
|
||||
APIRole,
|
||||
AuthType,
|
||||
discordFetch,
|
||||
getHighestRole,
|
||||
} from '@roleypoly/api/src/utils/discord';
|
||||
import { evaluatePermission, permissions } from '@roleypoly/misc-utils/hasPermission';
|
||||
import {
|
||||
Category,
|
||||
Features,
|
||||
Guild,
|
||||
GuildData,
|
||||
Member,
|
||||
OwnRoleInfo,
|
||||
Role,
|
||||
RoleSafety,
|
||||
} from '@roleypoly/types';
|
||||
|
||||
export const getGuild = async (
|
||||
config: Config,
|
||||
id: string,
|
||||
forceMiss?: boolean
|
||||
): Promise<(Guild & OwnRoleInfo) | null> =>
|
||||
config.kv.guilds.cacheThrough(
|
||||
`guild/${id}`,
|
||||
async () => {
|
||||
const guildRaw = await discordFetch<APIGuild>(
|
||||
`/guilds/${id}`,
|
||||
config.botToken,
|
||||
AuthType.Bot
|
||||
);
|
||||
|
||||
if (!guildRaw) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const botMemberRoles =
|
||||
(await getGuildMember(config, id, config.botClientID))?.roles || [];
|
||||
|
||||
const highestRolePosition =
|
||||
getHighestRole(
|
||||
botMemberRoles
|
||||
.map((r) => guildRaw.roles.find((r2) => r2.id === r))
|
||||
.filter((x) => x !== undefined) as APIRole[]
|
||||
)?.position || -1;
|
||||
|
||||
const roles = guildRaw.roles.map<Role>((role) => ({
|
||||
id: role.id,
|
||||
name: role.name,
|
||||
color: role.color,
|
||||
managed: role.managed,
|
||||
position: role.position,
|
||||
permissions: role.permissions,
|
||||
safety: calculateRoleSafety(role, highestRolePosition),
|
||||
}));
|
||||
|
||||
const guild: Guild & OwnRoleInfo = {
|
||||
id,
|
||||
name: guildRaw.name,
|
||||
icon: guildRaw.icon,
|
||||
roles,
|
||||
highestRolePosition,
|
||||
};
|
||||
|
||||
return guild;
|
||||
},
|
||||
config.retention.guild,
|
||||
forceMiss
|
||||
);
|
||||
|
||||
export const getGuildData = async (config: Config, id: string): Promise<GuildData> => {
|
||||
const guildData = await config.kv.guildData.get<GuildData>(id);
|
||||
const empty = {
|
||||
id,
|
||||
message: '',
|
||||
categories: [],
|
||||
features: Features.None,
|
||||
auditLogWebhook: null,
|
||||
accessControl: {
|
||||
allowList: [],
|
||||
blockList: [],
|
||||
blockPending: true,
|
||||
},
|
||||
};
|
||||
|
||||
if (!guildData) {
|
||||
await config.kv.guildData.put(id, empty);
|
||||
return empty;
|
||||
}
|
||||
|
||||
return {
|
||||
...empty,
|
||||
...guildData,
|
||||
};
|
||||
};
|
||||
|
||||
export const getGuildMember = async (
|
||||
config: Config,
|
||||
serverID: string,
|
||||
userID: string,
|
||||
forceMiss?: boolean,
|
||||
overrideRetention?: number // allows for own-member to be cached as long as it's used.
|
||||
): Promise<Member | null> =>
|
||||
config.kv.guilds.cacheThrough(
|
||||
`member/${serverID}/${userID}`,
|
||||
async () => {
|
||||
const discordMember = await discordFetch<APIMember>(
|
||||
`/guilds/${serverID}/members/${userID}`,
|
||||
config.botToken,
|
||||
AuthType.Bot
|
||||
);
|
||||
|
||||
if (!discordMember) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
guildid: serverID,
|
||||
roles: discordMember.roles,
|
||||
pending: discordMember.pending,
|
||||
nick: discordMember.nick,
|
||||
};
|
||||
},
|
||||
overrideRetention || config.retention.member,
|
||||
forceMiss
|
||||
);
|
||||
|
||||
export const updateGuildMember = async (
|
||||
config: Config,
|
||||
serverID: string,
|
||||
member: APIMember
|
||||
): Promise<void> => {
|
||||
config.kv.guilds.put(
|
||||
`members/${serverID}/${member.user.id}`,
|
||||
{
|
||||
guildid: serverID,
|
||||
roles: member.roles,
|
||||
pending: member.pending,
|
||||
nick: member.nick,
|
||||
},
|
||||
config.retention.member
|
||||
);
|
||||
};
|
||||
|
||||
const calculateRoleSafety = (role: Role | APIRole, highestBotRolePosition: number) => {
|
||||
let safety = RoleSafety.Safe;
|
||||
|
||||
if (role.managed) {
|
||||
safety |= RoleSafety.ManagedRole;
|
||||
}
|
||||
|
||||
if (role.position > highestBotRolePosition) {
|
||||
safety |= RoleSafety.HigherThanBot;
|
||||
}
|
||||
|
||||
const permBigInt = BigInt(role.permissions);
|
||||
if (
|
||||
evaluatePermission(permBigInt, permissions.ADMINISTRATOR) ||
|
||||
evaluatePermission(permBigInt, permissions.MANAGE_ROLES)
|
||||
) {
|
||||
safety |= RoleSafety.DangerousPermissions;
|
||||
}
|
||||
|
||||
return safety;
|
||||
};
|
||||
|
||||
export const getPickableRoles = (
|
||||
guildData: GuildData,
|
||||
guild: Guild
|
||||
): { category: Category; roles: Role[] }[] => {
|
||||
const pickableRoles: { category: Category; roles: Role[] }[] = [];
|
||||
|
||||
for (const category of guildData.categories) {
|
||||
if (category.roles.length === 0 || category.hidden) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const roles = category.roles
|
||||
.map((roleID) => guild.roles.find((r) => r.id === roleID))
|
||||
.filter((role) => role !== undefined && role.safety === RoleSafety.Safe) as Role[];
|
||||
|
||||
if (roles.length > 0) {
|
||||
pickableRoles.push({
|
||||
category,
|
||||
roles,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
console.log({ pickableRoles });
|
||||
|
||||
return pickableRoles;
|
||||
};
|
78
packages/api/src/guilds/middleware.spec.ts
Normal file
78
packages/api/src/guilds/middleware.spec.ts
Normal file
|
@ -0,0 +1,78 @@
|
|||
import { Router } from 'itty-router';
|
||||
import { json } from '../utils/response';
|
||||
import { configContext, makeSession } from '../utils/testHelpers';
|
||||
import { requireEditor } from './middleware';
|
||||
|
||||
describe('requireEditor', () => {
|
||||
it('continues the request when user is an editor', async () => {
|
||||
const testFn = jest.fn();
|
||||
const [config, context] = configContext();
|
||||
const session = await makeSession(config);
|
||||
const router = Router();
|
||||
|
||||
router.all('*', requireEditor).get('/:guildId', (request, context) => {
|
||||
testFn();
|
||||
return json({});
|
||||
});
|
||||
|
||||
const response = await router.handle(
|
||||
new Request(`http://test.local/${session.guilds[1].id}`, {
|
||||
headers: {
|
||||
authorization: `Bearer ${session.sessionID}`,
|
||||
},
|
||||
}),
|
||||
{ ...context, session, params: { guildId: session.guilds[1].id } }
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(testFn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('403s the request when user is not an editor', async () => {
|
||||
const testFn = jest.fn();
|
||||
const [config, context] = configContext();
|
||||
const session = await makeSession(config);
|
||||
const router = Router();
|
||||
|
||||
router.all('*', requireEditor).get('/:guildId', (request, context) => {
|
||||
testFn();
|
||||
return json({});
|
||||
});
|
||||
|
||||
const response = await router.handle(
|
||||
new Request(`http://test.local/${session.guilds[0].id}`, {
|
||||
headers: {
|
||||
authorization: `Bearer ${session.sessionID}`,
|
||||
},
|
||||
}),
|
||||
{ ...context, session, params: { guildId: session.guilds[0].id } }
|
||||
);
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(testFn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('404s the request when the guild isnt in session', async () => {
|
||||
const testFn = jest.fn();
|
||||
const [config, context] = configContext();
|
||||
const session = await makeSession(config);
|
||||
const router = Router();
|
||||
|
||||
router.all('*', requireEditor).get('/:guildId', (request, context) => {
|
||||
testFn();
|
||||
return json({});
|
||||
});
|
||||
|
||||
const response = await router.handle(
|
||||
new Request(`http://test.local/invalid-session-id`, {
|
||||
headers: {
|
||||
authorization: `Bearer ${session.sessionID}`,
|
||||
},
|
||||
}),
|
||||
{ ...context, session, params: { guildId: 'invalid-session-id' } }
|
||||
);
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(testFn).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
47
packages/api/src/guilds/middleware.ts
Normal file
47
packages/api/src/guilds/middleware.ts
Normal file
|
@ -0,0 +1,47 @@
|
|||
import { Context, RoleypolyMiddleware } from '@roleypoly/api/src/utils/context';
|
||||
import {
|
||||
engineeringProblem,
|
||||
forbidden,
|
||||
notFound,
|
||||
} from '@roleypoly/api/src/utils/response';
|
||||
import { UserGuildPermissions } from '@roleypoly/types';
|
||||
|
||||
export const requireEditor: RoleypolyMiddleware = async (
|
||||
request: Request,
|
||||
context: Context
|
||||
) => {
|
||||
if (!context.params.guildId) {
|
||||
return engineeringProblem('params not set up correctly');
|
||||
}
|
||||
|
||||
if (!context.session) {
|
||||
return engineeringProblem('middleware not set up correctly');
|
||||
}
|
||||
|
||||
const guild = context.session.guilds.find((g) => g.id === context.params.guildId);
|
||||
if (!guild) {
|
||||
return notFound(); // 404 because we don't want enumeration of guilds
|
||||
}
|
||||
|
||||
if (guild.permissionLevel === UserGuildPermissions.User) {
|
||||
return forbidden();
|
||||
}
|
||||
};
|
||||
|
||||
export const requireMember: RoleypolyMiddleware = async (
|
||||
request: Request,
|
||||
context: Context
|
||||
) => {
|
||||
if (!context.params.guildId) {
|
||||
return engineeringProblem('params not set up correctly');
|
||||
}
|
||||
|
||||
if (!context.session) {
|
||||
return engineeringProblem('middleware not set up correctly');
|
||||
}
|
||||
|
||||
const guild = context.session.guilds.find((g) => g.id === context.params.guildId);
|
||||
if (!guild) {
|
||||
return notFound(); // 404 because we don't want enumeration of guilds
|
||||
}
|
||||
};
|
99
packages/api/src/index.ts
Normal file
99
packages/api/src/index.ts
Normal file
|
@ -0,0 +1,99 @@
|
|||
import { requireEditor, requireMember } from '@roleypoly/api/src/guilds/middleware';
|
||||
import { authBot } from '@roleypoly/api/src/routes/auth/bot';
|
||||
import { authCallback } from '@roleypoly/api/src/routes/auth/callback';
|
||||
import { authSessionDelete } from '@roleypoly/api/src/routes/auth/delete-session';
|
||||
import { authSession } from '@roleypoly/api/src/routes/auth/session';
|
||||
import { guildsGuild } from '@roleypoly/api/src/routes/guilds/guild';
|
||||
import { guildsCacheDelete } from '@roleypoly/api/src/routes/guilds/guild-cache-delete';
|
||||
import { guildsRolesPut } from '@roleypoly/api/src/routes/guilds/guild-roles-put';
|
||||
import { guildsGuildPatch } from '@roleypoly/api/src/routes/guilds/guilds-patch';
|
||||
import { guildsSlug } from '@roleypoly/api/src/routes/guilds/slug';
|
||||
import { handleInteraction } from '@roleypoly/api/src/routes/interactions/interactions';
|
||||
import {
|
||||
requireSession,
|
||||
withAuthMode,
|
||||
withSession,
|
||||
} from '@roleypoly/api/src/sessions/middleware';
|
||||
import { injectParams } from '@roleypoly/api/src/utils/request';
|
||||
import { Router } from 'itty-router';
|
||||
import { authBounce } from './routes/auth/bounce';
|
||||
import { Environment, parseEnvironment } from './utils/config';
|
||||
import { Context, RoleypolyHandler } from './utils/context';
|
||||
import { corsHeaders, json, notFound, serverError } from './utils/response';
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.all('*', withAuthMode);
|
||||
|
||||
router.get('/auth/bot', authBot);
|
||||
router.get('/auth/bounce', authBounce);
|
||||
router.get('/auth/callback', authCallback);
|
||||
router.get('/auth/session', withSession, requireSession, authSession);
|
||||
router.delete('/auth/session', withSession, requireSession, authSessionDelete);
|
||||
|
||||
const guildsCommon = [injectParams, withSession, requireSession, requireMember];
|
||||
router.get('/guilds/:guildId', ...guildsCommon, guildsGuild);
|
||||
router.patch('/guilds/:guildId', ...guildsCommon, requireEditor, guildsGuildPatch);
|
||||
router.delete(
|
||||
'/guilds/:guildId/cache',
|
||||
...guildsCommon,
|
||||
requireEditor,
|
||||
guildsCacheDelete
|
||||
);
|
||||
router.put('/guilds/:guildId/roles', ...guildsCommon, guildsRolesPut);
|
||||
|
||||
router.get('/guilds/:guildId/slug', injectParams, withSession, guildsSlug);
|
||||
|
||||
router.post('/interactions', handleInteraction);
|
||||
|
||||
router.get('/', ((request: Request, { config }: Context) =>
|
||||
json({
|
||||
__warning: '🦊',
|
||||
this: 'is',
|
||||
a: 'fox-based',
|
||||
web: 'application',
|
||||
please: 'be',
|
||||
aware: 'of',
|
||||
your: 'surroundings',
|
||||
warning__: '🦊',
|
||||
meta: config.uiPublicURI,
|
||||
version: 2,
|
||||
})) as RoleypolyHandler);
|
||||
|
||||
router.options('*', (request: Request) => {
|
||||
return new Response(null, {
|
||||
headers: {
|
||||
...corsHeaders,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
router.all('/*', notFound);
|
||||
|
||||
const scrubURL = (urlStr: string) => {
|
||||
const url = new URL(urlStr);
|
||||
url.searchParams.delete('code');
|
||||
url.searchParams.delete('state');
|
||||
|
||||
return url.toString();
|
||||
};
|
||||
|
||||
export default {
|
||||
async fetch(request: Request, env: Environment, event: Context['fetchContext']) {
|
||||
const config = parseEnvironment(env);
|
||||
const context: Context = {
|
||||
config,
|
||||
fetchContext: {
|
||||
waitUntil: event.waitUntil.bind(event),
|
||||
},
|
||||
authMode: {
|
||||
type: 'anonymous',
|
||||
},
|
||||
params: {},
|
||||
};
|
||||
console.log(`${request.method} ${scrubURL(request.url)}`);
|
||||
return router
|
||||
.handle(request, context)
|
||||
.catch((e: Error) => (!e ? notFound() : serverError(e)));
|
||||
},
|
||||
};
|
25
packages/api/src/routes/auth/bot.spec.ts
Normal file
25
packages/api/src/routes/auth/bot.spec.ts
Normal file
|
@ -0,0 +1,25 @@
|
|||
import { makeRequest } from '../../utils/testHelpers';
|
||||
|
||||
describe('GET /auth/bot', () => {
|
||||
it('redirects to a Discord OAuth bot flow url', async () => {
|
||||
const response = await makeRequest('GET', '/auth/bot', undefined, {
|
||||
BOT_CLIENT_ID: 'test123',
|
||||
});
|
||||
|
||||
expect(response.status).toBe(303);
|
||||
expect(response.headers.get('Location')).toContain(
|
||||
'https://discord.com/api/oauth2/authorize?client_id=test123&scope=bot%20applications.commands&permissions=268435456'
|
||||
);
|
||||
});
|
||||
|
||||
it('redirects to a Discord OAuth bot flow url, forcing a guild when set', async () => {
|
||||
const response = await makeRequest('GET', '/auth/bot?guild=123456', undefined, {
|
||||
BOT_CLIENT_ID: 'test123',
|
||||
});
|
||||
|
||||
expect(response.status).toBe(303);
|
||||
expect(response.headers.get('Location')).toContain(
|
||||
'https://discord.com/api/oauth2/authorize?client_id=test123&scope=bot%20applications.commands&permissions=268435456&guild_id=123456&disable_guild_select=true'
|
||||
);
|
||||
});
|
||||
});
|
43
packages/api/src/routes/auth/bot.ts
Normal file
43
packages/api/src/routes/auth/bot.ts
Normal file
|
@ -0,0 +1,43 @@
|
|||
import { Context, RoleypolyHandler } from '@roleypoly/api/src/utils/context';
|
||||
import { seeOther } from '@roleypoly/api/src/utils/response';
|
||||
|
||||
const validGuildID = /^[0-9]+$/;
|
||||
|
||||
type URLParams = {
|
||||
clientID: string;
|
||||
permissions: number;
|
||||
guildID?: string;
|
||||
scopes: string[];
|
||||
};
|
||||
|
||||
const buildURL = (params: URLParams) => {
|
||||
let url = `https://discord.com/api/oauth2/authorize?client_id=${
|
||||
params.clientID
|
||||
}&scope=${params.scopes.join('%20')}&permissions=${params.permissions}`;
|
||||
|
||||
if (params.guildID) {
|
||||
url += `&guild_id=${params.guildID}&disable_guild_select=true`;
|
||||
}
|
||||
|
||||
return url;
|
||||
};
|
||||
|
||||
export const authBot: RoleypolyHandler = (
|
||||
request: Request,
|
||||
{ config }: Context
|
||||
): Response => {
|
||||
let guildID = new URL(request.url).searchParams.get('guild') || '';
|
||||
|
||||
if (guildID && !validGuildID.test(guildID)) {
|
||||
guildID = '';
|
||||
}
|
||||
|
||||
return seeOther(
|
||||
buildURL({
|
||||
clientID: config.botClientID,
|
||||
permissions: 268435456, // Send messages + manage roles
|
||||
guildID,
|
||||
scopes: ['bot', 'applications.commands'],
|
||||
})
|
||||
);
|
||||
};
|
54
packages/api/src/routes/auth/bounce.spec.ts
Normal file
54
packages/api/src/routes/auth/bounce.spec.ts
Normal file
|
@ -0,0 +1,54 @@
|
|||
import { StateSession } from '@roleypoly/types';
|
||||
import { getBindings, makeRequest } from '../../utils/testHelpers';
|
||||
|
||||
describe('GET /auth/bounce', () => {
|
||||
it('should return a redirect to Discord OAuth', async () => {
|
||||
const response = await makeRequest('GET', '/auth/bounce', undefined, {
|
||||
BOT_CLIENT_ID: 'test123',
|
||||
API_PUBLIC_URI: 'http://test.local/',
|
||||
});
|
||||
expect(response.status).toBe(303);
|
||||
expect(response.headers.get('Location')).toContain(
|
||||
'https://discord.com/api/oauth2/authorize?client_id=test123&response_type=code&scope=identify%20guilds&redirect_uri=http%3A%2F%2Ftest.local%2Fauth%2Fcallback&state='
|
||||
);
|
||||
});
|
||||
|
||||
it('should store a state-session', async () => {
|
||||
const response = await makeRequest('GET', '/auth/bounce', undefined, {
|
||||
BOT_CLIENT_ID: 'test123',
|
||||
API_PUBLIC_URI: 'http://test.local/',
|
||||
});
|
||||
expect(response.status).toBe(303);
|
||||
const url = new URL(response.headers.get('Location') || '');
|
||||
const state = url.searchParams.get('state');
|
||||
|
||||
const environment = getBindings();
|
||||
const session = await environment.KV_SESSIONS.get(`state_${state}`, 'json');
|
||||
expect(session).not.toBeUndefined();
|
||||
});
|
||||
|
||||
test.each([
|
||||
['http://web.test.local', 'http://web.test.local', 'http://web.test.local'],
|
||||
['http://*.test.local', 'http://web.test.local', 'http://web.test.local'],
|
||||
['http://other.test.local', 'http://web.test.local', undefined],
|
||||
])(
|
||||
'should process callback hosts when set to %s',
|
||||
async (allowlist, input, expected) => {
|
||||
const response = await makeRequest('GET', `/auth/bounce?cbh=${input}`, undefined, {
|
||||
BOT_CLIENT_ID: 'test123',
|
||||
API_PUBLIC_URI: 'http://api.test.local',
|
||||
ALLOWED_CALLBACK_HOSTS: allowlist,
|
||||
});
|
||||
expect(response.status).toBe(303);
|
||||
const url = new URL(response.headers.get('Location') || '');
|
||||
const state = url.searchParams.get('state');
|
||||
|
||||
const environment = getBindings();
|
||||
const session = (await environment.KV_SESSIONS.get(`state_${state}`, 'json')) as {
|
||||
data: StateSession;
|
||||
};
|
||||
expect(session).not.toBeUndefined();
|
||||
expect(session?.data.callbackHost).toBe(expected);
|
||||
}
|
||||
);
|
||||
});
|
64
packages/api/src/routes/auth/bounce.ts
Normal file
64
packages/api/src/routes/auth/bounce.ts
Normal file
|
@ -0,0 +1,64 @@
|
|||
import { setupStateSession } from '@roleypoly/api/src/sessions/state';
|
||||
import { Config } from '@roleypoly/api/src/utils/config';
|
||||
import { Context, RoleypolyHandler } from '@roleypoly/api/src/utils/context';
|
||||
import { getQuery } from '@roleypoly/api/src/utils/request';
|
||||
import { seeOther } from '@roleypoly/api/src/utils/response';
|
||||
import { StateSession } from '@roleypoly/types';
|
||||
|
||||
type URLParams = {
|
||||
clientID: string;
|
||||
redirectURI: string;
|
||||
state: string;
|
||||
};
|
||||
|
||||
export const buildURL = (params: URLParams) =>
|
||||
`https://discord.com/api/oauth2/authorize?client_id=${
|
||||
params.clientID
|
||||
}&response_type=code&scope=identify%20guilds&redirect_uri=${encodeURIComponent(
|
||||
params.redirectURI
|
||||
)}&state=${params.state}`;
|
||||
|
||||
const hostMatch = (a: string, b: string): boolean => {
|
||||
const aURL = new URL(a);
|
||||
const bURL = new URL(b);
|
||||
|
||||
return aURL.host === bURL.host && aURL.protocol === bURL.protocol;
|
||||
};
|
||||
|
||||
const wildcardMatch = (wildcard: string, host: string): boolean => {
|
||||
const aURL = new URL(wildcard);
|
||||
const bURL = new URL(host);
|
||||
|
||||
const regex = new RegExp(aURL.hostname.replace('*', '[a-z0-9-]+'));
|
||||
return regex.test(bURL.hostname);
|
||||
};
|
||||
|
||||
export const isAllowedCallbackHost = (config: Config, host: string): boolean => {
|
||||
return (
|
||||
hostMatch(host, config.apiPublicURI) ||
|
||||
config.allowedCallbackHosts.some((allowedHost) =>
|
||||
allowedHost.includes('*')
|
||||
? wildcardMatch(allowedHost, host)
|
||||
: hostMatch(allowedHost, host)
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
export const authBounce: RoleypolyHandler = async (
|
||||
request: Request,
|
||||
{ config }: Context
|
||||
) => {
|
||||
const stateSessionData: StateSession = {};
|
||||
|
||||
const { cbh: callbackHost } = getQuery(request);
|
||||
if (callbackHost && isAllowedCallbackHost(config, callbackHost)) {
|
||||
stateSessionData.callbackHost = callbackHost;
|
||||
}
|
||||
|
||||
const state = await setupStateSession(config, stateSessionData);
|
||||
|
||||
const redirectURI = `${config.apiPublicURI}/auth/callback`;
|
||||
const clientID = config.botClientID;
|
||||
|
||||
return seeOther(buildURL({ state, redirectURI, clientID }));
|
||||
};
|
93
packages/api/src/routes/auth/callback.spec.ts
Normal file
93
packages/api/src/routes/auth/callback.spec.ts
Normal file
|
@ -0,0 +1,93 @@
|
|||
jest.mock('../../utils/discord');
|
||||
jest.mock('../../sessions/create');
|
||||
|
||||
import { createSession } from '../../sessions/create';
|
||||
import { setupStateSession } from '../../sessions/state';
|
||||
import { parseEnvironment } from '../../utils/config';
|
||||
import { discordFetch } from '../../utils/discord';
|
||||
import { getBindings, makeRequest } from '../../utils/testHelpers';
|
||||
|
||||
const mockDiscordFetch = discordFetch as jest.Mock;
|
||||
const mockCreateSession = createSession as jest.Mock;
|
||||
|
||||
describe('GET /auth/callback', () => {
|
||||
it('should ask Discord to trade code for tokens', async () => {
|
||||
const env = getBindings();
|
||||
const config = parseEnvironment(env);
|
||||
const stateID = await setupStateSession(config, {});
|
||||
|
||||
const tokens = {
|
||||
access_token: 'test-access-token',
|
||||
refresh_token: 'test-refresh-token',
|
||||
expires_in: 3600,
|
||||
scope: 'identify guilds',
|
||||
token_type: 'Bearer',
|
||||
};
|
||||
mockDiscordFetch.mockReturnValueOnce(tokens);
|
||||
|
||||
mockCreateSession.mockReturnValueOnce({
|
||||
sessionID: 'test-session-id',
|
||||
tokens,
|
||||
user: {
|
||||
id: 'test-user-id',
|
||||
username: 'test-username',
|
||||
discriminator: 'test-discriminator',
|
||||
avatar: 'test-avatar',
|
||||
bot: false,
|
||||
},
|
||||
guilds: [],
|
||||
});
|
||||
|
||||
const response = await makeRequest(
|
||||
'GET',
|
||||
`/auth/callback?state=${stateID}&code=1234`,
|
||||
undefined,
|
||||
{
|
||||
BOT_CLIENT_ID: 'test123',
|
||||
BOT_CLIENT_SECRET: 'test456',
|
||||
API_PUBLIC_URI: 'http://test.local/',
|
||||
UI_PUBLIC_URI: 'http://web.test.local/',
|
||||
}
|
||||
);
|
||||
|
||||
expect(response.status).toBe(303);
|
||||
expect(mockDiscordFetch).toBeCalledTimes(1);
|
||||
expect(mockCreateSession).toBeCalledWith(expect.any(Object), tokens);
|
||||
expect(response.headers.get('Location')).toContain(
|
||||
'http://web.test.local/machinery/new-session#/test-session-id'
|
||||
);
|
||||
});
|
||||
|
||||
it('will fail if state is invalid', async () => {
|
||||
const response = await makeRequest(
|
||||
'GET',
|
||||
`/auth/callback?state=invalid-state&code=1234`,
|
||||
undefined,
|
||||
{
|
||||
BOT_CLIENT_ID: 'test123',
|
||||
BOT_CLIENT_SECRET: 'test456',
|
||||
API_PUBLIC_URI: 'http://test.local/',
|
||||
UI_PUBLIC_URI: 'http://web.test.local/',
|
||||
}
|
||||
);
|
||||
|
||||
expect(response.status).toBe(303);
|
||||
expect(response.headers.get('Location')).toContain(
|
||||
'http://web.test.local/error/authFailure?extra=state invalid'
|
||||
);
|
||||
});
|
||||
|
||||
it('will fail if state is missing', async () => {
|
||||
const response = await makeRequest('GET', `/auth/callback?code=1234`, undefined, {
|
||||
BOT_CLIENT_ID: 'test123',
|
||||
BOT_CLIENT_SECRET: 'test456',
|
||||
API_PUBLIC_URI: 'http://test.local/',
|
||||
UI_PUBLIC_URI: 'http://web.test.local/',
|
||||
});
|
||||
|
||||
expect(response.status).toBe(303);
|
||||
expect(response.headers.get('Location')).toContain(
|
||||
'http://web.test.local/error/authFailure?extra=state invalid'
|
||||
);
|
||||
});
|
||||
});
|
77
packages/api/src/routes/auth/callback.ts
Normal file
77
packages/api/src/routes/auth/callback.ts
Normal file
|
@ -0,0 +1,77 @@
|
|||
import { isAllowedCallbackHost } from '@roleypoly/api/src/routes/auth/bounce';
|
||||
import { createSession } from '@roleypoly/api/src/sessions/create';
|
||||
import { getStateSession } from '@roleypoly/api/src/sessions/state';
|
||||
import { Context, RoleypolyHandler } from '@roleypoly/api/src/utils/context';
|
||||
import { AuthType, discordFetch } from '@roleypoly/api/src/utils/discord';
|
||||
import { dateFromID } from '@roleypoly/api/src/utils/id';
|
||||
import { formDataRequest, getQuery } from '@roleypoly/api/src/utils/request';
|
||||
import { seeOther } from '@roleypoly/api/src/utils/response';
|
||||
import { AuthTokenResponse, StateSession } from '@roleypoly/types';
|
||||
import normalizeUrl from 'normalize-url';
|
||||
|
||||
const authFailure = (uiPublicURI: string, extra?: string) =>
|
||||
seeOther(uiPublicURI + `/error/authFailure${extra ? `?extra=${extra}` : ''}`);
|
||||
|
||||
export const authCallback: RoleypolyHandler = async (
|
||||
request: Request,
|
||||
{ config }: Context
|
||||
) => {
|
||||
let bounceBaseUrl = config.uiPublicURI;
|
||||
|
||||
const { state: stateValue, code } = getQuery(request);
|
||||
|
||||
if (stateValue === null) {
|
||||
return authFailure('state missing');
|
||||
}
|
||||
|
||||
try {
|
||||
const stateTime = dateFromID(stateValue);
|
||||
const stateExpiry = stateTime + 1000 * config.retention.session;
|
||||
const currentTime = Date.now();
|
||||
|
||||
if (currentTime > stateExpiry) {
|
||||
return authFailure('state expired');
|
||||
}
|
||||
|
||||
const stateSession = await getStateSession<StateSession>(config, stateValue);
|
||||
if (
|
||||
stateSession?.callbackHost &&
|
||||
isAllowedCallbackHost(config, stateSession.callbackHost)
|
||||
) {
|
||||
bounceBaseUrl = stateSession.callbackHost;
|
||||
}
|
||||
} catch (e) {
|
||||
return authFailure(config.uiPublicURI, 'state invalid');
|
||||
}
|
||||
|
||||
if (!code) {
|
||||
return authFailure(config.uiPublicURI, 'code missing');
|
||||
}
|
||||
|
||||
const response = await discordFetch<AuthTokenResponse>(
|
||||
`/oauth2/token`,
|
||||
'',
|
||||
AuthType.None,
|
||||
formDataRequest({
|
||||
client_id: config.botClientID,
|
||||
client_secret: config.botClientSecret,
|
||||
grant_type: 'authorization_code',
|
||||
code,
|
||||
redirect_uri: config.apiPublicURI + '/auth/callback',
|
||||
})
|
||||
);
|
||||
|
||||
if (!response) {
|
||||
return authFailure(config.uiPublicURI, 'code auth failure');
|
||||
}
|
||||
|
||||
const session = await createSession(config, response);
|
||||
if (!session) {
|
||||
return authFailure(config.uiPublicURI, 'session setup failure');
|
||||
}
|
||||
|
||||
const nextURL = normalizeUrl(
|
||||
bounceBaseUrl + '/machinery/new-session/#/' + session.sessionID
|
||||
);
|
||||
return seeOther(nextURL);
|
||||
};
|
63
packages/api/src/routes/auth/delete-session.spec.ts
Normal file
63
packages/api/src/routes/auth/delete-session.spec.ts
Normal file
|
@ -0,0 +1,63 @@
|
|||
jest.mock('../../utils/discord');
|
||||
|
||||
import { SessionData } from '@roleypoly/types';
|
||||
import { parseEnvironment } from '../../utils/config';
|
||||
import { AuthType, discordFetch } from '../../utils/discord';
|
||||
import { formDataRequest } from '../../utils/request';
|
||||
import { getBindings, makeRequest } from '../../utils/testHelpers';
|
||||
|
||||
const mockDiscordFetch = discordFetch as jest.Mock;
|
||||
|
||||
describe('DELETE /auth/session', () => {
|
||||
it('deletes the current user session when it is valid', async () => {
|
||||
const config = parseEnvironment(getBindings());
|
||||
|
||||
const session: SessionData = {
|
||||
sessionID: 'test-session-id',
|
||||
user: {
|
||||
id: 'test-user-id',
|
||||
username: 'test-username',
|
||||
discriminator: 'test-discriminator',
|
||||
avatar: 'test-avatar',
|
||||
bot: false,
|
||||
},
|
||||
guilds: [],
|
||||
tokens: {
|
||||
access_token: 'test-access-token',
|
||||
refresh_token: 'test-refresh-token',
|
||||
expires_in: 3600,
|
||||
scope: 'identify guilds',
|
||||
token_type: 'Bearer',
|
||||
},
|
||||
};
|
||||
|
||||
await config.kv.sessions.put(session.sessionID, session);
|
||||
|
||||
mockDiscordFetch.mockReturnValue(
|
||||
new Response(null, {
|
||||
status: 200,
|
||||
})
|
||||
);
|
||||
|
||||
const response = await makeRequest('DELETE', '/auth/session', {
|
||||
headers: {
|
||||
Authorization: `Bearer ${session.sessionID}`,
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.status).toBe(204);
|
||||
expect(await config.kv.sessions.get(session.sessionID)).toBeNull();
|
||||
expect(mockDiscordFetch).toHaveBeenCalledWith(
|
||||
'/oauth2/token/revoke',
|
||||
'',
|
||||
AuthType.None,
|
||||
expect.objectContaining(
|
||||
formDataRequest({
|
||||
client_id: config.botClientID,
|
||||
client_secret: config.botClientSecret,
|
||||
token: session.tokens.access_token,
|
||||
})
|
||||
)
|
||||
);
|
||||
});
|
||||
});
|
27
packages/api/src/routes/auth/delete-session.ts
Normal file
27
packages/api/src/routes/auth/delete-session.ts
Normal file
|
@ -0,0 +1,27 @@
|
|||
import { Context, RoleypolyHandler } from '@roleypoly/api/src/utils/context';
|
||||
import { AuthType, discordFetch } from '@roleypoly/api/src/utils/discord';
|
||||
import { formDataRequest } from '@roleypoly/api/src/utils/request';
|
||||
import { noContent } from '@roleypoly/api/src/utils/response';
|
||||
|
||||
export const authSessionDelete: RoleypolyHandler = async (
|
||||
request: Request,
|
||||
context: Context
|
||||
) => {
|
||||
if (!context.session) {
|
||||
return noContent();
|
||||
}
|
||||
|
||||
await discordFetch(
|
||||
'/oauth2/token/revoke',
|
||||
'',
|
||||
AuthType.None,
|
||||
formDataRequest({
|
||||
client_id: context.config.botClientID,
|
||||
client_secret: context.config.botClientSecret,
|
||||
token: context.session.tokens.access_token,
|
||||
})
|
||||
);
|
||||
|
||||
await context.config.kv.sessions.delete(context.session.sessionID);
|
||||
return noContent();
|
||||
};
|
53
packages/api/src/routes/auth/session.spec.ts
Normal file
53
packages/api/src/routes/auth/session.spec.ts
Normal file
|
@ -0,0 +1,53 @@
|
|||
import { SessionData } from '@roleypoly/types';
|
||||
import { parseEnvironment } from '../../utils/config';
|
||||
import { getBindings, makeRequest } from '../../utils/testHelpers';
|
||||
|
||||
describe('GET /auth/session', () => {
|
||||
it('fetches the current user session when it is valid', async () => {
|
||||
const config = parseEnvironment(getBindings());
|
||||
|
||||
const session: SessionData = {
|
||||
sessionID: 'test-session-id',
|
||||
user: {
|
||||
id: 'test-user-id',
|
||||
username: 'test-username',
|
||||
discriminator: 'test-discriminator',
|
||||
avatar: 'test-avatar',
|
||||
bot: false,
|
||||
},
|
||||
guilds: [],
|
||||
tokens: {
|
||||
access_token: 'test-access-token',
|
||||
refresh_token: 'test-refresh-token',
|
||||
expires_in: 3600,
|
||||
scope: 'identify guilds',
|
||||
token_type: 'Bearer',
|
||||
},
|
||||
};
|
||||
|
||||
await config.kv.sessions.put(session.sessionID, session);
|
||||
|
||||
const response = await makeRequest('GET', '/auth/session', {
|
||||
headers: {
|
||||
Authorization: `Bearer ${session.sessionID}`,
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.json()).toMatchObject({
|
||||
sessionID: session.sessionID,
|
||||
user: session.user,
|
||||
guilds: session.guilds,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns 401 when session is not valid', async () => {
|
||||
const response = await makeRequest('GET', '/auth/session', {
|
||||
headers: {
|
||||
Authorization: `Bearer invalid-session-id`,
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
});
|
||||
});
|
17
packages/api/src/routes/auth/session.ts
Normal file
17
packages/api/src/routes/auth/session.ts
Normal file
|
@ -0,0 +1,17 @@
|
|||
import { Context, RoleypolyHandler } from '@roleypoly/api/src/utils/context';
|
||||
import { json, notFound } from '@roleypoly/api/src/utils/response';
|
||||
|
||||
export const authSession: RoleypolyHandler = async (
|
||||
request: Request,
|
||||
context: Context
|
||||
) => {
|
||||
if (context.session) {
|
||||
return json({
|
||||
user: context.session.user,
|
||||
guilds: context.session.guilds,
|
||||
sessionID: context.session.sessionID,
|
||||
});
|
||||
}
|
||||
|
||||
return notFound();
|
||||
};
|
32
packages/api/src/routes/guilds/guild-cache-delete.spec.ts
Normal file
32
packages/api/src/routes/guilds/guild-cache-delete.spec.ts
Normal file
|
@ -0,0 +1,32 @@
|
|||
jest.mock('../../guilds/getters');
|
||||
|
||||
import { UserGuildPermissions } from '@roleypoly/types';
|
||||
import { getGuild } from '../../guilds/getters';
|
||||
import { configContext, makeRequest, makeSession } from '../../utils/testHelpers';
|
||||
|
||||
const mockGetGuild = getGuild as jest.Mock;
|
||||
|
||||
describe('DELETE /guilds/:id/cache', () => {
|
||||
it('calls getGuilds and returns No Content', async () => {
|
||||
const [config] = configContext();
|
||||
const session = await makeSession(config, {
|
||||
guilds: [
|
||||
{
|
||||
id: '123',
|
||||
name: 'test',
|
||||
icon: 'test',
|
||||
permissionLevel: UserGuildPermissions.Admin,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const response = await makeRequest('DELETE', `/guilds/123/cache`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${session.sessionID}`,
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.status).toBe(204);
|
||||
expect(mockGetGuild).toHaveBeenCalledWith(expect.any(Object), '123', true);
|
||||
});
|
||||
});
|
12
packages/api/src/routes/guilds/guild-cache-delete.ts
Normal file
12
packages/api/src/routes/guilds/guild-cache-delete.ts
Normal file
|
@ -0,0 +1,12 @@
|
|||
import { getGuild } from '@roleypoly/api/src/guilds/getters';
|
||||
import { Context, RoleypolyHandler } from '@roleypoly/api/src/utils/context';
|
||||
import { noContent } from '@roleypoly/api/src/utils/response';
|
||||
|
||||
export const guildsCacheDelete: RoleypolyHandler = async (
|
||||
request: Request,
|
||||
context: Context
|
||||
) => {
|
||||
await getGuild(context.config, context.params.guildId!, true);
|
||||
|
||||
return noContent();
|
||||
};
|
370
packages/api/src/routes/guilds/guild-roles-put.spec.ts
Normal file
370
packages/api/src/routes/guilds/guild-roles-put.spec.ts
Normal file
|
@ -0,0 +1,370 @@
|
|||
jest.mock('../../guilds/getters');
|
||||
jest.mock('../../utils/discord');
|
||||
|
||||
import {
|
||||
CategoryType,
|
||||
Features,
|
||||
Guild,
|
||||
GuildData,
|
||||
Member,
|
||||
OwnRoleInfo,
|
||||
RoleSafety,
|
||||
RoleUpdate,
|
||||
TransactionType,
|
||||
} from '@roleypoly/types';
|
||||
import { getGuild, getGuildData, getGuildMember } from '../../guilds/getters';
|
||||
import { AuthType, discordFetch } from '../../utils/discord';
|
||||
import { json } from '../../utils/response';
|
||||
import { configContext, makeRequest, makeSession } from '../../utils/testHelpers';
|
||||
|
||||
const mockDiscordFetch = discordFetch as jest.Mock;
|
||||
const mockGetGuild = getGuild as jest.Mock;
|
||||
const mockGetGuildMember = getGuildMember as jest.Mock;
|
||||
const mockGetGuildData = getGuildData as jest.Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
doMock();
|
||||
});
|
||||
|
||||
describe('PUT /guilds/:id/roles', () => {
|
||||
it('adds member roles when called with valid roles', async () => {
|
||||
const [config] = configContext();
|
||||
const session = await makeSession(config, {
|
||||
guilds: [
|
||||
{
|
||||
id: '123',
|
||||
name: 'test',
|
||||
icon: 'test',
|
||||
permissionLevel: 0,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const update: RoleUpdate = {
|
||||
knownState: ['role-1'],
|
||||
transactions: [{ id: 'role-2', action: TransactionType.Add }],
|
||||
};
|
||||
|
||||
mockDiscordFetch.mockReturnValueOnce(
|
||||
json({
|
||||
roles: ['role-1', 'role-2'],
|
||||
})
|
||||
);
|
||||
|
||||
const response = await makeRequest(
|
||||
'PUT',
|
||||
`/guilds/123/roles`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${session.sessionID}`,
|
||||
},
|
||||
body: JSON.stringify(update),
|
||||
},
|
||||
{
|
||||
BOT_TOKEN: 'test',
|
||||
}
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(mockDiscordFetch).toHaveBeenCalledWith(
|
||||
`/guilds/123/members/${session.user.id}`,
|
||||
'test',
|
||||
AuthType.Bot,
|
||||
{
|
||||
body: JSON.stringify({
|
||||
roles: ['role-1', 'role-2'],
|
||||
}),
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
'x-audit-log-reason': `Picked their roles via ${config.uiPublicURI}`,
|
||||
},
|
||||
method: 'PATCH',
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('removes member roles when called with valid roles', async () => {
|
||||
const [config] = configContext();
|
||||
const session = await makeSession(config, {
|
||||
guilds: [
|
||||
{
|
||||
id: '123',
|
||||
name: 'test',
|
||||
icon: 'test',
|
||||
permissionLevel: 0,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const update: RoleUpdate = {
|
||||
knownState: ['role-1'],
|
||||
transactions: [{ id: 'role-1', action: TransactionType.Remove }],
|
||||
};
|
||||
|
||||
mockDiscordFetch.mockReturnValueOnce(
|
||||
json({
|
||||
roles: [],
|
||||
})
|
||||
);
|
||||
|
||||
const response = await makeRequest(
|
||||
'PUT',
|
||||
`/guilds/123/roles`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${session.sessionID}`,
|
||||
},
|
||||
body: JSON.stringify(update),
|
||||
},
|
||||
{
|
||||
BOT_TOKEN: 'test',
|
||||
}
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(mockDiscordFetch).toHaveBeenCalledWith(
|
||||
`/guilds/123/members/${session.user.id}`,
|
||||
'test',
|
||||
AuthType.Bot,
|
||||
{
|
||||
body: JSON.stringify({
|
||||
roles: [],
|
||||
}),
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
'x-audit-log-reason': `Picked their roles via ${config.uiPublicURI}`,
|
||||
},
|
||||
method: 'PATCH',
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('does not update roles when called only with invalid roles', async () => {
|
||||
const [config] = configContext();
|
||||
const session = await makeSession(config, {
|
||||
guilds: [
|
||||
{
|
||||
id: '123',
|
||||
name: 'test',
|
||||
icon: 'test',
|
||||
permissionLevel: 0,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const update: RoleUpdate = {
|
||||
knownState: ['role-1'],
|
||||
transactions: [
|
||||
{ id: 'role-3', action: TransactionType.Add }, // role is in a hidden category
|
||||
{ id: 'role-5-unsafe', action: TransactionType.Add }, // role is marked unsafe
|
||||
],
|
||||
};
|
||||
|
||||
const response = await makeRequest(
|
||||
'PUT',
|
||||
`/guilds/123/roles`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${session.sessionID}`,
|
||||
},
|
||||
body: JSON.stringify(update),
|
||||
},
|
||||
{
|
||||
BOT_TOKEN: 'test',
|
||||
}
|
||||
);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(mockDiscordFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('filters roles that are invalid while accepting ones that are valid', async () => {
|
||||
const [config] = configContext();
|
||||
const session = await makeSession(config, {
|
||||
guilds: [
|
||||
{
|
||||
id: '123',
|
||||
name: 'test',
|
||||
icon: 'test',
|
||||
permissionLevel: 0,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const update: RoleUpdate = {
|
||||
knownState: ['role-1'],
|
||||
transactions: [
|
||||
{ id: 'role-3', action: TransactionType.Add }, // role is in a hidden category
|
||||
{ id: 'role-2', action: TransactionType.Add }, // role is in a hidden category
|
||||
],
|
||||
};
|
||||
|
||||
const response = await makeRequest(
|
||||
'PUT',
|
||||
`/guilds/123/roles`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${session.sessionID}`,
|
||||
},
|
||||
body: JSON.stringify(update),
|
||||
},
|
||||
{
|
||||
BOT_TOKEN: 'test',
|
||||
}
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(mockDiscordFetch).toHaveBeenCalledWith(
|
||||
`/guilds/123/members/${session.user.id}`,
|
||||
'test',
|
||||
AuthType.Bot,
|
||||
{
|
||||
body: JSON.stringify({
|
||||
roles: ['role-1', 'role-2'],
|
||||
}),
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
'x-audit-log-reason': `Picked their roles via ${config.uiPublicURI}`,
|
||||
},
|
||||
method: 'PATCH',
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('400s when no transactions are present', async () => {
|
||||
const [config] = configContext();
|
||||
const session = await makeSession(config, {
|
||||
guilds: [
|
||||
{
|
||||
id: '123',
|
||||
name: 'test',
|
||||
icon: 'test',
|
||||
permissionLevel: 0,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const update: RoleUpdate = {
|
||||
knownState: ['role-1'],
|
||||
transactions: [],
|
||||
};
|
||||
|
||||
const response = await makeRequest(
|
||||
'PUT',
|
||||
`/guilds/123/roles`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${session.sessionID}`,
|
||||
},
|
||||
body: JSON.stringify(update),
|
||||
},
|
||||
{
|
||||
BOT_TOKEN: 'test',
|
||||
}
|
||||
);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(mockDiscordFetch).not.toHaveBeenCalled();
|
||||
expect(mockGetGuild).not.toHaveBeenCalled();
|
||||
expect(mockGetGuildData).not.toHaveBeenCalled();
|
||||
expect(mockGetGuildMember).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
const doMock = () => {
|
||||
const guild: Guild & OwnRoleInfo = {
|
||||
id: '123',
|
||||
name: 'test',
|
||||
icon: 'test',
|
||||
highestRolePosition: 0,
|
||||
roles: [
|
||||
{
|
||||
id: 'role-1',
|
||||
name: 'Role 1',
|
||||
color: 0,
|
||||
position: 17,
|
||||
permissions: '',
|
||||
managed: false,
|
||||
safety: RoleSafety.Safe,
|
||||
},
|
||||
{
|
||||
id: 'role-2',
|
||||
name: 'Role 2',
|
||||
color: 0,
|
||||
position: 16,
|
||||
permissions: '',
|
||||
managed: false,
|
||||
safety: RoleSafety.Safe,
|
||||
},
|
||||
{
|
||||
id: 'role-3',
|
||||
name: 'Role 3',
|
||||
color: 0,
|
||||
position: 15,
|
||||
permissions: '',
|
||||
managed: false,
|
||||
safety: RoleSafety.Safe,
|
||||
},
|
||||
{
|
||||
id: 'role-4',
|
||||
name: 'Role 4',
|
||||
color: 0,
|
||||
position: 14,
|
||||
permissions: '',
|
||||
managed: false,
|
||||
safety: RoleSafety.Safe,
|
||||
},
|
||||
{
|
||||
id: 'role-5-unsafe',
|
||||
name: 'Role 5 (Unsafe)',
|
||||
color: 0,
|
||||
position: 14,
|
||||
permissions: '',
|
||||
managed: false,
|
||||
safety: RoleSafety.DangerousPermissions,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const member: Member = {
|
||||
roles: ['role-1'],
|
||||
pending: false,
|
||||
nick: '',
|
||||
};
|
||||
|
||||
const guildData: GuildData = {
|
||||
id: '123',
|
||||
message: 'test',
|
||||
categories: [
|
||||
{
|
||||
id: 'category-1',
|
||||
name: 'Category 1',
|
||||
position: 0,
|
||||
hidden: false,
|
||||
type: CategoryType.Multi,
|
||||
roles: ['role-1', 'role-2'],
|
||||
},
|
||||
{
|
||||
id: 'category-2',
|
||||
name: 'Category 2',
|
||||
position: 1,
|
||||
hidden: true,
|
||||
type: CategoryType.Multi,
|
||||
roles: ['role-3'],
|
||||
},
|
||||
],
|
||||
features: Features.None,
|
||||
auditLogWebhook: null,
|
||||
accessControl: {
|
||||
allowList: [],
|
||||
blockList: [],
|
||||
blockPending: false,
|
||||
},
|
||||
};
|
||||
|
||||
mockGetGuild.mockReturnValue(guild);
|
||||
mockGetGuildMember.mockReturnValue(member);
|
||||
mockGetGuildData.mockReturnValue(guildData);
|
||||
mockDiscordFetch.mockReturnValue(json({}));
|
||||
};
|
160
packages/api/src/routes/guilds/guild-roles-put.ts
Normal file
160
packages/api/src/routes/guilds/guild-roles-put.ts
Normal file
|
@ -0,0 +1,160 @@
|
|||
import {
|
||||
getGuild,
|
||||
getGuildData,
|
||||
getGuildMember,
|
||||
updateGuildMember,
|
||||
} from '@roleypoly/api/src/guilds/getters';
|
||||
import { Context, RoleypolyHandler } from '@roleypoly/api/src/utils/context';
|
||||
import { APIMember, AuthType, discordFetch } from '@roleypoly/api/src/utils/discord';
|
||||
import {
|
||||
engineeringProblem,
|
||||
invalid,
|
||||
json,
|
||||
notFound,
|
||||
serverError,
|
||||
} from '@roleypoly/api/src/utils/response';
|
||||
import {
|
||||
difference,
|
||||
isIdenticalArray,
|
||||
keyBy,
|
||||
union,
|
||||
} from '@roleypoly/misc-utils/collection-tools';
|
||||
import {
|
||||
GuildData,
|
||||
Member,
|
||||
Role,
|
||||
RoleSafety,
|
||||
RoleTransaction,
|
||||
RoleUpdate,
|
||||
TransactionType,
|
||||
} from '@roleypoly/types';
|
||||
|
||||
export const guildsRolesPut: RoleypolyHandler = async (
|
||||
request: Request,
|
||||
context: Context
|
||||
) => {
|
||||
if (!request.body) {
|
||||
return invalid();
|
||||
}
|
||||
|
||||
const updateRequest: RoleUpdate = await request.json();
|
||||
|
||||
if (updateRequest.transactions.length === 0) {
|
||||
return invalid();
|
||||
}
|
||||
|
||||
const guildID = context.params.guildId;
|
||||
if (!guildID) {
|
||||
return engineeringProblem('params not set up correctly');
|
||||
}
|
||||
|
||||
const userID = context.session!.user.id;
|
||||
|
||||
const [member, guildData, guild] = await Promise.all([
|
||||
getGuildMember(context.config, guildID, userID),
|
||||
getGuildData(context.config, guildID),
|
||||
getGuild(context.config, guildID),
|
||||
]);
|
||||
|
||||
if (!guild || !member) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
const newRoles = calculateNewRoles({
|
||||
currentRoles: member.roles,
|
||||
guildRoles: guild.roles,
|
||||
guildData,
|
||||
updateRequest,
|
||||
});
|
||||
|
||||
if (
|
||||
isIdenticalArray(member.roles, newRoles) ||
|
||||
isIdenticalArray(updateRequest.knownState, newRoles)
|
||||
) {
|
||||
return invalid();
|
||||
}
|
||||
|
||||
const patchMemberRoles = await discordFetch<APIMember>(
|
||||
`/guilds/${guildID}/members/${userID}`,
|
||||
context.config.botToken,
|
||||
AuthType.Bot,
|
||||
{
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
'x-audit-log-reason': `Picked their roles via ${context.config.uiPublicURI}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
roles: newRoles,
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
if (!patchMemberRoles) {
|
||||
return serverError(new Error('discord rejected the request'));
|
||||
}
|
||||
|
||||
context.fetchContext.waitUntil(
|
||||
updateGuildMember(context.config, guildID, patchMemberRoles)
|
||||
);
|
||||
|
||||
const updatedMember: Member = {
|
||||
roles: patchMemberRoles.roles,
|
||||
};
|
||||
|
||||
return json(updatedMember);
|
||||
};
|
||||
|
||||
export const calculateNewRoles = ({
|
||||
currentRoles,
|
||||
guildData,
|
||||
guildRoles,
|
||||
updateRequest,
|
||||
}: {
|
||||
currentRoles: string[];
|
||||
guildRoles: Role[];
|
||||
guildData: GuildData;
|
||||
updateRequest: RoleUpdate;
|
||||
}): string[] => {
|
||||
const roleMap = keyBy(guildRoles, 'id');
|
||||
|
||||
// These roles were ones changed between knownState (role picker page load/cache) and current (fresh from discord).
|
||||
// We could cause issues, so we'll re-add them later.
|
||||
// const diffRoles = difference(updateRequest.knownState, currentRoles);
|
||||
|
||||
// Only these are safe
|
||||
const allSafeRoles = guildData.categories.reduce<string[]>(
|
||||
(categorizedRoles, category) =>
|
||||
!category.hidden
|
||||
? [
|
||||
...categorizedRoles,
|
||||
...category.roles.filter(
|
||||
(roleID) => roleMap[roleID]?.safety === RoleSafety.Safe
|
||||
),
|
||||
]
|
||||
: categorizedRoles,
|
||||
[]
|
||||
);
|
||||
|
||||
const safeTransactions = updateRequest.transactions.filter((tx: RoleTransaction) =>
|
||||
allSafeRoles.includes(tx.id)
|
||||
);
|
||||
|
||||
const changesByAction = safeTransactions.reduce<
|
||||
Record<TransactionType, RoleTransaction[]>
|
||||
>((group, value, _1, _2, key = value.action) => (group[key].push(value), group), {
|
||||
[TransactionType.Add]: [],
|
||||
[TransactionType.Remove]: [],
|
||||
});
|
||||
|
||||
const rolesToAdd = (changesByAction[TransactionType.Add] ?? []).map(
|
||||
(tx: RoleTransaction) => tx.id
|
||||
);
|
||||
const rolesToRemove = (changesByAction[TransactionType.Remove] ?? []).map(
|
||||
(tx: RoleTransaction) => tx.id
|
||||
);
|
||||
|
||||
const final = union(difference(currentRoles, rolesToRemove), rolesToAdd);
|
||||
|
||||
return final;
|
||||
};
|
164
packages/api/src/routes/guilds/guild.spec.ts
Normal file
164
packages/api/src/routes/guilds/guild.spec.ts
Normal file
|
@ -0,0 +1,164 @@
|
|||
jest.mock('../../guilds/getters');
|
||||
|
||||
import { Features, GuildData, PresentableGuild } from '@roleypoly/types';
|
||||
import { getGuild, getGuildData, getGuildMember } from '../../guilds/getters';
|
||||
import { APIGuild, APIMember } from '../../utils/discord';
|
||||
import { configContext, makeRequest, makeSession } from '../../utils/testHelpers';
|
||||
|
||||
const mockGetGuild = getGuild as jest.Mock;
|
||||
const mockGetGuildMember = getGuildMember as jest.Mock;
|
||||
const mockGetGuildData = getGuildData as jest.Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
mockGetGuildData.mockReset();
|
||||
mockGetGuild.mockReset();
|
||||
mockGetGuildMember.mockReset();
|
||||
});
|
||||
|
||||
describe('GET /guilds/:id', () => {
|
||||
it('returns a presentable guild', async () => {
|
||||
const guild: APIGuild = {
|
||||
id: '123',
|
||||
name: 'test',
|
||||
icon: 'test',
|
||||
roles: [
|
||||
{
|
||||
id: 'role-1',
|
||||
name: 'Role 1',
|
||||
color: 0,
|
||||
position: 17,
|
||||
permissions: '',
|
||||
managed: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const member: APIMember = {
|
||||
roles: ['role-1'],
|
||||
pending: false,
|
||||
nick: '',
|
||||
user: {
|
||||
id: 'user-1',
|
||||
},
|
||||
};
|
||||
|
||||
const guildData: GuildData = {
|
||||
id: '123',
|
||||
message: 'test',
|
||||
categories: [],
|
||||
features: Features.None,
|
||||
auditLogWebhook: null,
|
||||
accessControl: {
|
||||
allowList: [],
|
||||
blockList: [],
|
||||
blockPending: false,
|
||||
},
|
||||
};
|
||||
|
||||
mockGetGuild.mockReturnValue(guild);
|
||||
mockGetGuildMember.mockReturnValue(member);
|
||||
mockGetGuildData.mockReturnValue(guildData);
|
||||
|
||||
const [config] = configContext();
|
||||
|
||||
const session = await makeSession(config, {
|
||||
guilds: [
|
||||
{
|
||||
id: '123',
|
||||
name: 'test',
|
||||
icon: 'test',
|
||||
permissionLevel: 0,
|
||||
},
|
||||
],
|
||||
});
|
||||
const response = await makeRequest('GET', `/guilds/${guild.id}`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${session.sessionID}`,
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.json()).toEqual({
|
||||
id: guild.id,
|
||||
guild: session.guilds[0],
|
||||
member: {
|
||||
roles: member.roles,
|
||||
},
|
||||
roles: guild.roles,
|
||||
data: guildData,
|
||||
} as PresentableGuild);
|
||||
});
|
||||
|
||||
it('returns a 404 when the guild is not in session', async () => {
|
||||
const [config, context] = configContext();
|
||||
const session = await makeSession(config);
|
||||
const response = await makeRequest('GET', `/guilds/123`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${session.sessionID}`,
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
});
|
||||
|
||||
it('returns 404 when the guild is not fetchable', async () => {
|
||||
const [config, context] = configContext();
|
||||
const session = await makeSession(config, {
|
||||
guilds: [
|
||||
{
|
||||
id: '123',
|
||||
name: 'test',
|
||||
icon: 'test',
|
||||
permissionLevel: 0,
|
||||
},
|
||||
],
|
||||
});
|
||||
const response = await makeRequest('GET', `/guilds/123`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${session.sessionID}`,
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
});
|
||||
|
||||
it('returns 404 when the member is no longer in the guild', async () => {
|
||||
const guild: APIGuild = {
|
||||
id: '123',
|
||||
name: 'test',
|
||||
icon: 'test',
|
||||
roles: [
|
||||
{
|
||||
id: 'role-1',
|
||||
name: 'Role 1',
|
||||
color: 0,
|
||||
position: 17,
|
||||
permissions: '',
|
||||
managed: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
mockGetGuild.mockReturnValue(guild);
|
||||
mockGetGuildMember.mockReturnValue(null);
|
||||
|
||||
const [config, context] = configContext();
|
||||
const session = await makeSession(config, {
|
||||
guilds: [
|
||||
{
|
||||
id: '123',
|
||||
name: 'test',
|
||||
icon: 'test',
|
||||
permissionLevel: 0,
|
||||
},
|
||||
],
|
||||
});
|
||||
const response = await makeRequest('GET', `/guilds/${guild.id}`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${session.sessionID}`,
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
});
|
||||
});
|
45
packages/api/src/routes/guilds/guild.ts
Normal file
45
packages/api/src/routes/guilds/guild.ts
Normal file
|
@ -0,0 +1,45 @@
|
|||
import {
|
||||
getGuild,
|
||||
getGuildData,
|
||||
getGuildMember,
|
||||
} from '@roleypoly/api/src/guilds/getters';
|
||||
import { Context, RoleypolyHandler } from '@roleypoly/api/src/utils/context';
|
||||
import { getQuery } from '@roleypoly/api/src/utils/request';
|
||||
import { json, notFound } from '@roleypoly/api/src/utils/response';
|
||||
import { PresentableGuild } from '@roleypoly/types';
|
||||
|
||||
export const guildsGuild: RoleypolyHandler = async (
|
||||
request: Request,
|
||||
context: Context
|
||||
) => {
|
||||
const { __no_cache: noCache } = getQuery(request);
|
||||
const guild = await getGuild(context.config, context.params!.guildId!, !!noCache);
|
||||
|
||||
if (!guild) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
const member = await getGuildMember(
|
||||
context.config,
|
||||
context.params!.guildId!,
|
||||
context.session!.user.id,
|
||||
!!noCache
|
||||
);
|
||||
|
||||
if (!member) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
const data = await getGuildData(context.config, guild.id);
|
||||
const presentableGuild: PresentableGuild = {
|
||||
id: guild.id,
|
||||
guild: context.session?.guilds.find((g) => g.id === guild.id)!,
|
||||
roles: guild.roles,
|
||||
member: {
|
||||
roles: member.roles,
|
||||
},
|
||||
data,
|
||||
};
|
||||
|
||||
return json(presentableGuild);
|
||||
};
|
164
packages/api/src/routes/guilds/guilds-patch.spec.ts
Normal file
164
packages/api/src/routes/guilds/guilds-patch.spec.ts
Normal file
|
@ -0,0 +1,164 @@
|
|||
jest.mock('../../guilds/getters');
|
||||
|
||||
import {
|
||||
Features,
|
||||
GuildData,
|
||||
GuildDataUpdate,
|
||||
UserGuildPermissions,
|
||||
} from '@roleypoly/types';
|
||||
import { getGuildData } from '../../guilds/getters';
|
||||
import { configContext, makeRequest, makeSession } from '../../utils/testHelpers';
|
||||
|
||||
const mockGetGuildData = getGuildData as jest.Mock;
|
||||
|
||||
beforeAll(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
describe('PATCH /guilds/:id', () => {
|
||||
it('updates guild data when user is an editor', async () => {
|
||||
const [config, context] = configContext();
|
||||
const session = await makeSession(config, {
|
||||
guilds: [
|
||||
{
|
||||
id: '123',
|
||||
name: 'test',
|
||||
icon: 'test',
|
||||
permissionLevel: UserGuildPermissions.Manager,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
mockGetGuildData.mockReturnValue({
|
||||
id: '123',
|
||||
message: 'test',
|
||||
categories: [],
|
||||
features: Features.None,
|
||||
auditLogWebhook: null,
|
||||
accessControl: {
|
||||
allowList: [],
|
||||
blockList: [],
|
||||
blockPending: false,
|
||||
},
|
||||
} as GuildData);
|
||||
|
||||
const response = await makeRequest('PATCH', `/guilds/123`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${session.sessionID}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
message: 'hello test world!',
|
||||
} as GuildDataUpdate),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
const newGuildData = await config.kv.guildData.get('123');
|
||||
expect(newGuildData).toMatchObject({
|
||||
message: 'hello test world!',
|
||||
});
|
||||
});
|
||||
|
||||
it('ignores extraneous fields sent as updates', async () => {
|
||||
const [config, context] = configContext();
|
||||
const session = await makeSession(config, {
|
||||
guilds: [
|
||||
{
|
||||
id: '123',
|
||||
name: 'test',
|
||||
icon: 'test',
|
||||
permissionLevel: UserGuildPermissions.Manager,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
mockGetGuildData.mockReturnValue({
|
||||
id: '123',
|
||||
message: 'test',
|
||||
categories: [],
|
||||
features: Features.None,
|
||||
auditLogWebhook: null,
|
||||
accessControl: {
|
||||
allowList: [],
|
||||
blockList: [],
|
||||
blockPending: false,
|
||||
},
|
||||
} as GuildData);
|
||||
|
||||
const response = await makeRequest('PATCH', `/guilds/123`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${session.sessionID}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
fifteen: 'foxes',
|
||||
}),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
const newGuildData = await config.kv.guildData.get('123');
|
||||
expect(newGuildData).not.toMatchObject({
|
||||
fifteen: 'foxes',
|
||||
});
|
||||
});
|
||||
|
||||
it('403s when user is not an editor', async () => {
|
||||
const [config, context] = configContext();
|
||||
const session = await makeSession(config, {
|
||||
guilds: [
|
||||
{
|
||||
id: '123',
|
||||
name: 'test',
|
||||
icon: 'test',
|
||||
permissionLevel: UserGuildPermissions.User,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
mockGetGuildData.mockReturnValue({
|
||||
id: '123',
|
||||
message: 'test',
|
||||
categories: [],
|
||||
features: Features.None,
|
||||
auditLogWebhook: null,
|
||||
accessControl: {
|
||||
allowList: [],
|
||||
blockList: [],
|
||||
blockPending: false,
|
||||
},
|
||||
} as GuildData);
|
||||
|
||||
const response = await makeRequest('PATCH', `/guilds/123`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${session.sessionID}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
message: 'hello test world!',
|
||||
} as GuildDataUpdate),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
});
|
||||
|
||||
it('400s when no body is present', async () => {
|
||||
const [config, context] = configContext();
|
||||
const session = await makeSession(config, {
|
||||
guilds: [
|
||||
{
|
||||
id: '123',
|
||||
name: 'test',
|
||||
icon: 'test',
|
||||
permissionLevel: UserGuildPermissions.Manager,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const response = await makeRequest('PATCH', `/guilds/123`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${session.sessionID}`,
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
});
|
37
packages/api/src/routes/guilds/guilds-patch.ts
Normal file
37
packages/api/src/routes/guilds/guilds-patch.ts
Normal file
|
@ -0,0 +1,37 @@
|
|||
import { getGuildData } from '@roleypoly/api/src/guilds/getters';
|
||||
import { Context, RoleypolyHandler } from '@roleypoly/api/src/utils/context';
|
||||
import { invalid, json, notFound } from '@roleypoly/api/src/utils/response';
|
||||
import { GuildData, GuildDataUpdate } from '@roleypoly/types';
|
||||
|
||||
export const guildsGuildPatch: RoleypolyHandler = async (
|
||||
request: Request,
|
||||
context: Context
|
||||
) => {
|
||||
const id = context.params.guildId!;
|
||||
if (!request.body) {
|
||||
return invalid();
|
||||
}
|
||||
|
||||
const update: GuildDataUpdate = await request.json();
|
||||
|
||||
const oldGuildData = await getGuildData(context.config, id);
|
||||
if (!oldGuildData) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
const newGuildData: GuildData = {
|
||||
...oldGuildData,
|
||||
|
||||
// TODO: validation
|
||||
message: update.message || oldGuildData.message,
|
||||
categories: update.categories || oldGuildData.categories,
|
||||
accessControl: update.accessControl || oldGuildData.accessControl,
|
||||
|
||||
// TODO: audit log webhooks
|
||||
auditLogWebhook: oldGuildData.auditLogWebhook,
|
||||
};
|
||||
|
||||
await context.config.kv.guildData.put(id, newGuildData);
|
||||
|
||||
return json(newGuildData);
|
||||
};
|
52
packages/api/src/routes/guilds/slug.spec.ts
Normal file
52
packages/api/src/routes/guilds/slug.spec.ts
Normal file
|
@ -0,0 +1,52 @@
|
|||
jest.mock('../../guilds/getters');
|
||||
|
||||
import { GuildSlug, UserGuildPermissions } from '@roleypoly/types';
|
||||
import { getGuild } from '../../guilds/getters';
|
||||
import { APIGuild } from '../../utils/discord';
|
||||
import { makeRequest } from '../../utils/testHelpers';
|
||||
|
||||
const mockGetGuild = getGuild as jest.Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
mockGetGuild.mockReset();
|
||||
});
|
||||
|
||||
describe('GET /guilds/:id/slug', () => {
|
||||
it('returns a valid slug for a given discord server', async () => {
|
||||
const guild: APIGuild = {
|
||||
id: '123',
|
||||
name: 'test',
|
||||
icon: 'test',
|
||||
roles: [
|
||||
{
|
||||
id: 'role-1',
|
||||
name: 'Role 1',
|
||||
color: 0,
|
||||
position: 17,
|
||||
permissions: '',
|
||||
managed: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
mockGetGuild.mockReturnValue(guild);
|
||||
|
||||
const response = await makeRequest('GET', `/guilds/${guild.id}/slug`);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.json()).toEqual({
|
||||
id: guild.id,
|
||||
icon: guild.icon,
|
||||
name: guild.name,
|
||||
permissionLevel: UserGuildPermissions.User,
|
||||
} as GuildSlug);
|
||||
});
|
||||
|
||||
it('returns a 404 if the guild cannot be fetched', async () => {
|
||||
mockGetGuild.mockReturnValue(null);
|
||||
|
||||
const response = await makeRequest('GET', `/guilds/slug/123`);
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
});
|
||||
});
|
31
packages/api/src/routes/guilds/slug.ts
Normal file
31
packages/api/src/routes/guilds/slug.ts
Normal file
|
@ -0,0 +1,31 @@
|
|||
import { getGuild } from '@roleypoly/api/src/guilds/getters';
|
||||
import { Context, RoleypolyHandler } from '@roleypoly/api/src/utils/context';
|
||||
import { json, notFound } from '@roleypoly/api/src/utils/response';
|
||||
import { GuildSlug, UserGuildPermissions } from '@roleypoly/types';
|
||||
|
||||
export const guildsSlug: RoleypolyHandler = async (
|
||||
request: Request,
|
||||
context: Context
|
||||
) => {
|
||||
const id = context.params.guildId!;
|
||||
|
||||
const guildInSession = context.session?.guilds.find((guild) => guild.id === id);
|
||||
|
||||
if (guildInSession) {
|
||||
return json<GuildSlug>(guildInSession);
|
||||
}
|
||||
|
||||
const guild = await getGuild(context.config, id);
|
||||
if (!guild) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
const slug: GuildSlug = {
|
||||
id,
|
||||
name: guild.name,
|
||||
icon: guild.icon,
|
||||
permissionLevel: UserGuildPermissions.User,
|
||||
};
|
||||
|
||||
return json<GuildSlug>(slug);
|
||||
};
|
|
@ -0,0 +1,60 @@
|
|||
jest.mock('../../../utils/discord');
|
||||
|
||||
import { discordFetch } from '../../../utils/discord';
|
||||
import { configContext } from '../../../utils/testHelpers';
|
||||
import {
|
||||
extractInteractionResponse,
|
||||
isDeferred,
|
||||
isEphemeral,
|
||||
makeInteractionsRequest,
|
||||
mockUpdateCall,
|
||||
} from '../testHelpers';
|
||||
|
||||
const mockDiscordFetch = discordFetch as jest.Mock;
|
||||
it('responds with the username when member.nick is missing', async () => {
|
||||
const [, context] = configContext();
|
||||
const response = await makeInteractionsRequest(
|
||||
context,
|
||||
{
|
||||
name: 'hello-world',
|
||||
},
|
||||
false,
|
||||
{
|
||||
member: {
|
||||
nick: undefined,
|
||||
roles: [],
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
const interaction = await extractInteractionResponse(response);
|
||||
|
||||
expect(isDeferred(interaction)).toBe(true);
|
||||
expect(isEphemeral(interaction)).toBe(true);
|
||||
expect(mockDiscordFetch).toBeCalledWith(
|
||||
...mockUpdateCall(expect, {
|
||||
content: 'Hey there, test-user',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('responds with the nickname when member.nick is set', async () => {
|
||||
const [, context] = configContext();
|
||||
const response = await makeInteractionsRequest(context, {
|
||||
name: 'hello-world',
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
const interaction = await extractInteractionResponse(response);
|
||||
|
||||
expect(isDeferred(interaction)).toBe(true);
|
||||
expect(isEphemeral(interaction)).toBe(true);
|
||||
expect(mockDiscordFetch).toBeCalledWith(
|
||||
...mockUpdateCall(expect, {
|
||||
content: 'Hey there, test-user-nick',
|
||||
})
|
||||
);
|
||||
});
|
27
packages/api/src/routes/interactions/commands/hello-world.ts
Normal file
27
packages/api/src/routes/interactions/commands/hello-world.ts
Normal file
|
@ -0,0 +1,27 @@
|
|||
import { InteractionHandler } from '@roleypoly/api/src/routes/interactions/helpers';
|
||||
import { Context } from '@roleypoly/api/src/utils/context';
|
||||
import {
|
||||
InteractionCallbackType,
|
||||
InteractionRequest,
|
||||
InteractionResponse,
|
||||
} from '@roleypoly/types';
|
||||
|
||||
export const helloWorld: InteractionHandler = (
|
||||
interaction: InteractionRequest,
|
||||
context: Context
|
||||
): InteractionResponse => {
|
||||
console.log({ interaction });
|
||||
return {
|
||||
type: InteractionCallbackType.CHANNEL_MESSAGE_WITH_SOURCE,
|
||||
data: {
|
||||
content: `Hey there, ${
|
||||
interaction.member?.nick ||
|
||||
interaction.member?.user?.username ||
|
||||
interaction.user?.username
|
||||
}`,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
helloWorld.ephemeral = true;
|
||||
helloWorld.deferred = true;
|
18
packages/api/src/routes/interactions/commands/pick-role.ts
Normal file
18
packages/api/src/routes/interactions/commands/pick-role.ts
Normal file
|
@ -0,0 +1,18 @@
|
|||
import { InteractionHandler } from '@roleypoly/api/src/routes/interactions/helpers';
|
||||
import { rolePickerCommon } from '@roleypoly/api/src/routes/interactions/role-picker-common';
|
||||
import { Context } from '@roleypoly/api/src/utils/context';
|
||||
import {
|
||||
InteractionRequest,
|
||||
InteractionResponse,
|
||||
TransactionType,
|
||||
} from '@roleypoly/types';
|
||||
|
||||
export const pickRole: InteractionHandler = async (
|
||||
interaction: InteractionRequest,
|
||||
context: Context
|
||||
): Promise<InteractionResponse> => {
|
||||
return rolePickerCommon(interaction, context, TransactionType.Add);
|
||||
};
|
||||
|
||||
pickRole.ephemeral = true;
|
||||
pickRole.deferred = true;
|
118
packages/api/src/routes/interactions/commands/pickable-roles.ts
Normal file
118
packages/api/src/routes/interactions/commands/pickable-roles.ts
Normal file
|
@ -0,0 +1,118 @@
|
|||
import {
|
||||
getGuild,
|
||||
getGuildData,
|
||||
getGuildMember,
|
||||
getPickableRoles,
|
||||
} from '@roleypoly/api/src/guilds/getters';
|
||||
import {
|
||||
embedBuilder,
|
||||
getName,
|
||||
InteractionHandler,
|
||||
} from '@roleypoly/api/src/routes/interactions/helpers';
|
||||
import {
|
||||
embedPalette,
|
||||
embedResponse,
|
||||
} from '@roleypoly/api/src/routes/interactions/responses';
|
||||
import { Context } from '@roleypoly/api/src/utils/context';
|
||||
import {
|
||||
CategoryType,
|
||||
Embed,
|
||||
InteractionCallbackType,
|
||||
InteractionRequest,
|
||||
InteractionResponse,
|
||||
Role,
|
||||
} from '@roleypoly/types';
|
||||
|
||||
export const pickableRoles: InteractionHandler = async (
|
||||
interaction: InteractionRequest,
|
||||
context: Context
|
||||
): Promise<InteractionResponse> => {
|
||||
if (!interaction.guild_id) {
|
||||
return embedResponse(
|
||||
':x: Error',
|
||||
`Hey ${getName(
|
||||
interaction
|
||||
)}. You need to use this command in a server, not in a DM.`,
|
||||
embedPalette.error
|
||||
);
|
||||
}
|
||||
|
||||
const [guildData, guild, member] = await Promise.all([
|
||||
getGuildData(context.config, interaction.guild_id),
|
||||
getGuild(context.config, interaction.guild_id),
|
||||
getGuildMember(context.config, interaction.guild_id, interaction.member?.user?.id!),
|
||||
]);
|
||||
|
||||
if (!guildData || !guild) {
|
||||
return embedResponse(
|
||||
':x: Error',
|
||||
`Hey ${getName(
|
||||
interaction
|
||||
)}. Something's wrong with the server you're in. Try picking your roles at ${
|
||||
context.config.uiPublicURI
|
||||
}/s/${interaction.guild_id} instead.`,
|
||||
embedPalette.error
|
||||
);
|
||||
}
|
||||
|
||||
const roles = getPickableRoles(guildData, guild);
|
||||
if (roles.length === 0) {
|
||||
return embedResponse(
|
||||
':fire: Error',
|
||||
`Hey ${getName(
|
||||
interaction
|
||||
)}. This server might not be set up to use Roleypoly yet, as there are no roles to pick from.`,
|
||||
embedPalette.error
|
||||
);
|
||||
}
|
||||
|
||||
const makeBoldIfMemberHasRole = (role: Role, base: string): string => {
|
||||
if (member?.roles.includes(role.id)) {
|
||||
return `__${base}__`;
|
||||
}
|
||||
|
||||
return base;
|
||||
};
|
||||
|
||||
const embed: Embed = {
|
||||
color: embedPalette.neutral,
|
||||
fields: roles.map(({ category, roles }) => {
|
||||
return {
|
||||
name: `${category.name}${
|
||||
category.type === CategoryType.Single ? ' *(pick one)*' : ''
|
||||
}`,
|
||||
value: roles
|
||||
.map((role) => makeBoldIfMemberHasRole(role, `<@&${role.id}>`))
|
||||
.join(', '),
|
||||
} as Embed['fields'][0];
|
||||
}),
|
||||
title: 'You can pick any of these roles with /pick-role',
|
||||
footer: {
|
||||
text: `Roles with an __underline__ are already picked by you.`,
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
type: InteractionCallbackType.CHANNEL_MESSAGE_WITH_SOURCE,
|
||||
data: {
|
||||
embeds: embedBuilder(embed),
|
||||
components: [
|
||||
{
|
||||
type: 1,
|
||||
components: [
|
||||
// Link to Roleypoly
|
||||
{
|
||||
type: 2,
|
||||
label: 'Pick roles on your browser',
|
||||
url: `${context.config.uiPublicURI}/s/${interaction.guild_id}`,
|
||||
style: 5,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
pickableRoles.ephemeral = true;
|
||||
pickableRoles.deferred = true;
|
18
packages/api/src/routes/interactions/commands/remove-role.ts
Normal file
18
packages/api/src/routes/interactions/commands/remove-role.ts
Normal file
|
@ -0,0 +1,18 @@
|
|||
import { InteractionHandler } from '@roleypoly/api/src/routes/interactions/helpers';
|
||||
import { rolePickerCommon } from '@roleypoly/api/src/routes/interactions/role-picker-common';
|
||||
import { Context } from '@roleypoly/api/src/utils/context';
|
||||
import {
|
||||
InteractionRequest,
|
||||
InteractionResponse,
|
||||
TransactionType,
|
||||
} from '@roleypoly/types';
|
||||
|
||||
export const removeRole: InteractionHandler = async (
|
||||
interaction: InteractionRequest,
|
||||
context: Context
|
||||
): Promise<InteractionResponse> => {
|
||||
return rolePickerCommon(interaction, context, TransactionType.Remove);
|
||||
};
|
||||
|
||||
removeRole.ephemeral = true;
|
||||
removeRole.deferred = true;
|
60
packages/api/src/routes/interactions/commands/roleypoly.ts
Normal file
60
packages/api/src/routes/interactions/commands/roleypoly.ts
Normal file
|
@ -0,0 +1,60 @@
|
|||
import {
|
||||
getName,
|
||||
InteractionHandler,
|
||||
} from '@roleypoly/api/src/routes/interactions/helpers';
|
||||
import { embedResponse } from '@roleypoly/api/src/routes/interactions/responses';
|
||||
import { Context } from '@roleypoly/api/src/utils/context';
|
||||
import {
|
||||
Embed,
|
||||
InteractionCallbackType,
|
||||
InteractionRequest,
|
||||
InteractionResponse,
|
||||
} from '@roleypoly/types';
|
||||
|
||||
export const roleypoly: InteractionHandler = (
|
||||
interaction: InteractionRequest,
|
||||
context: Context
|
||||
): InteractionResponse => {
|
||||
if (!interaction.guild_id) {
|
||||
return embedResponse(
|
||||
':x: Error',
|
||||
`Hey ${getName(
|
||||
interaction
|
||||
)}. You need to use this command in a server, not in a DM.`
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
type: InteractionCallbackType.CHANNEL_MESSAGE_WITH_SOURCE,
|
||||
data: {
|
||||
embeds: [
|
||||
{
|
||||
color: 0x453e3d,
|
||||
title: `:beginner: Hey there, ${getName(interaction)}!`,
|
||||
description: `Try these slash commands, or pick roles from your browser!`,
|
||||
fields: [
|
||||
{ name: 'See all the roles', value: '/pickable-roles' },
|
||||
{ name: 'Pick a role', value: '/pick-role' },
|
||||
{ name: 'Remove a role', value: '/remove-role' },
|
||||
],
|
||||
} as Embed,
|
||||
],
|
||||
components: [
|
||||
{
|
||||
type: 1,
|
||||
components: [
|
||||
// Link to Roleypoly
|
||||
{
|
||||
type: 2,
|
||||
label: `Pick roles on ${new URL(context.config.uiPublicURI).hostname}`,
|
||||
url: `${context.config.uiPublicURI}/s/${interaction.guild_id}`,
|
||||
style: 5,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
roleypoly.ephemeral = true;
|
179
packages/api/src/routes/interactions/helpers.spec.ts
Normal file
179
packages/api/src/routes/interactions/helpers.spec.ts
Normal file
|
@ -0,0 +1,179 @@
|
|||
import { InteractionRequest, InteractionType } from '@roleypoly/types';
|
||||
import nacl from 'tweetnacl';
|
||||
import { configContext } from '../../utils/testHelpers';
|
||||
import { embedBuilder, verifyRequest } from './helpers';
|
||||
|
||||
//
|
||||
// Q: Why tweetnacl when WebCrypto is available?
|
||||
// A: Discord uses tweetnacl on their end, thus is also
|
||||
// used in far more examples of Discord Interactions than WebCrypto.
|
||||
// We don't actually use it in Workers, as SubtleCrypto using NODE-ED25519
|
||||
// is better in every way, and still gives us the same effect.
|
||||
//
|
||||
|
||||
describe('verifyRequest', () => {
|
||||
it('validates a successful Discord interactions request', async () => {
|
||||
const [config, context] = configContext();
|
||||
|
||||
const timestamp = String(Date.now());
|
||||
const body: InteractionRequest = {
|
||||
id: '123',
|
||||
type: InteractionType.APPLICATION_COMMAND,
|
||||
application_id: '123',
|
||||
token: '123',
|
||||
version: 1,
|
||||
};
|
||||
|
||||
const { publicKey, secretKey } = nacl.sign.keyPair();
|
||||
const signature = nacl.sign.detached(
|
||||
Buffer.from(timestamp + JSON.stringify(body)),
|
||||
secretKey
|
||||
);
|
||||
config.publicKey = Buffer.from(publicKey).toString('hex');
|
||||
|
||||
const request = new Request('http://local.test', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
headers: {
|
||||
'x-signature-timestamp': timestamp,
|
||||
'x-signature-ed25519': Buffer.from(signature).toString('hex'),
|
||||
},
|
||||
});
|
||||
|
||||
expect(await verifyRequest(context.config, request, body)).toBe(true);
|
||||
});
|
||||
|
||||
it('fails to validate a headerless Discord interactions request', async () => {
|
||||
const [config, context] = configContext();
|
||||
|
||||
const body: InteractionRequest = {
|
||||
id: '123',
|
||||
type: InteractionType.APPLICATION_COMMAND,
|
||||
application_id: '123',
|
||||
token: '123',
|
||||
version: 1,
|
||||
};
|
||||
|
||||
const { publicKey, secretKey } = nacl.sign.keyPair();
|
||||
config.publicKey = Buffer.from(publicKey).toString('hex');
|
||||
|
||||
const request = new Request('http://local.test', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
headers: {},
|
||||
});
|
||||
|
||||
expect(await verifyRequest(context.config, request, body)).toBe(false);
|
||||
});
|
||||
|
||||
it('fails to validate a bad signature from Discord', async () => {
|
||||
const [config, context] = configContext();
|
||||
|
||||
const timestamp = String(Date.now());
|
||||
const body: InteractionRequest = {
|
||||
id: '123',
|
||||
type: InteractionType.APPLICATION_COMMAND,
|
||||
application_id: '123',
|
||||
token: '123',
|
||||
version: 1,
|
||||
};
|
||||
|
||||
const { publicKey } = nacl.sign.keyPair();
|
||||
const { secretKey: otherKey } = nacl.sign.keyPair();
|
||||
const signature = nacl.sign.detached(
|
||||
Buffer.from(timestamp + JSON.stringify(body)),
|
||||
otherKey
|
||||
);
|
||||
config.publicKey = Buffer.from(publicKey).toString('hex');
|
||||
|
||||
const request = new Request('http://local.test', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
headers: {
|
||||
'x-signature-timestamp': timestamp,
|
||||
'x-signature-ed25519': Buffer.from(signature).toString('hex'),
|
||||
},
|
||||
});
|
||||
|
||||
expect(await verifyRequest(context.config, request, body)).toBe(false);
|
||||
});
|
||||
|
||||
it('fails to validate when signature differs from data', async () => {
|
||||
const [config, context] = configContext();
|
||||
|
||||
const timestamp = String(Date.now());
|
||||
const body: InteractionRequest = {
|
||||
id: '123',
|
||||
type: InteractionType.APPLICATION_COMMAND,
|
||||
application_id: '123',
|
||||
token: '123',
|
||||
version: 1,
|
||||
};
|
||||
|
||||
const { publicKey, secretKey } = nacl.sign.keyPair();
|
||||
const signature = nacl.sign.detached(
|
||||
Buffer.from(timestamp + JSON.stringify({ ...body, id: '456' })),
|
||||
secretKey
|
||||
);
|
||||
config.publicKey = Buffer.from(publicKey).toString('hex');
|
||||
|
||||
const request = new Request('http://local.test', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
headers: {
|
||||
'x-signature-timestamp': timestamp,
|
||||
'x-signature-ed25519': Buffer.from(signature).toString('hex'),
|
||||
},
|
||||
});
|
||||
|
||||
expect(await verifyRequest(context.config, request, body)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('embedBuilder', () => {
|
||||
it('builds embeds that discord approves of', () => {
|
||||
const embeds = embedBuilder({
|
||||
title: 'Test',
|
||||
fields: [
|
||||
{
|
||||
name: 'Field 1',
|
||||
value: 'role-1, role-2, role-3, role-4, role-5, '
|
||||
.repeat(1024 / 30 - 15)
|
||||
.replace(/, $/, ''),
|
||||
},
|
||||
{
|
||||
name: 'Field 2',
|
||||
value: 'role-1, role-2, role-3, role-4, role-5, '
|
||||
.repeat(1024 / 30 + 4)
|
||||
.replace(/, $/, ''),
|
||||
},
|
||||
],
|
||||
color: 0xff0000,
|
||||
});
|
||||
|
||||
expect(embeds).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
Object {
|
||||
"color": 16711680,
|
||||
"fields": Array [
|
||||
Object {
|
||||
"name": "Field 1",
|
||||
"value": "role-1, role-2, role-3, role-4, role-5, role-1, role-2, role-3, role-4, role-5, role-1, role-2, role-3, role-4, role-5, role-1, role-2, role-3, role-4, role-5, role-1, role-2, role-3, role-4, role-5, role-1, role-2, role-3, role-4, role-5, role-1, role-2, role-3, role-4, role-5, role-1, role-2, role-3, role-4, role-5, role-1, role-2, role-3, role-4, role-5, role-1, role-2, role-3, role-4, role-5, role-1, role-2, role-3, role-4, role-5, role-1, role-2, role-3, role-4, role-5, role-1, role-2, role-3, role-4, role-5, role-1, role-2, role-3, role-4, role-5, role-1, role-2, role-3, role-4, role-5, role-1, role-2, role-3, role-4, role-5, role-1, role-2, role-3, role-4, role-5, role-1, role-2, role-3, role-4, role-5, role-1, role-2, role-3, role-4, role-5",
|
||||
},
|
||||
Object {
|
||||
"name": "Field 2",
|
||||
"value": "role-1, role-2, role-3, role-4, role-5, role-1, role-2, role-3, role-4, role-5, role-1, role-2, role-3, role-4, role-5, role-1, role-2, role-3, role-4, role-5, role-1, role-2, role-3, role-4, role-5, role-1, role-2, role-3, role-4, role-5, role-1, role-2, role-3, role-4, role-5, role-1, role-2, role-3, role-4, role-5, role-1, role-2, role-3, role-4, role-5, role-1, role-2, role-3, role-4, role-5, role-1, role-2, role-3, role-4, role-5, role-1, role-2, role-3, role-4, role-5, role-1, role-2, role-3, role-4, role-5, role-1, role-2, role-3, role-4, role-5, role-1, role-2, role-3, role-4, role-5, role-1, role-2, role-3, role-4, role-5, role-1, role-2, role-3, role-4, role-5, role-1, role-2, role-3, role-4, role-5, role-1, role-2, role-3, role-4, role-5, role-1, role-2, role-3, role-4, role-5, role-1, role-2, role-3, role-4, role-5, role-1, role-2, role-3, role-4, role-5, role-1, role-2, role-3, role-4, role-5, role-1, role-2, role-3, role-4, role-5, role-1, role-2, role-3, role-4, role-5, role-1, role-2, role-3",
|
||||
},
|
||||
Object {
|
||||
"name": "Field 2 (continued)",
|
||||
"value": "role-5, role-1, role-2, role-3, role-4, role-5, role-1, role-2, role-3, role-4, role-5, role-1, role-2, role-3, role-4, role-5, role-1, role-2, role-3, role-4, role-5, role-1, role-2, role-3, role-4, role-5, role-1, role-2, role-3, role-4, role-5, role-1, role-2, role-3, role-4, role-5, role-1, role-2, role-3, role-4, role-5, role-1, role-2, role-3, role-4, role-5, role-1, role-2, role-3, role-4, role-5, role-1, role-2, role-3, role-4, role-5, role-1, role-2, role-3, role-4, role-5",
|
||||
},
|
||||
],
|
||||
"title": "Test",
|
||||
},
|
||||
]
|
||||
`);
|
||||
expect(embeds.length).toBe(1);
|
||||
expect(embeds[0].fields.length).toBe(3);
|
||||
});
|
||||
});
|
203
packages/api/src/routes/interactions/helpers.ts
Normal file
203
packages/api/src/routes/interactions/helpers.ts
Normal file
|
@ -0,0 +1,203 @@
|
|||
import { Config } from '@roleypoly/api/src/utils/config';
|
||||
import { Context } from '@roleypoly/api/src/utils/context';
|
||||
import { AuthType, discordFetch } from '@roleypoly/api/src/utils/discord';
|
||||
import { Embed, InteractionRequest, InteractionResponse } from '@roleypoly/types';
|
||||
|
||||
export const verifyRequest = async (
|
||||
config: Config,
|
||||
request: Request,
|
||||
interaction: InteractionRequest
|
||||
): Promise<boolean> => {
|
||||
try {
|
||||
const timestamp = request.headers.get('x-signature-timestamp');
|
||||
const signature = request.headers.get('x-signature-ed25519');
|
||||
|
||||
if (!timestamp || !signature) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const key = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
bufferizeHex(config.publicKey),
|
||||
{ name: 'NODE-ED25519', namedCurve: 'NODE-ED25519', public: true } as any,
|
||||
false,
|
||||
['verify']
|
||||
);
|
||||
|
||||
const verified = await crypto.subtle.verify(
|
||||
'NODE-ED25519',
|
||||
key,
|
||||
bufferizeHex(signature),
|
||||
bufferizeString(timestamp + JSON.stringify(interaction))
|
||||
);
|
||||
|
||||
return verified;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// Cloudflare Workers + SubtleCrypto has no idea what a Buffer.from() is.
|
||||
// What the fuck?
|
||||
const bufferizeHex = (input: string) => {
|
||||
const buffer = new Uint8Array(input.length / 2);
|
||||
|
||||
for (let i = 0; i < input.length; i += 2) {
|
||||
buffer[i / 2] = parseInt(input.substring(i, i + 2), 16);
|
||||
}
|
||||
|
||||
return buffer;
|
||||
};
|
||||
|
||||
const bufferizeString = (input: string) => {
|
||||
const encoder = new TextEncoder();
|
||||
return encoder.encode(input);
|
||||
};
|
||||
|
||||
export type InteractionHandler = ((
|
||||
interaction: InteractionRequest,
|
||||
context: Context
|
||||
) => Promise<InteractionResponse> | InteractionResponse) & {
|
||||
ephemeral?: boolean;
|
||||
deferred?: boolean;
|
||||
};
|
||||
|
||||
export const runAsync = async (
|
||||
handler: InteractionHandler,
|
||||
interaction: InteractionRequest,
|
||||
context: Context
|
||||
): Promise<void> => {
|
||||
const url = `/webhooks/${interaction.application_id}/${interaction.token}/messages/@original`;
|
||||
|
||||
try {
|
||||
const response = await handler(interaction, context);
|
||||
if (!response) {
|
||||
throw new Error('Interaction handler returned no response');
|
||||
}
|
||||
|
||||
console.log({ response });
|
||||
|
||||
await discordFetch(url, '', AuthType.None, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
...response.data,
|
||||
}),
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('/interations runAsync failed', {
|
||||
e,
|
||||
interaction: {
|
||||
data: interaction.data,
|
||||
user: interaction.user,
|
||||
guild: interaction.guild_id,
|
||||
},
|
||||
});
|
||||
try {
|
||||
await discordFetch(url, '', AuthType.None, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
content: "I'm sorry, I'm having trouble processing this request.",
|
||||
} as InteractionResponse['data']),
|
||||
});
|
||||
} catch (e) {}
|
||||
}
|
||||
};
|
||||
|
||||
export const getName = (interaction: InteractionRequest): string => {
|
||||
return (
|
||||
interaction.member?.nick ||
|
||||
interaction.member?.user?.username ||
|
||||
interaction.user?.username ||
|
||||
'friend'
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Take a single big embed and fit it into Discord limits
|
||||
* per embed, 25 fields, and 1024 characters per field.
|
||||
* so we'll make new embeds/fields as the content gets too long.
|
||||
*/
|
||||
export const embedBuilder = (embed: Embed): Embed[] => {
|
||||
const embeds: Embed[] = [];
|
||||
|
||||
const titleCorrection = (title: string, withContinued?: boolean) => {
|
||||
const suffix = withContinued ? '... (continued)' : '...';
|
||||
const offsetTitle = title.length + suffix.length;
|
||||
return title.length > 256 - offsetTitle
|
||||
? title.slice(0, 256 - offsetTitle) + suffix
|
||||
: withContinued
|
||||
? `${title} (continued)`
|
||||
: title;
|
||||
};
|
||||
|
||||
let currentEmbed: Embed = {
|
||||
color: embed.color,
|
||||
title: embed.title,
|
||||
fields: [],
|
||||
};
|
||||
|
||||
let knownFieldTitles: string[] = [];
|
||||
|
||||
const commitField = (field: Embed['fields'][0]) => {
|
||||
if (currentEmbed.fields.length === 25) {
|
||||
embeds.push(currentEmbed);
|
||||
currentEmbed = {
|
||||
color: embed.color,
|
||||
title: `${embed.title} (continued)`,
|
||||
fields: [],
|
||||
};
|
||||
}
|
||||
|
||||
console.warn({ field });
|
||||
const addContinued = knownFieldTitles.includes(field.name);
|
||||
|
||||
if (!addContinued) {
|
||||
knownFieldTitles.push(field.name);
|
||||
}
|
||||
|
||||
field.name = titleCorrection(`${field.name}`, addContinued);
|
||||
console.warn({ field, knownFieldTitles });
|
||||
|
||||
currentEmbed.fields.push(field);
|
||||
};
|
||||
|
||||
for (let field of embed.fields) {
|
||||
if (field.value.length <= 1024) {
|
||||
commitField(field);
|
||||
continue;
|
||||
}
|
||||
|
||||
const split = field.value.split(', '); // we know we'll be using , as a delimiter
|
||||
let fieldValue: Embed['fields'][0]['value'] = '';
|
||||
for (let part of split) {
|
||||
if (fieldValue.length + part.length > 1024) {
|
||||
commitField({
|
||||
name: field.name,
|
||||
value: fieldValue.replace(/, $/, ''),
|
||||
});
|
||||
fieldValue = '';
|
||||
} else {
|
||||
fieldValue += part + ', ';
|
||||
}
|
||||
}
|
||||
|
||||
if (fieldValue.length > 0) {
|
||||
commitField({
|
||||
name: field.name,
|
||||
value: fieldValue.replace(/, $/, ''),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (currentEmbed.fields.length > 0) {
|
||||
embeds.push(currentEmbed);
|
||||
}
|
||||
|
||||
return embeds;
|
||||
};
|
45
packages/api/src/routes/interactions/interactions.spec.ts
Normal file
45
packages/api/src/routes/interactions/interactions.spec.ts
Normal file
|
@ -0,0 +1,45 @@
|
|||
jest.mock('../../utils/discord');
|
||||
import { InteractionCallbackType, InteractionFlags } from '@roleypoly/types';
|
||||
import { AuthType, discordFetch } from '../../utils/discord';
|
||||
import { configContext } from '../../utils/testHelpers';
|
||||
import { extractInteractionResponse, makeInteractionsRequest } from './testHelpers';
|
||||
|
||||
const mockDiscordFetch = discordFetch as jest.Mock;
|
||||
|
||||
it('responds with a simple hello-world!', async () => {
|
||||
const [config, context] = configContext();
|
||||
const response = await makeInteractionsRequest(context, {
|
||||
name: 'hello-world',
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
const interaction = await extractInteractionResponse(response);
|
||||
|
||||
expect(interaction.type).toEqual(
|
||||
InteractionCallbackType.DEFERRED_CHANNEL_MESSAGE_WITH_SOURCE
|
||||
);
|
||||
expect(interaction.data).toEqual({
|
||||
flags: InteractionFlags.EPHEMERAL,
|
||||
});
|
||||
expect(mockDiscordFetch).toBeCalledWith(expect.any(String), '', AuthType.None, {
|
||||
body: JSON.stringify({
|
||||
content: 'Hey there, test-user-nick',
|
||||
}),
|
||||
headers: expect.any(Object),
|
||||
method: 'PATCH',
|
||||
});
|
||||
});
|
||||
|
||||
it('does not allow requests that are invalid', async () => {
|
||||
const [config, context] = configContext();
|
||||
const response = await makeInteractionsRequest(
|
||||
context,
|
||||
{
|
||||
name: 'hello-world',
|
||||
},
|
||||
true
|
||||
);
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
});
|
92
packages/api/src/routes/interactions/interactions.ts
Normal file
92
packages/api/src/routes/interactions/interactions.ts
Normal file
|
@ -0,0 +1,92 @@
|
|||
import { helloWorld } from '@roleypoly/api/src/routes/interactions/commands/hello-world';
|
||||
import { pickRole } from '@roleypoly/api/src/routes/interactions/commands/pick-role';
|
||||
import { pickableRoles } from '@roleypoly/api/src/routes/interactions/commands/pickable-roles';
|
||||
import { removeRole } from '@roleypoly/api/src/routes/interactions/commands/remove-role';
|
||||
import { roleypoly } from '@roleypoly/api/src/routes/interactions/commands/roleypoly';
|
||||
import {
|
||||
InteractionHandler,
|
||||
runAsync,
|
||||
verifyRequest,
|
||||
} from '@roleypoly/api/src/routes/interactions/helpers';
|
||||
import { notImplemented } from '@roleypoly/api/src/routes/interactions/responses';
|
||||
import { Context, RoleypolyHandler } from '@roleypoly/api/src/utils/context';
|
||||
import { invalid, json } from '@roleypoly/api/src/utils/response';
|
||||
import {
|
||||
InteractionCallbackType,
|
||||
InteractionData,
|
||||
InteractionFlags,
|
||||
InteractionRequest,
|
||||
InteractionResponse,
|
||||
InteractionType,
|
||||
} from '@roleypoly/types';
|
||||
|
||||
const commands: Record<InteractionData['name'], InteractionHandler> = {
|
||||
'hello-world': helloWorld,
|
||||
roleypoly: roleypoly,
|
||||
'pickable-roles': pickableRoles,
|
||||
'pick-role': pickRole,
|
||||
'remove-role': removeRole,
|
||||
};
|
||||
|
||||
export const handleInteraction: RoleypolyHandler = async (
|
||||
request: Request,
|
||||
context: Context
|
||||
) => {
|
||||
const interaction: InteractionRequest = await request.json();
|
||||
if (!interaction) {
|
||||
return invalid();
|
||||
}
|
||||
|
||||
if (!(await verifyRequest(context.config, request, interaction))) {
|
||||
console.warn('interactions: invalid signature');
|
||||
return new Response('invalid request signature', { status: 401 });
|
||||
}
|
||||
|
||||
if (interaction.type !== InteractionType.APPLICATION_COMMAND) {
|
||||
if (interaction.type === InteractionType.PING) {
|
||||
console.info('interactions: ping');
|
||||
return json({ type: InteractionCallbackType.PONG });
|
||||
}
|
||||
|
||||
console.warn('interactions: not application command');
|
||||
return json({ err: 'not implemented' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!interaction.data) {
|
||||
return json({ err: 'data missing' }, { status: 400 });
|
||||
}
|
||||
|
||||
const handler = commands[interaction.data.name] || notImplemented;
|
||||
|
||||
try {
|
||||
if (handler.deferred) {
|
||||
context.fetchContext.waitUntil(runAsync(handler, interaction, context));
|
||||
|
||||
return json({
|
||||
type: InteractionCallbackType.DEFERRED_CHANNEL_MESSAGE_WITH_SOURCE,
|
||||
data: {
|
||||
flags: handler.ephemeral ? InteractionFlags.EPHEMERAL : 0,
|
||||
},
|
||||
} as InteractionResponse);
|
||||
}
|
||||
|
||||
const response = await handler(interaction, context);
|
||||
return json({
|
||||
type: InteractionCallbackType.CHANNEL_MESSAGE_WITH_SOURCE,
|
||||
data: {
|
||||
flags: handler.ephemeral ? InteractionFlags.EPHEMERAL : 0,
|
||||
...response.data,
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('/interactions error:', {
|
||||
interaction: {
|
||||
data: interaction.data,
|
||||
user: interaction.user,
|
||||
guild: interaction.guild_id,
|
||||
},
|
||||
e,
|
||||
});
|
||||
return invalid();
|
||||
}
|
||||
};
|
61
packages/api/src/routes/interactions/responses.ts
Normal file
61
packages/api/src/routes/interactions/responses.ts
Normal file
|
@ -0,0 +1,61 @@
|
|||
import { InteractionHandler } from '@roleypoly/api/src/routes/interactions/helpers';
|
||||
import {
|
||||
InteractionCallbackType,
|
||||
InteractionFlags,
|
||||
InteractionResponse,
|
||||
} from '@roleypoly/types';
|
||||
|
||||
export const mustBeInGuild: InteractionHandler = (): InteractionResponse => ({
|
||||
type: InteractionCallbackType.CHANNEL_MESSAGE_WITH_SOURCE,
|
||||
data: {
|
||||
content: ':x: This command has to be used in a server.',
|
||||
flags: InteractionFlags.EPHEMERAL,
|
||||
},
|
||||
});
|
||||
|
||||
export const invalid: InteractionHandler = (): InteractionResponse => ({
|
||||
type: InteractionCallbackType.CHANNEL_MESSAGE_WITH_SOURCE,
|
||||
data: {
|
||||
content: ':x: You filled that command out wrong...',
|
||||
flags: InteractionFlags.EPHEMERAL,
|
||||
},
|
||||
});
|
||||
|
||||
export const somethingWentWrong: InteractionHandler = (): InteractionResponse => ({
|
||||
type: InteractionCallbackType.CHANNEL_MESSAGE_WITH_SOURCE,
|
||||
data: {
|
||||
content: '<a:promareFlame:624850108667789333> Something went terribly wrong.',
|
||||
flags: InteractionFlags.EPHEMERAL,
|
||||
},
|
||||
});
|
||||
|
||||
export const notImplemented: InteractionHandler = (): InteractionResponse => ({
|
||||
type: InteractionCallbackType.CHANNEL_MESSAGE_WITH_SOURCE,
|
||||
data: {
|
||||
content: ':x: This command is not implemented yet.',
|
||||
flags: InteractionFlags.EPHEMERAL,
|
||||
},
|
||||
});
|
||||
|
||||
export const embedResponse = (
|
||||
title: string,
|
||||
description: string,
|
||||
color?: number
|
||||
): InteractionResponse => ({
|
||||
type: InteractionCallbackType.CHANNEL_MESSAGE_WITH_SOURCE,
|
||||
data: {
|
||||
embeds: [
|
||||
{
|
||||
color: color || 0x00ff00,
|
||||
title,
|
||||
description,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
export const embedPalette = {
|
||||
success: 0x1d8227,
|
||||
error: 0xf14343,
|
||||
neutral: 0x453e3d,
|
||||
};
|
155
packages/api/src/routes/interactions/role-picker-common.ts
Normal file
155
packages/api/src/routes/interactions/role-picker-common.ts
Normal file
|
@ -0,0 +1,155 @@
|
|||
import { getGuild, getGuildData } from '@roleypoly/api/src/guilds/getters';
|
||||
import { calculateNewRoles } from '@roleypoly/api/src/routes/guilds/guild-roles-put';
|
||||
import { getName } from '@roleypoly/api/src/routes/interactions/helpers';
|
||||
import {
|
||||
embedPalette,
|
||||
embedResponse,
|
||||
} from '@roleypoly/api/src/routes/interactions/responses';
|
||||
import { Context } from '@roleypoly/api/src/utils/context';
|
||||
import { APIMember, AuthType, discordFetch } from '@roleypoly/api/src/utils/discord';
|
||||
import { isIdenticalArray } from '@roleypoly/misc-utils/collection-tools';
|
||||
import {
|
||||
CategoryType,
|
||||
InteractionRequest,
|
||||
InteractionResponse,
|
||||
RoleTransaction,
|
||||
TransactionType,
|
||||
} from '@roleypoly/types';
|
||||
|
||||
export const rolePickerCommon = async (
|
||||
interaction: InteractionRequest,
|
||||
context: Context,
|
||||
action: TransactionType
|
||||
): Promise<InteractionResponse> => {
|
||||
if (!interaction.guild_id) {
|
||||
return embedResponse(
|
||||
':x: Error',
|
||||
`Hey ${getName(
|
||||
interaction
|
||||
)}. You need to use this command in a server, not in a DM.`
|
||||
);
|
||||
}
|
||||
|
||||
const currentRoles = interaction.member?.roles || [];
|
||||
|
||||
const [guildData, guild] = await Promise.all([
|
||||
getGuildData(context.config, interaction.guild_id),
|
||||
getGuild(context.config, interaction.guild_id),
|
||||
]);
|
||||
|
||||
if (!guildData || !guild) {
|
||||
return embedResponse(
|
||||
':x: Error',
|
||||
`Hey ${getName(
|
||||
interaction
|
||||
)}. Something's wrong with the server you're in. Try picking your roles at ${
|
||||
context.config.uiPublicURI
|
||||
}/s/${interaction.guild_id} instead.`,
|
||||
embedPalette.error
|
||||
);
|
||||
}
|
||||
|
||||
const roleToPick = interaction.data?.options?.[0]?.value;
|
||||
if (!roleToPick) {
|
||||
return embedResponse(
|
||||
':fire: Discord sent me the wrong data',
|
||||
`Hey ${getName(interaction)}. Please try again later.`,
|
||||
embedPalette.error
|
||||
);
|
||||
}
|
||||
|
||||
const hasRole = interaction.member?.roles.includes(roleToPick);
|
||||
if (action === TransactionType.Add && hasRole) {
|
||||
return embedResponse(
|
||||
`:white_check_mark: You already have that role.`,
|
||||
`Hey ${getName(interaction)}. You already have <@&${roleToPick}>!`,
|
||||
embedPalette.neutral
|
||||
);
|
||||
}
|
||||
|
||||
if (action === TransactionType.Remove && !hasRole) {
|
||||
return embedResponse(
|
||||
`:white_check_mark: You don't have that role.`,
|
||||
`Hey ${getName(interaction)}. You already don't have <@&${roleToPick}>!`,
|
||||
embedPalette.neutral
|
||||
);
|
||||
}
|
||||
|
||||
const extraTransactions: RoleTransaction[] = [];
|
||||
let isSingle = false;
|
||||
if (action === TransactionType.Add) {
|
||||
// For single-type categories, let's also generate the remove rules for the other roles in the category
|
||||
const category = guildData.categories.find((category) =>
|
||||
category.roles.includes(roleToPick)
|
||||
);
|
||||
if (category?.type === CategoryType.Single) {
|
||||
const otherRoles = category.roles.filter((role) => role !== roleToPick);
|
||||
extraTransactions.push(
|
||||
...otherRoles.map((role) => ({ action: TransactionType.Remove, id: role }))
|
||||
);
|
||||
isSingle = true;
|
||||
}
|
||||
}
|
||||
|
||||
const newRoles = calculateNewRoles({
|
||||
currentRoles,
|
||||
guildRoles: guild.roles,
|
||||
guildData,
|
||||
updateRequest: {
|
||||
knownState: currentRoles,
|
||||
transactions: [{ action, id: roleToPick }, ...extraTransactions],
|
||||
},
|
||||
});
|
||||
|
||||
if (isIdenticalArray(currentRoles, newRoles)) {
|
||||
return embedResponse(
|
||||
':x: You cannot pick this role.',
|
||||
`Hey ${getName(
|
||||
interaction
|
||||
)}. <@&${roleToPick}> isn't pickable. Check /pickable-roles to see which roles you can use.`,
|
||||
embedPalette.error
|
||||
);
|
||||
}
|
||||
|
||||
const patchMemberRoles = await discordFetch<APIMember>(
|
||||
`/guilds/${interaction.guild_id}/members/${interaction.member?.user?.id}`,
|
||||
context.config.botToken,
|
||||
AuthType.Bot,
|
||||
{
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
'x-audit-log-reason': `Picked their roles via /${
|
||||
action === TransactionType.Add ? 'pick' : 'remove'
|
||||
}-role`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
roles: newRoles,
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
if (!patchMemberRoles) {
|
||||
return embedResponse(
|
||||
':x: Discord stopped me from updating your roles.',
|
||||
`Hey ${getName(
|
||||
interaction
|
||||
)}. Discord didn't let me give you <@&${roleToPick}>. Could you try again later?`,
|
||||
embedPalette.error
|
||||
);
|
||||
}
|
||||
|
||||
return action === TransactionType.Add
|
||||
? embedResponse(
|
||||
':white_check_mark: You got it!',
|
||||
`Hey ${getName(interaction)}, I gave you <@&${roleToPick}>!${
|
||||
isSingle ? `\nThe other roles in this category have been removed.` : ''
|
||||
}`,
|
||||
embedPalette.success
|
||||
)
|
||||
: embedResponse(
|
||||
":white_check_mark: You (don't) got it!",
|
||||
`Hey ${getName(interaction)}, I took away <@&${roleToPick}>!`,
|
||||
embedPalette.success
|
||||
);
|
||||
};
|
130
packages/api/src/routes/interactions/testHelpers.ts
Normal file
130
packages/api/src/routes/interactions/testHelpers.ts
Normal file
|
@ -0,0 +1,130 @@
|
|||
import { handleInteraction } from '@roleypoly/api/src/routes/interactions/interactions';
|
||||
import { Context } from '@roleypoly/api/src/utils/context';
|
||||
import { AuthType } from '@roleypoly/api/src/utils/discord';
|
||||
import { getID } from '@roleypoly/api/src/utils/id';
|
||||
import {
|
||||
InteractionCallbackData,
|
||||
InteractionCallbackType,
|
||||
InteractionData,
|
||||
InteractionFlags,
|
||||
InteractionRequest,
|
||||
InteractionResponse,
|
||||
InteractionType,
|
||||
} from '@roleypoly/types';
|
||||
import nacl from 'tweetnacl';
|
||||
|
||||
const { publicKey, secretKey } = nacl.sign.keyPair();
|
||||
const hexPublicKey = Buffer.from(publicKey).toString('hex');
|
||||
|
||||
export const getSignatureHeaders = (
|
||||
context: Context,
|
||||
interaction: InteractionRequest
|
||||
): {
|
||||
'x-signature-ed25519': string;
|
||||
'x-signature-timestamp': string;
|
||||
} => {
|
||||
const timestamp = Date.now().toString();
|
||||
const body = JSON.stringify(interaction);
|
||||
const signature = nacl.sign.detached(Buffer.from(timestamp + body), secretKey);
|
||||
|
||||
return {
|
||||
'x-signature-ed25519': Buffer.from(signature).toString('hex'),
|
||||
'x-signature-timestamp': timestamp,
|
||||
};
|
||||
};
|
||||
|
||||
export const makeInteractionsRequest = async (
|
||||
context: Context,
|
||||
interactionData: Partial<InteractionData>,
|
||||
forceInvalid?: boolean,
|
||||
topLevelMixin?: Partial<InteractionRequest>
|
||||
): Promise<Response> => {
|
||||
context.config.publicKey = hexPublicKey;
|
||||
|
||||
const interaction: InteractionRequest = {
|
||||
data: {
|
||||
id: getID(),
|
||||
name: 'hello-world',
|
||||
...interactionData,
|
||||
} as InteractionData,
|
||||
id: '123',
|
||||
type: InteractionType.APPLICATION_COMMAND,
|
||||
application_id: context.config.botClientID,
|
||||
token: getID(),
|
||||
version: 1,
|
||||
user: {
|
||||
id: '123',
|
||||
username: 'test-user',
|
||||
discriminator: '1234',
|
||||
bot: false,
|
||||
avatar: '',
|
||||
},
|
||||
member: {
|
||||
nick: 'test-user-nick',
|
||||
roles: [],
|
||||
},
|
||||
...topLevelMixin,
|
||||
};
|
||||
|
||||
const request = new Request('http://localhost:3000/interactions', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
...getSignatureHeaders(context, {
|
||||
...interaction,
|
||||
...(forceInvalid ? { id: 'invalid-id' } : {}),
|
||||
}),
|
||||
},
|
||||
body: JSON.stringify(interaction),
|
||||
});
|
||||
|
||||
return handleInteraction(request, context);
|
||||
};
|
||||
|
||||
export const extractInteractionResponse = async (
|
||||
response: Response
|
||||
): Promise<InteractionResponse> => {
|
||||
const body = await response.json();
|
||||
return body as InteractionResponse;
|
||||
};
|
||||
|
||||
export const isDeferred = (response: InteractionResponse): boolean => {
|
||||
return response.type === InteractionCallbackType.DEFERRED_CHANNEL_MESSAGE_WITH_SOURCE;
|
||||
};
|
||||
|
||||
export const isEphemeral = (response: InteractionResponse): boolean => {
|
||||
return (
|
||||
(response.data?.flags || 0 & InteractionFlags.EPHEMERAL) ===
|
||||
InteractionFlags.EPHEMERAL
|
||||
);
|
||||
};
|
||||
|
||||
export const interactionData = (
|
||||
response: InteractionResponse
|
||||
): Omit<InteractionCallbackData, 'flags'> | undefined => {
|
||||
const { data } = response;
|
||||
if (!data) return undefined;
|
||||
|
||||
delete data.flags;
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const mockUpdateCall = (
|
||||
expect: any,
|
||||
data: Omit<InteractionCallbackData, 'flags'>
|
||||
) => {
|
||||
return [
|
||||
expect.any(String),
|
||||
'',
|
||||
AuthType.None,
|
||||
{
|
||||
body: JSON.stringify({
|
||||
...data,
|
||||
}),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
method: 'PATCH',
|
||||
},
|
||||
];
|
||||
};
|
53
packages/api/src/sessions/create.spec.ts
Normal file
53
packages/api/src/sessions/create.spec.ts
Normal file
|
@ -0,0 +1,53 @@
|
|||
jest.mock('../utils/discord');
|
||||
|
||||
import { AuthTokenResponse } from '@roleypoly/types';
|
||||
import { parseEnvironment } from '../utils/config';
|
||||
import { getTokenGuilds, getTokenUser } from '../utils/discord';
|
||||
import { getBindings } from '../utils/testHelpers';
|
||||
import { createSession } from './create';
|
||||
|
||||
const mockGetTokenGuilds = getTokenGuilds as jest.Mock;
|
||||
const mockGetTokenUser = getTokenUser as jest.Mock;
|
||||
|
||||
it('creates a session from tokens', async () => {
|
||||
const config = parseEnvironment(getBindings());
|
||||
|
||||
const tokens: AuthTokenResponse = {
|
||||
access_token: 'test-access-token',
|
||||
refresh_token: 'test-refresh-token',
|
||||
expires_in: 3600,
|
||||
scope: 'identify guilds',
|
||||
token_type: 'Bearer',
|
||||
};
|
||||
|
||||
mockGetTokenUser.mockReturnValueOnce({
|
||||
id: 'test-user-id',
|
||||
username: 'test-username',
|
||||
discriminator: 'test-discriminator',
|
||||
avatar: 'test-avatar',
|
||||
bot: false,
|
||||
});
|
||||
|
||||
mockGetTokenGuilds.mockReturnValueOnce([]);
|
||||
|
||||
const session = await createSession(config, tokens);
|
||||
|
||||
expect(session).toEqual({
|
||||
sessionID: expect.any(String),
|
||||
user: {
|
||||
id: 'test-user-id',
|
||||
discriminator: 'test-discriminator',
|
||||
avatar: 'test-avatar',
|
||||
bot: false,
|
||||
username: 'test-username',
|
||||
},
|
||||
guilds: [],
|
||||
tokens,
|
||||
});
|
||||
|
||||
expect(mockGetTokenUser).toBeCalledWith(tokens.access_token);
|
||||
expect(mockGetTokenGuilds).toBeCalledWith(tokens.access_token);
|
||||
|
||||
const savedSession = await config.kv.sessions.get(session?.sessionID || '');
|
||||
expect(savedSession).toEqual(session);
|
||||
});
|
31
packages/api/src/sessions/create.ts
Normal file
31
packages/api/src/sessions/create.ts
Normal file
|
@ -0,0 +1,31 @@
|
|||
import { Config } from '@roleypoly/api/src/utils/config';
|
||||
import { getTokenGuilds, getTokenUser } from '@roleypoly/api/src/utils/discord';
|
||||
import { getID } from '@roleypoly/api/src/utils/id';
|
||||
import { AuthTokenResponse, SessionData } from '@roleypoly/types';
|
||||
|
||||
export const createSession = async (
|
||||
config: Config,
|
||||
tokens: AuthTokenResponse
|
||||
): Promise<SessionData | null> => {
|
||||
const [user, guilds] = await Promise.all([
|
||||
getTokenUser(tokens.access_token),
|
||||
getTokenGuilds(tokens.access_token),
|
||||
]);
|
||||
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const sessionID = getID();
|
||||
|
||||
const session: SessionData = {
|
||||
sessionID,
|
||||
user,
|
||||
guilds,
|
||||
tokens,
|
||||
};
|
||||
|
||||
await config.kv.sessions.put(sessionID, session, config.retention.session);
|
||||
|
||||
return session;
|
||||
};
|
172
packages/api/src/sessions/middleware.spec.ts
Normal file
172
packages/api/src/sessions/middleware.spec.ts
Normal file
|
@ -0,0 +1,172 @@
|
|||
import { Router } from 'itty-router';
|
||||
import { Context } from '../utils/context';
|
||||
import { json } from '../utils/response';
|
||||
import { configContext, makeSession } from '../utils/testHelpers';
|
||||
import { requireSession, withAuthMode, withSession } from './middleware';
|
||||
|
||||
it('detects anonymous auth mode via middleware', async () => {
|
||||
const [, context] = configContext();
|
||||
const router = Router();
|
||||
const testFn = jest.fn();
|
||||
|
||||
router.all('*', withAuthMode).get('/', (request, context) => {
|
||||
expect(context.authMode.type).toBe('anonymous');
|
||||
testFn();
|
||||
return json({});
|
||||
});
|
||||
|
||||
await router.handle(new Request('http://test.local/'), context);
|
||||
|
||||
expect(testFn).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('detects bearer auth mode via middleware', async () => {
|
||||
const [, context] = configContext();
|
||||
const testFn = jest.fn();
|
||||
|
||||
const token = 'abc123';
|
||||
const router = Router();
|
||||
router.all('*', withAuthMode).get('/', (request, context) => {
|
||||
expect(context.authMode.type).toBe('bearer');
|
||||
expect(context.authMode.sessionId).toBe(token);
|
||||
testFn();
|
||||
return json({});
|
||||
});
|
||||
|
||||
await router.handle(
|
||||
new Request('http://test.local/', {
|
||||
headers: {
|
||||
authorization: `Bearer ${token}`,
|
||||
},
|
||||
}),
|
||||
context
|
||||
);
|
||||
|
||||
expect(testFn).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('detects bot auth mode via middleware', async () => {
|
||||
const testFn = jest.fn();
|
||||
const [, context] = configContext();
|
||||
|
||||
const token = 'abc123';
|
||||
const router = Router();
|
||||
router.all('*', withAuthMode).get('/', (request, context) => {
|
||||
expect(context.authMode.type).toBe('bot');
|
||||
expect(context.authMode.identity).toBe(token);
|
||||
testFn();
|
||||
return json({});
|
||||
});
|
||||
|
||||
await router.handle(
|
||||
new Request('http://test.local/', {
|
||||
headers: {
|
||||
authorization: `Bot ${token}`,
|
||||
},
|
||||
}),
|
||||
context
|
||||
);
|
||||
|
||||
expect(testFn).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('sets Context.session via withSession middleware', async () => {
|
||||
const testFn = jest.fn();
|
||||
const [config, context] = configContext();
|
||||
|
||||
const session = await makeSession(config);
|
||||
|
||||
const router = Router();
|
||||
router.all('*', withAuthMode, withSession).get('/', (request, context: Context) => {
|
||||
expect(context.session).toBeDefined();
|
||||
expect(context.session!.sessionID).toBe(session.sessionID);
|
||||
testFn();
|
||||
return json({});
|
||||
});
|
||||
|
||||
await router.handle(
|
||||
new Request('http://test.local/', {
|
||||
headers: {
|
||||
authorization: `Bearer ${session.sessionID}`,
|
||||
},
|
||||
}),
|
||||
context
|
||||
);
|
||||
expect(testFn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not set Context.session when session is invalid', async () => {
|
||||
const testFn = jest.fn();
|
||||
const [, context] = configContext();
|
||||
|
||||
const router = Router();
|
||||
router.all('*', withAuthMode, withSession).get('/', (request, context: Context) => {
|
||||
expect(context.session).not.toBeDefined();
|
||||
testFn();
|
||||
return json({});
|
||||
});
|
||||
|
||||
await router.handle(
|
||||
new Request('http://test.local/', {
|
||||
headers: {
|
||||
authorization: `Bearer abc123`,
|
||||
},
|
||||
}),
|
||||
context
|
||||
);
|
||||
|
||||
expect(testFn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('errors with 401 when requireSession is coupled with invalid session', async () => {
|
||||
const [, context] = configContext();
|
||||
const router = Router();
|
||||
|
||||
const testFn = jest.fn();
|
||||
router
|
||||
.all('*', withAuthMode, withSession, requireSession)
|
||||
.get('/', (request, context: Context) => {
|
||||
testFn();
|
||||
return json({});
|
||||
});
|
||||
|
||||
const response = await router.handle(
|
||||
new Request('http://test.local/', {
|
||||
headers: {
|
||||
authorization: `Bearer abc123`,
|
||||
},
|
||||
}),
|
||||
context
|
||||
);
|
||||
|
||||
expect(testFn).not.toHaveBeenCalled();
|
||||
expect(response.status).toBe(401);
|
||||
});
|
||||
|
||||
it('passes through when requireSession is coupled with a valid session', async () => {
|
||||
const [config, context] = configContext();
|
||||
|
||||
const session = await makeSession(config);
|
||||
const router = Router();
|
||||
|
||||
const testFn = jest.fn();
|
||||
router
|
||||
.all('*', withAuthMode, withSession, requireSession)
|
||||
.get('/', (request, context: Context) => {
|
||||
expect(context.session).toBeDefined();
|
||||
testFn();
|
||||
return json({});
|
||||
});
|
||||
|
||||
const response = await router.handle(
|
||||
new Request('http://test.local/', {
|
||||
headers: {
|
||||
authorization: `Bearer ${session.sessionID}`,
|
||||
},
|
||||
}),
|
||||
context
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(testFn).toHaveBeenCalled();
|
||||
});
|
67
packages/api/src/sessions/middleware.ts
Normal file
67
packages/api/src/sessions/middleware.ts
Normal file
|
@ -0,0 +1,67 @@
|
|||
import { Context, RoleypolyMiddleware } from '@roleypoly/api/src/utils/context';
|
||||
import { unauthorized } from '@roleypoly/api/src/utils/response';
|
||||
import { SessionData } from '@roleypoly/types';
|
||||
|
||||
export const withSession: RoleypolyMiddleware = async (
|
||||
request: Request,
|
||||
context: Context
|
||||
) => {
|
||||
if (context.authMode.type !== 'bearer') {
|
||||
return;
|
||||
}
|
||||
|
||||
const session = await context.config.kv.sessions.get<SessionData>(
|
||||
context.authMode.sessionId
|
||||
);
|
||||
if (!session) {
|
||||
return;
|
||||
}
|
||||
|
||||
context.session = session;
|
||||
};
|
||||
|
||||
export const requireSession: RoleypolyMiddleware = (
|
||||
request: Request,
|
||||
context: Context
|
||||
) => {
|
||||
if (context.authMode.type !== 'bearer' || !context.session) {
|
||||
return unauthorized();
|
||||
}
|
||||
};
|
||||
|
||||
export const withAuthMode: RoleypolyMiddleware = (request: Request, context: Context) => {
|
||||
const auth = extractAuthentication(request);
|
||||
|
||||
if (auth.authType === 'Bearer') {
|
||||
context.authMode = {
|
||||
type: 'bearer',
|
||||
sessionId: auth.token,
|
||||
};
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (auth.authType === 'Bot') {
|
||||
context.authMode = {
|
||||
type: 'bot',
|
||||
identity: auth.token,
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
context.authMode = {
|
||||
type: 'anonymous',
|
||||
};
|
||||
};
|
||||
|
||||
export const extractAuthentication = (
|
||||
request: Request
|
||||
): { authType: string; token: string } => {
|
||||
const authHeader = request.headers.get('authorization');
|
||||
if (!authHeader) {
|
||||
return { authType: 'None', token: '' };
|
||||
}
|
||||
|
||||
const [authType, token] = authHeader.split(' ');
|
||||
return { authType, token };
|
||||
};
|
25
packages/api/src/sessions/state.spec.ts
Normal file
25
packages/api/src/sessions/state.spec.ts
Normal file
|
@ -0,0 +1,25 @@
|
|||
import { parseEnvironment } from '../utils/config';
|
||||
import { getBindings } from '../utils/testHelpers';
|
||||
import { getStateSession, setupStateSession } from './state';
|
||||
|
||||
it('creates and fetches a state session', async () => {
|
||||
const config = parseEnvironment(getBindings());
|
||||
|
||||
const stateID = await setupStateSession(config, {
|
||||
test: 'test-data',
|
||||
});
|
||||
|
||||
const stateSession = await getStateSession(config, stateID);
|
||||
|
||||
expect(stateSession).toEqual({
|
||||
test: 'test-data',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns undefined when state is invalid', async () => {
|
||||
const config = parseEnvironment(getBindings());
|
||||
|
||||
const stateSession = await getStateSession(config, 'invalid-state-id');
|
||||
|
||||
expect(stateSession).toBeUndefined();
|
||||
});
|
19
packages/api/src/sessions/state.ts
Normal file
19
packages/api/src/sessions/state.ts
Normal file
|
@ -0,0 +1,19 @@
|
|||
import { Config } from '@roleypoly/api/src/utils/config';
|
||||
import { getID } from '@roleypoly/api/src/utils/id';
|
||||
|
||||
export const setupStateSession = async <T>(config: Config, data: T): Promise<string> => {
|
||||
const stateID = getID();
|
||||
|
||||
await config.kv.sessions.put(`state_${stateID}`, { data }, config.retention.session);
|
||||
|
||||
return stateID;
|
||||
};
|
||||
|
||||
export const getStateSession = async <T>(
|
||||
config: Config,
|
||||
stateID: string
|
||||
): Promise<T | undefined> => {
|
||||
const stateSession = await config.kv.sessions.get<{ data: T }>(`state_${stateID}`);
|
||||
|
||||
return stateSession?.data;
|
||||
};
|
78
packages/api/src/utils/config.ts
Normal file
78
packages/api/src/utils/config.ts
Normal file
|
@ -0,0 +1,78 @@
|
|||
import { WrappedKVNamespace } from './kv';
|
||||
|
||||
export type Environment = {
|
||||
BOT_CLIENT_ID: string;
|
||||
BOT_CLIENT_SECRET: string;
|
||||
BOT_TOKEN: string;
|
||||
UI_PUBLIC_URI: string;
|
||||
API_PUBLIC_URI: string;
|
||||
ROOT_USERS: string;
|
||||
ALLOWED_CALLBACK_HOSTS: string;
|
||||
BOT_IMPORT_TOKEN: string;
|
||||
INTERACTIONS_SHARED_KEY: string;
|
||||
RP_SERVER_ID: string;
|
||||
RP_HELPER_ROLE_IDS: string;
|
||||
DISCORD_PUBLIC_KEY: string;
|
||||
KV_SESSIONS: KVNamespace;
|
||||
KV_GUILDS: KVNamespace;
|
||||
KV_GUILD_DATA: KVNamespace;
|
||||
};
|
||||
|
||||
export type Config = {
|
||||
botClientID: string;
|
||||
botClientSecret: string;
|
||||
botToken: string;
|
||||
publicKey: string;
|
||||
uiPublicURI: string;
|
||||
apiPublicURI: string;
|
||||
rootUsers: string[];
|
||||
allowedCallbackHosts: string[];
|
||||
importSharedKey: string;
|
||||
interactionsSharedKey: string;
|
||||
roleypolyServerID: string;
|
||||
helperRoleIDs: string[];
|
||||
kv: {
|
||||
sessions: WrappedKVNamespace;
|
||||
guilds: WrappedKVNamespace;
|
||||
guildData: WrappedKVNamespace;
|
||||
};
|
||||
retention: {
|
||||
session: number;
|
||||
sessionState: number;
|
||||
guild: number;
|
||||
member: number;
|
||||
};
|
||||
_raw: Environment;
|
||||
};
|
||||
|
||||
const toList = (x: string): string[] => String(x).split(',');
|
||||
const safeURI = (x: string) => String(x).replace(/\/$/, '');
|
||||
|
||||
export const parseEnvironment = (env: Environment): Config => {
|
||||
return {
|
||||
_raw: env,
|
||||
botClientID: env.BOT_CLIENT_ID,
|
||||
botClientSecret: env.BOT_CLIENT_SECRET,
|
||||
botToken: env.BOT_TOKEN,
|
||||
publicKey: env.DISCORD_PUBLIC_KEY,
|
||||
uiPublicURI: safeURI(env.UI_PUBLIC_URI),
|
||||
apiPublicURI: safeURI(env.API_PUBLIC_URI),
|
||||
rootUsers: toList(env.ROOT_USERS),
|
||||
allowedCallbackHosts: toList(env.ALLOWED_CALLBACK_HOSTS),
|
||||
importSharedKey: env.BOT_IMPORT_TOKEN,
|
||||
interactionsSharedKey: env.INTERACTIONS_SHARED_KEY,
|
||||
roleypolyServerID: env.RP_SERVER_ID,
|
||||
helperRoleIDs: toList(env.RP_HELPER_ROLE_IDS),
|
||||
kv: {
|
||||
sessions: new WrappedKVNamespace(env.KV_SESSIONS),
|
||||
guilds: new WrappedKVNamespace(env.KV_GUILDS),
|
||||
guildData: new WrappedKVNamespace(env.KV_GUILD_DATA),
|
||||
},
|
||||
retention: {
|
||||
session: 60 * 60 * 6, // 6 hours
|
||||
sessionState: 60 * 5, // 5 minutes
|
||||
guild: 60 * 15, // 15 minutes
|
||||
member: 60 * 5, // 5 minutes
|
||||
},
|
||||
};
|
||||
};
|
40
packages/api/src/utils/context.ts
Normal file
40
packages/api/src/utils/context.ts
Normal file
|
@ -0,0 +1,40 @@
|
|||
import { Config } from '@roleypoly/api/src/utils/config';
|
||||
import { SessionData } from '@roleypoly/types';
|
||||
|
||||
export type AuthMode =
|
||||
| {
|
||||
type: 'anonymous';
|
||||
}
|
||||
| {
|
||||
type: 'bearer';
|
||||
sessionId: string;
|
||||
}
|
||||
| {
|
||||
type: 'bot';
|
||||
identity: string;
|
||||
};
|
||||
|
||||
export type Context = {
|
||||
config: Config;
|
||||
fetchContext: {
|
||||
waitUntil: FetchEvent['waitUntil'];
|
||||
};
|
||||
authMode: AuthMode;
|
||||
params: {
|
||||
guildId?: string;
|
||||
memberId?: string;
|
||||
};
|
||||
|
||||
// Must include withSession middleware for population
|
||||
session?: SessionData;
|
||||
};
|
||||
|
||||
export type RoleypolyHandler = (
|
||||
request: Request,
|
||||
context: Context
|
||||
) => Promise<Response> | Response;
|
||||
|
||||
export type RoleypolyMiddleware = (
|
||||
request: Request,
|
||||
context: Context
|
||||
) => Promise<Response | void> | Response | void;
|
34
packages/api/src/utils/discord.spec.ts
Normal file
34
packages/api/src/utils/discord.spec.ts
Normal file
|
@ -0,0 +1,34 @@
|
|||
import { getHighestRole } from './discord';
|
||||
|
||||
describe('getHighestRole', () => {
|
||||
it('returns the highest role', () => {
|
||||
const roles = [
|
||||
{
|
||||
id: 'role-1',
|
||||
name: 'Role 1',
|
||||
color: 0,
|
||||
position: 17,
|
||||
permissions: '',
|
||||
managed: false,
|
||||
},
|
||||
{
|
||||
id: 'role-2',
|
||||
name: 'Role 2',
|
||||
color: 0,
|
||||
position: 2,
|
||||
permissions: '',
|
||||
managed: false,
|
||||
},
|
||||
{
|
||||
id: 'role-3',
|
||||
name: 'Role 3',
|
||||
color: 0,
|
||||
position: 19,
|
||||
permissions: '',
|
||||
managed: false,
|
||||
},
|
||||
];
|
||||
|
||||
expect(getHighestRole(roles)).toEqual(roles[2]);
|
||||
});
|
||||
});
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue