Merge "scouting: Re-authorize users when their auth expires"
diff --git a/.bazelignore b/.bazelignore
index 9166728..9da1960 100644
--- a/.bazelignore
+++ b/.bazelignore
@@ -8,6 +8,7 @@
scouting/www/notes/node_modules
scouting/www/pipes/node_modules
scouting/www/rpc/node_modules
+scouting/www/test/authorize/node_modules
scouting/www/shift_schedule/node_modules
scouting/www/view/node_modules
scouting/www/pit_scouting/node_modules
diff --git a/WORKSPACE b/WORKSPACE
index f68d1b0..253cba7 100644
--- a/WORKSPACE
+++ b/WORKSPACE
@@ -922,6 +922,7 @@
"@//scouting/www/rpc:package.json",
"@//scouting/www/scan:package.json",
"@//scouting/www/shift_schedule:package.json",
+ "@//scouting/www/test/authorize:package.json",
"@//scouting/www/view:package.json",
],
@@ -1037,6 +1038,20 @@
)
http_archive(
+ name = "chromedriver_linux",
+ build_file_content = """
+filegroup(
+ name = "chromedriver",
+ srcs = ["chromedriver-linux64/chromedriver"],
+ visibility = ["//visibility:public"],
+)""",
+ sha256 = "527b81f8aaf94344af4103c1166ce5e65037e7ad071c773fe354c215d547ef73",
+ urls = [
+ "https://storage.googleapis.com/chrome-for-testing-public/124.0.6367.8/linux64/chromedriver-linux64.zip",
+ ],
+)
+
+http_archive(
name = "rules_rust_tinyjson",
build_file = "@rules_rust//util/process_wrapper:BUILD.tinyjson.bazel",
sha256 = "1a8304da9f9370f6a6f9020b7903b044aa9ce3470f300a1fba5bc77c78145a16",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index f718ebc..5411319 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -224,6 +224,9 @@
'@org_frc971/scouting/webserver/requests/messages':
specifier: workspace:*
version: link:../../webserver/requests/messages
+ '@org_frc971/scouting/www/rpc':
+ specifier: workspace:*
+ version: link:../rpc
'@types/pako':
specifier: 2.0.3
version: 2.0.3
@@ -240,6 +243,15 @@
specifier: workspace:*
version: link:../../webserver/requests/messages
+ scouting/www/test/authorize:
+ dependencies:
+ '@org_frc971/scouting/www/rpc':
+ specifier: workspace:*
+ version: link:../../rpc
+ selenium-webdriver:
+ specifier: ^4.5.0
+ version: 4.18.1
+
scouting/www/view:
dependencies:
'@angular/forms':
@@ -1271,6 +1283,7 @@
/anymatch@3.1.3:
resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==}
engines: {node: '>= 8'}
+ requiresBuild: true
dependencies:
normalize-path: 3.0.0
picomatch: 2.3.1
@@ -1348,6 +1361,7 @@
/binary-extensions@2.2.0:
resolution: {integrity: sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==}
engines: {node: '>=8'}
+ requiresBuild: true
/bl@4.1.0:
resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==}
@@ -1717,7 +1731,6 @@
/core-util-is@1.0.2:
resolution: {integrity: sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==}
- dev: true
/cors@2.8.5:
resolution: {integrity: sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==}
@@ -2085,6 +2098,7 @@
/fill-range@7.0.1:
resolution: {integrity: sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==}
engines: {node: '>=8'}
+ requiresBuild: true
dependencies:
to-regex-range: 5.0.1
@@ -2247,6 +2261,7 @@
/glob-parent@5.1.2:
resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==}
engines: {node: '>= 6'}
+ requiresBuild: true
dependencies:
is-glob: 4.0.3
@@ -2438,6 +2453,10 @@
minimatch: 9.0.3
dev: true
+ /immediate@3.0.6:
+ resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==}
+ dev: false
+
/imurmurhash@0.1.4:
resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==}
engines: {node: '>=0.8.19'}
@@ -2499,6 +2518,7 @@
/is-binary-path@2.1.0:
resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==}
engines: {node: '>=8'}
+ requiresBuild: true
dependencies:
binary-extensions: 2.2.0
@@ -2537,6 +2557,7 @@
/is-extglob@2.1.1:
resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==}
engines: {node: '>=0.10.0'}
+ requiresBuild: true
/is-fullwidth-code-point@3.0.0:
resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==}
@@ -2545,6 +2566,7 @@
/is-glob@4.0.3:
resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==}
engines: {node: '>=0.10.0'}
+ requiresBuild: true
dependencies:
is-extglob: 2.1.1
@@ -2572,6 +2594,7 @@
/is-number@7.0.0:
resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==}
engines: {node: '>=0.12.0'}
+ requiresBuild: true
/is-path-inside@3.0.3:
resolution: {integrity: sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==}
@@ -2599,6 +2622,10 @@
is-docker: 2.2.1
dev: true
+ /isarray@1.0.0:
+ resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==}
+ dev: false
+
/isbinaryfile@4.0.10:
resolution: {integrity: sha512-iHrqe5shvBUcFbmZq9zOQHBoeOhZJu6RQGrDpBgenUm/Am+F3JM2MgQj+rK3Z601fzrL5gLZWtAPH2OBaSVcyw==}
engines: {node: '>= 8.0.0'}
@@ -2689,6 +2716,15 @@
verror: 1.10.0
dev: true
+ /jszip@3.10.1:
+ resolution: {integrity: sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==}
+ dependencies:
+ lie: 3.3.0
+ pako: 1.0.11
+ readable-stream: 2.3.8
+ setimmediate: 1.0.5
+ dev: false
+
/karma-safari-launcher@1.0.0(karma@6.4.3):
resolution: {integrity: sha512-qmypLWd6F2qrDJfAETvXDfxHvKDk+nyIjpH9xIeI3/hENr0U3nuqkxaftq73PfXZ4aOuOChA6SnLW4m4AxfRjQ==}
peerDependencies:
@@ -2736,6 +2772,12 @@
engines: {node: '> 0.8'}
dev: true
+ /lie@3.3.0:
+ resolution: {integrity: sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==}
+ dependencies:
+ immediate: 3.0.6
+ dev: false
+
/listr2@3.14.0(enquirer@2.3.6):
resolution: {integrity: sha512-TyWI8G99GX9GjE54cJ+RrNMcIFBfwMPxc3XTFiAYGN4s10hWROGtOg7+O6u6LE3mNkyld7RSLE6nrKBvTfcs3g==}
engines: {node: '>=10.0.0'}
@@ -3093,6 +3135,7 @@
/normalize-path@3.0.0:
resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==}
engines: {node: '>=0.10.0'}
+ requiresBuild: true
/npm-bundled@3.0.0:
resolution: {integrity: sha512-Vq0eyEQy+elFpzsKjMss9kxqb9tG3YHg4dsyWuUENuzvSUWe1TCnW/vV9FkhvBk/brEDoDiVd+M1Btosa6ImdQ==}
@@ -3287,6 +3330,10 @@
- supports-color
dev: true
+ /pako@1.0.11:
+ resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==}
+ dev: false
+
/pako@2.1.0:
resolution: {integrity: sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==}
@@ -3369,6 +3416,10 @@
engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0}
dev: true
+ /process-nextick-args@2.0.1:
+ resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==}
+ dev: false
+
/process@0.11.10:
resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==}
engines: {node: '>= 0.6.0'}
@@ -3473,6 +3524,18 @@
npm-normalize-package-bin: 3.0.1
dev: true
+ /readable-stream@2.3.8:
+ resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==}
+ dependencies:
+ core-util-is: 1.0.2
+ inherits: 2.0.4
+ isarray: 1.0.0
+ process-nextick-args: 2.0.1
+ safe-buffer: 5.1.2
+ string_decoder: 1.1.1
+ util-deprecate: 1.0.2
+ dev: false
+
/readable-stream@3.6.2:
resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==}
engines: {node: '>= 6'}
@@ -3485,6 +3548,7 @@
/readdirp@3.6.0:
resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==}
engines: {node: '>=8.10.0'}
+ requiresBuild: true
dependencies:
picomatch: 2.3.1
@@ -3589,6 +3653,10 @@
tslib: 2.6.0
dev: true
+ /safe-buffer@5.1.2:
+ resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==}
+ dev: false
+
/safe-buffer@5.2.1:
resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==}
dev: true
@@ -3596,6 +3664,18 @@
/safer-buffer@2.1.2:
resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==}
+ /selenium-webdriver@4.18.1:
+ resolution: {integrity: sha512-uP4OJ5wR4+VjdTi5oi/k8oieV2fIhVdVuaOPrklKghgS59w7Zz3nGa5gcG73VcU9EBRv5IZEBRhPr7qFJAj5mQ==}
+ engines: {node: '>= 14.20.0'}
+ dependencies:
+ jszip: 3.10.1
+ tmp: 0.2.1
+ ws: 8.16.0
+ transitivePeerDependencies:
+ - bufferutil
+ - utf-8-validate
+ dev: false
+
/semver@5.7.1:
resolution: {integrity: sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==}
hasBin: true
@@ -3625,6 +3705,10 @@
/set-blocking@2.0.0:
resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==}
+ /setimmediate@1.0.5:
+ resolution: {integrity: sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==}
+ dev: false
+
/setprototypeof@1.2.0:
resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==}
@@ -3855,6 +3939,12 @@
strip-ansi: 7.1.0
dev: true
+ /string_decoder@1.1.1:
+ resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==}
+ dependencies:
+ safe-buffer: 5.1.2
+ dev: false
+
/string_decoder@1.3.0:
resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==}
dependencies:
@@ -3961,6 +4051,7 @@
/to-regex-range@5.0.1:
resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==}
engines: {node: '>=8.0'}
+ requiresBuild: true
dependencies:
is-number: 7.0.0
@@ -4103,7 +4194,6 @@
/util-deprecate@1.0.2:
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
- dev: true
/utils-merge@1.0.1:
resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==}
@@ -4216,6 +4306,19 @@
utf-8-validate:
optional: true
+ /ws@8.16.0:
+ resolution: {integrity: sha512-HS0c//TP7Ina87TfiPUz1rQzMhHrl/SG2guqRcTOIUYD2q8uhUdNHZYJUaQ8aTGPzCh+c6oawMKW35nFl1dxyQ==}
+ engines: {node: '>=10.0.0'}
+ peerDependencies:
+ bufferutil: ^4.0.1
+ utf-8-validate: '>=5.0.2'
+ peerDependenciesMeta:
+ bufferutil:
+ optional: true
+ utf-8-validate:
+ optional: true
+ dev: false
+
/y18n@4.0.3:
resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==}
diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml
index cf4c338..6eac091 100644
--- a/pnpm-workspace.yaml
+++ b/pnpm-workspace.yaml
@@ -2,3 +2,4 @@
- 'scouting/webserver/requests/messages'
- 'scouting/www'
- 'scouting/www/*'
+ - 'scouting/www/test/*'
diff --git a/scouting/www/BUILD b/scouting/www/BUILD
index 685fe14..c73f93a 100644
--- a/scouting/www/BUILD
+++ b/scouting/www/BUILD
@@ -7,6 +7,10 @@
npm_link_all_packages(name = "node_modules")
+exports_files([
+ "_static_files/authorize/index.html",
+])
+
assemble_service_worker_files(
name = "service_worker_files",
outs = [
@@ -43,12 +47,14 @@
assemble_static_files(
name = "static_files",
srcs = [
+ "_static_files/authorize/index.html",
":ngsw.json",
":ngsw-worker.js",
"//third_party/y2024/field:pictures",
],
app_files = ":app",
replace_prefixes = {
+ "_static_files": "",
"prod": "",
"dev": "",
"third_party/y2024": "pictures",
diff --git a/scouting/www/_static_files/authorize/index.html b/scouting/www/_static_files/authorize/index.html
new file mode 100644
index 0000000..a54c182
--- /dev/null
+++ b/scouting/www/_static_files/authorize/index.html
@@ -0,0 +1,12 @@
+<!DOCTYPE html>
+<!-- This page exists purely for re-authorizing. When the page loads
+ successfully, it will close itself. I.e. the app will open a new tab to
+ refresh the authorization. -->
+<html lang="en">
+ <head>
+ <script>
+ window.close();
+ </script>
+ </head>
+ <body></body>
+</html>
diff --git a/scouting/www/entry/entry.component.ts b/scouting/www/entry/entry.component.ts
index cd59884..41f492c 100644
--- a/scouting/www/entry/entry.component.ts
+++ b/scouting/www/entry/entry.component.ts
@@ -33,7 +33,10 @@
ActionT,
} from '@org_frc971/scouting/webserver/requests/messages/submit_2024_actions_generated';
import {Match} from '@org_frc971/scouting/webserver/requests/messages/request_all_matches_response_generated';
-import {MatchListRequestor} from '@org_frc971/scouting/www/rpc';
+import {
+ MatchListRequestor,
+ ActionsSubmitter,
+} from '@org_frc971/scouting/www/rpc';
import {ActionHelper, ConcreteAction} from './action_helper';
import * as pako from 'pako';
@@ -139,7 +142,10 @@
qrCodeValuePieces: string[] = [];
qrCodeValueIndex: number = 0;
- constructor(private readonly matchListRequestor: MatchListRequestor) {}
+ constructor(
+ private readonly matchListRequestor: MatchListRequestor,
+ private readonly actionsSubmitter: ActionsSubmitter
+ ) {}
ngOnInit() {
this.actionHelper = new ActionHelper(
@@ -366,10 +372,7 @@
}
async submit2024Actions() {
- const res = await fetch('/requests/submit/submit_2024_actions', {
- method: 'POST',
- body: this.createActionsBuffer(),
- });
+ const res = await this.actionsSubmitter.submit(this.createActionsBuffer());
if (res.ok) {
// We successfully submitted the data. Report success.
diff --git a/scouting/www/rpc/actions_submitter.ts b/scouting/www/rpc/actions_submitter.ts
new file mode 100644
index 0000000..c6eeca4
--- /dev/null
+++ b/scouting/www/rpc/actions_submitter.ts
@@ -0,0 +1,14 @@
+import {Injectable} from '@angular/core';
+import {RequestAuthorizer} from './auth_handler';
+
+@Injectable({providedIn: 'root'})
+export class ActionsSubmitter {
+ constructor(private readonly requestAuthorizer: RequestAuthorizer) {}
+
+ async submit(actionBuffer: Uint8Array): Promise<Response> {
+ return this.requestAuthorizer.submit(
+ '/requests/submit/submit_2024_actions',
+ actionBuffer
+ );
+ }
+}
diff --git a/scouting/www/rpc/auth_handler.ts b/scouting/www/rpc/auth_handler.ts
new file mode 100644
index 0000000..02a790d
--- /dev/null
+++ b/scouting/www/rpc/auth_handler.ts
@@ -0,0 +1,54 @@
+import {Injectable} from '@angular/core';
+
+const AUTHORIZATION_URL = '/authorize';
+const AUTHORIZATION_CHECK_INTERVAL = 500; // ms
+const AUTHORIZATION_CHECK_DURATION = 20000; // ms
+const AUTHORIZATION_CHECK_ATTEMPTS =
+ AUTHORIZATION_CHECK_DURATION / AUTHORIZATION_CHECK_INTERVAL;
+
+@Injectable({providedIn: 'root'})
+export class RequestAuthorizer {
+ // Submits the buffer to the specified end point.
+ async submit(path: string, actionBuffer: Uint8Array): Promise<Response> {
+ const result = await this.singleSubmit(path, actionBuffer);
+ if (result.status === 401) {
+ await this.authorize();
+ return await this.singleSubmit(path, actionBuffer);
+ } else {
+ return result;
+ }
+ }
+
+ // Actually performs the underlying submission.
+ private async singleSubmit(
+ path: string,
+ actionBuffer: Uint8Array
+ ): Promise<Response> {
+ return await fetch(path, {
+ method: 'POST',
+ body: actionBuffer,
+ });
+ }
+
+ // Open a new tab in an attempt to re-authorize the user with the server.
+ private async authorize() {
+ const authorizationWindow = window.open(AUTHORIZATION_URL);
+ // We should deal with errors opening the tab, but this is good enough for now.
+
+ const tabIsClosed = new Promise<void>((resolve, reject) => {
+ let checkCounter = 0;
+ const tabCheckTimer = setInterval(() => {
+ if (authorizationWindow.closed) {
+ clearInterval(tabCheckTimer);
+ resolve();
+ }
+ checkCounter++;
+ if (checkCounter >= AUTHORIZATION_CHECK_ATTEMPTS) {
+ clearInterval(tabCheckTimer);
+ reject();
+ }
+ }, 500);
+ });
+ await tabIsClosed;
+ }
+}
diff --git a/scouting/www/rpc/match_list_requestor.ts b/scouting/www/rpc/match_list_requestor.ts
index 97ed0b0..6f73b30 100644
--- a/scouting/www/rpc/match_list_requestor.ts
+++ b/scouting/www/rpc/match_list_requestor.ts
@@ -7,7 +7,9 @@
RequestAllMatchesResponse,
} from '@org_frc971/scouting/webserver/requests/messages/request_all_matches_response_generated';
import {db, MatchListData} from './db';
+
const MATCH_TYPE_ORDERING = ['qm', 'ef', 'qf', 'sf', 'f'];
+
@Injectable({providedIn: 'root'})
export class MatchListRequestor {
async fetchMatchList(): Promise<Match[]> {
diff --git a/scouting/www/rpc/public-api.ts b/scouting/www/rpc/public-api.ts
index fe61453..ab6eb45 100644
--- a/scouting/www/rpc/public-api.ts
+++ b/scouting/www/rpc/public-api.ts
@@ -1,2 +1,4 @@
+export * from './auth_handler';
+export * from './actions_submitter';
export * from './match_list_requestor';
export * from './view_data_requestor';
diff --git a/scouting/www/scan/package.json b/scouting/www/scan/package.json
index 7fa3990..a53259c 100644
--- a/scouting/www/scan/package.json
+++ b/scouting/www/scan/package.json
@@ -5,6 +5,7 @@
"pako": "2.1.0",
"@types/pako": "2.0.3",
"@angular/forms": "v16-lts",
- "@org_frc971/scouting/webserver/requests/messages": "workspace:*"
+ "@org_frc971/scouting/webserver/requests/messages": "workspace:*",
+ "@org_frc971/scouting/www/rpc": "workspace:*"
}
}
diff --git a/scouting/www/scan/scan.component.ts b/scouting/www/scan/scan.component.ts
index 4a1c9c8..431fda3 100644
--- a/scouting/www/scan/scan.component.ts
+++ b/scouting/www/scan/scan.component.ts
@@ -1,5 +1,6 @@
import {Component, NgZone, OnInit, ViewChild, ElementRef} from '@angular/core';
import {ErrorResponse} from '@org_frc971/scouting/webserver/requests/messages/error_response_generated';
+import {ActionsSubmitter} from '@org_frc971/scouting/www/rpc';
import {Builder, ByteBuffer} from 'flatbuffers';
import * as pako from 'pako';
@@ -32,7 +33,10 @@
scanStream: MediaStream | null = null;
scanTimer: ReturnType<typeof setTimeout> | null = null;
- constructor(private ngZone: NgZone) {}
+ constructor(
+ private ngZone: NgZone,
+ private actionsSubmitter: ActionsSubmitter
+ ) {}
ngOnInit() {
// If the user switched away from this tab, then the onRuntimeInitialized
@@ -179,10 +183,7 @@
);
const actionBuffer = pako.inflate(deflatedData);
- const res = await fetch('/requests/submit/submit_2024_actions', {
- method: 'POST',
- body: actionBuffer,
- });
+ const res = await this.actionsSubmitter.submit(actionBuffer);
if (res.ok) {
// We successfully submitted the data. Report success.
diff --git a/scouting/www/test/authorize/BUILD b/scouting/www/test/authorize/BUILD
new file mode 100644
index 0000000..af8bc70
--- /dev/null
+++ b/scouting/www/test/authorize/BUILD
@@ -0,0 +1,62 @@
+load("@aspect_rules_js//js:defs.bzl", "js_test")
+load("@io_bazel_rules_go//go:def.bzl", "go_binary", "go_library")
+load("@npm//:defs.bzl", "npm_link_all_packages")
+load("//scouting/www:defs.bzl", "assemble_static_files")
+load("//tools/build_rules:js.bzl", "ng_application")
+
+npm_link_all_packages(name = "node_modules")
+
+go_library(
+ name = "authorize_lib",
+ srcs = ["server.go"],
+ importpath = "github.com/frc971/971-Robot-Code/scouting/www/test/authorize",
+ target_compatible_with = ["@platforms//cpu:x86_64"],
+ visibility = ["//visibility:private"],
+)
+
+go_binary(
+ name = "authorize",
+ data = [
+ ":static_files",
+ ],
+ embed = [":authorize_lib"],
+ target_compatible_with = ["@platforms//cpu:x86_64"],
+ visibility = ["//visibility:public"],
+)
+
+ng_application(
+ name = "app",
+ html_assets = [],
+ deps = [
+ ":node_modules",
+ ],
+)
+
+assemble_static_files(
+ name = "static_files",
+ srcs = [
+ "//scouting/www:_static_files/authorize/index.html",
+ ],
+ app_files = ":app",
+ replace_prefixes = {
+ "prod": "",
+ "dev": "",
+ "scouting/www/_static_files": "",
+ },
+)
+
+js_test(
+ name = "authorize_test",
+ chdir = package_name(),
+ copy_data_to_bin = False,
+ data = [
+ ":authorize",
+ ":node_modules/selenium-webdriver",
+ "@chrome_linux//:all",
+ "@chromedriver_linux//:chromedriver",
+ ],
+ entry_point = "authorize_test.js",
+ tags = [
+ "no-remote-cache",
+ ],
+)
diff --git a/scouting/www/test/authorize/app/app.module.ts b/scouting/www/test/authorize/app/app.module.ts
new file mode 100644
index 0000000..893ab58
--- /dev/null
+++ b/scouting/www/test/authorize/app/app.module.ts
@@ -0,0 +1,12 @@
+import {BrowserModule} from '@angular/platform-browser';
+import {NgModule} from '@angular/core';
+
+import {App} from './app';
+
+@NgModule({
+ declarations: [App],
+ imports: [BrowserModule],
+ exports: [App],
+ bootstrap: [App],
+})
+export class AppModule {}
diff --git a/scouting/www/test/authorize/app/app.ng.html b/scouting/www/test/authorize/app/app.ng.html
new file mode 100644
index 0000000..d32e181
--- /dev/null
+++ b/scouting/www/test/authorize/app/app.ng.html
@@ -0,0 +1,8 @@
+<div id="message">{{ message }}</div>
+<button (click)="performSubmissionWithoutReAuth()" id="without_re_auth">
+ Submit without re-auth
+</button>
+<br />
+<button (click)="performSubmissionWithReAuth()" id="with_re_auth">
+ Submit with re-auth
+</button>
diff --git a/scouting/www/test/authorize/app/app.ts b/scouting/www/test/authorize/app/app.ts
new file mode 100644
index 0000000..569eb42
--- /dev/null
+++ b/scouting/www/test/authorize/app/app.ts
@@ -0,0 +1,41 @@
+import {Component} from '@angular/core';
+
+import {RequestAuthorizer} from '@org_frc971/scouting/www/rpc';
+
+@Component({
+ selector: 'test-app',
+ templateUrl: './app.ng.html',
+})
+export class App {
+ message: string = 'Waiting for button click';
+
+ constructor(private readonly requestAuthorizer: RequestAuthorizer) {}
+
+ // A dummy request submission that is expected to fail initially.
+ async performSubmissionWithoutReAuth() {
+ this.performSubmission(() => {
+ return fetch('/submit', {method: 'POST', body: new Uint8Array()});
+ });
+ }
+
+ // A dummy request that is expected to authorize itself if it hits a 401 error.
+ async performSubmissionWithReAuth() {
+ this.performSubmission(() => {
+ return this.requestAuthorizer.submit('/submit', new Uint8Array());
+ });
+ }
+
+ async performSubmission(callback: () => Promise<Response>) {
+ this.message = 'Starting submission';
+ try {
+ const result = await callback();
+ if (result.status != 200) {
+ this.message = `Failed to perform submission: ${result.status}!`;
+ } else {
+ this.message = await result.text();
+ }
+ } catch (error) {
+ this.message = error;
+ }
+ }
+}
diff --git a/scouting/www/test/authorize/authorize_test.js b/scouting/www/test/authorize/authorize_test.js
new file mode 100644
index 0000000..83bc755
--- /dev/null
+++ b/scouting/www/test/authorize/authorize_test.js
@@ -0,0 +1,121 @@
+/*
+ * This test here validates that the re-authorization logic works at a very high level. Ideally we
+ * would validate this with an actual Apache server, but that's a little tedious to set up. Instead,
+ * we create a custom Go server that behaves as if the user is unauthorized. This works for our
+ * immediate purposes.
+ *
+ * This test cannot be written in the usual //scouting:scouting_test because the Cypress test
+ * framework does not support dealing with multiple tabs.
+ */
+
+const child_process = require('child_process');
+const process = require('process');
+
+const {Builder, By, Key, until, Condition} = require('selenium-webdriver');
+const chrome = require('selenium-webdriver/chrome');
+
+// These are the sandboxed binaries we want to point webdriver at.
+const CHROME_PATH = '../../../../../chrome_linux/chrome';
+const CHROMEDRIVER_PATH =
+ '../../../../../chromedriver_linux/chromedriver-linux64/chromedriver';
+
+// Checks if the specified element contains the specified text.
+const elementContainsText = (element, text) => {
+ return async (driver) => {
+ const actualText = await element.getText();
+ console.log(`Current message: ${actualText}`);
+ return actualText.includes(text);
+ };
+};
+
+// Asserts that the text includes the specified "include" text.
+const expectTextToInclude = (text, include) => {
+ if (!text.includes(include)) {
+ throw new Error(`Could not find "${include}" in "${text}"`);
+ }
+};
+
+// Run the actual test. We want to validate that the re-authorization helper hits the /authorize
+// endpoint properly.
+async function runTest(driver) {
+ // Validate that the page opened properly.
+ title = await driver.getTitle();
+ expectTextToInclude(title, 'RPC Authorize Test');
+
+ // When the page first loads, it should tell us it's waiting for a button click.
+ const messageElement = await driver.wait(
+ until.elementLocated(By.id('message')),
+ 10000
+ );
+ const message = await messageElement.getText();
+ expectTextToInclude(message, 'Waiting for button click');
+
+ // Click the "without re-auth" button and make sure we get a permission denied error.
+ let withoutReAuthButton = await driver.findElement(By.id('without_re_auth'));
+ withoutReAuthButton.click();
+ await driver.wait(
+ elementContainsText(messageElement, 'Failed to perform submission: 401!'),
+ 10000
+ );
+
+ // Click the "with re-auth" button and validate that we get a successful re-authorization.
+ let withReAuthButton = await driver.findElement(By.id('with_re_auth'));
+ withReAuthButton.click();
+ await driver.wait(
+ elementContainsText(messageElement, 'Successful submission 1!'),
+ 10000
+ );
+
+ // Now click the "without re-auth" button again and make sure it's successful.
+ withoutReAuthButton.click();
+ await driver.wait(
+ elementContainsText(messageElement, 'Successful submission 2!'),
+ 10000
+ );
+}
+
+(async () => {
+ // Start the dummy server in the background.
+ console.log('Starting server.');
+ let server = child_process.spawn(
+ './authorize_/authorize',
+ ['-directory', './static_files'],
+ {stdio: ['inherit', 'inherit', 'inherit']}
+ );
+
+ // Start up Chrome in the background.
+ let chromeOptions = new chrome.Options();
+ chromeOptions.setChromeBinaryPath(CHROME_PATH);
+ chromeOptions.addArguments('--headless');
+ chromeOptions.addArguments('--no-sandbox');
+ chromeOptions.addArguments('--disable-dev-shm-usage');
+ chromeOptions.addArguments('--remote-debugging-pipe');
+
+ let service = new chrome.ServiceBuilder(CHROMEDRIVER_PATH).build();
+ const driver = chrome.Driver.createSession(chromeOptions, service);
+
+ // Load the page and wait for it to finish loading.
+ driver.get('http://localhost:8000/');
+ await driver.wait(function () {
+ return driver
+ .executeScript('return document.readyState')
+ .then(function (readyState) {
+ return readyState === 'complete';
+ });
+ });
+
+ // Run the actual test we care about.
+ let exitCode = 0;
+ try {
+ await runTest(driver);
+ } catch (error) {
+ console.log(`Failed test: ${error}`);
+ exitCode = 1;
+ }
+
+ // Shut everything down.
+ driver.quit();
+ console.log('Killing server.');
+ await server.kill();
+ process.exit(exitCode);
+})();
diff --git a/scouting/www/test/authorize/index.html b/scouting/www/test/authorize/index.html
new file mode 100644
index 0000000..c307b65
--- /dev/null
+++ b/scouting/www/test/authorize/index.html
@@ -0,0 +1,13 @@
+<!DOCTYPE html>
+<html lang="en">
+ <head>
+ <meta charset="utf-8" />
+ <title>RPC Authorize Test</title>
+ </head>
+ <body>
+ <test-app></test-app>
+ <noscript>
+ Please enable JavaScript to continue using this application.
+ </noscript>
+ </body>
+</html>
diff --git a/scouting/www/test/authorize/main.ts b/scouting/www/test/authorize/main.ts
new file mode 100644
index 0000000..1f8eb00
--- /dev/null
+++ b/scouting/www/test/authorize/main.ts
@@ -0,0 +1,4 @@
+import {platformBrowser} from '@angular/platform-browser';
+import {AppModule} from './app/app.module';
+
+platformBrowser().bootstrapModule(AppModule);
diff --git a/scouting/www/test/authorize/package.json b/scouting/www/test/authorize/package.json
new file mode 100644
index 0000000..58999d3
--- /dev/null
+++ b/scouting/www/test/authorize/package.json
@@ -0,0 +1,9 @@
+{
+ "name": "@org_frc971/scouting/www/test/authorize",
+ "private": true,
+ "type": "module",
+ "dependencies": {
+ "@org_frc971/scouting/www/rpc": "workspace:*",
+ "selenium-webdriver": "^4.5.0"
+ }
+}
diff --git a/scouting/www/test/authorize/polyfills.ts b/scouting/www/test/authorize/polyfills.ts
new file mode 100644
index 0000000..aa09a9f
--- /dev/null
+++ b/scouting/www/test/authorize/polyfills.ts
@@ -0,0 +1 @@
+import 'zone.js';
diff --git a/scouting/www/test/authorize/server.go b/scouting/www/test/authorize/server.go
new file mode 100644
index 0000000..3fad800
--- /dev/null
+++ b/scouting/www/test/authorize/server.go
@@ -0,0 +1,50 @@
+package main
+
+import (
+ "flag"
+ "fmt"
+ "log"
+ "net/http"
+)
+
+var authorized = false
+var authorizedCounter = 0
+
+// Special handler for responding to /submit requests.
+func handleSubmission(w http.ResponseWriter, r *http.Request) {
+ if !authorized {
+ log.Println("Replying with 'Unauthorized'.")
+ w.WriteHeader(http.StatusUnauthorized)
+ w.Write([]byte{})
+ return
+ }
+
+ log.Println("Replying with success.")
+ authorizedCounter += 1
+ w.Write([]byte(fmt.Sprintf("Successful submission %d!", authorizedCounter)))
+}
+
+// Default handler for all other requests.
+func createDefaultHandler(directory string) http.HandlerFunc {
+ handler := http.FileServer(http.Dir(directory))
+
+ fn := func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path == "/authorize" {
+ authorized = true
+ }
+ handler.ServeHTTP(w, r)
+ }
+
+ return http.HandlerFunc(fn)
+}
+
+func main() {
+ directoryPtr := flag.String("directory", ".", "The directory to serve")
+ flag.Parse()
+
+ http.HandleFunc("/", createDefaultHandler(*directoryPtr))
+ http.HandleFunc("/submit", handleSubmission)
+
+ fmt.Println("Server listening on port 8000...")
+ http.ListenAndServe(":8000", nil)
+}