Skip to content

Commit 2e6bc45

Browse files
authored
Merge pull request #1337 from leggedrobotics/staging
Dev Version 0.41.2
2 parents fa71b4e + 57745bc commit 2e6bc45

File tree

17 files changed

+266
-25
lines changed

17 files changed

+266
-25
lines changed

backend/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "kleinkram-backend",
3-
"version": "0.41.1",
3+
"version": "0.41.2",
44
"description": "",
55
"author": "",
66
"private": true,

backend/src/app.module.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import { PrometheusModule } from '@willsoto/nestjs-prometheus';
2323
import { AuditLoggerMiddleware } from './routing/middlewares/audit-logger-middleware.service';
2424
import { appVersion } from './app-version';
2525
import { ActionModule } from './endpoints/action/action.module';
26+
import { VersionCheckerMiddlewareService } from './routing/middlewares/version-checker-middleware.service';
2627

2728
export interface AccessGroupConfig {
2829
emails: [{ email: string; access_groups: string[] }];
@@ -92,8 +93,13 @@ export class AppModule implements NestModule {
9293
* @param consumer
9394
*/
9495
configure(consumer: MiddlewareConsumer): void {
95-
consumer // enable default middleware for all routes
96-
.apply(APIKeyResolverMiddleware, AuditLoggerMiddleware)
96+
// Apply APIKeyResolverMiddleware and AuditLoggerMiddleware to all routes
97+
consumer
98+
.apply(
99+
APIKeyResolverMiddleware,
100+
AuditLoggerMiddleware,
101+
VersionCheckerMiddlewareService,
102+
)
97103
.forRoutes('*');
98104
}
99105
}
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
import { Injectable, NestMiddleware } from '@nestjs/common';
2+
import { NextFunction, Request, Response } from 'express';
3+
import logger from '../../logger';
4+
5+
/**
6+
*
7+
* A nest middleware that resolves the user from the API key in the request.
8+
*
9+
*/
10+
@Injectable()
11+
export class VersionCheckerMiddlewareService implements NestMiddleware {
12+
async use(
13+
request: Request,
14+
response: Response,
15+
next: NextFunction,
16+
): Promise<void> {
17+
let clientVersion = request.headers['kleinkram-client-version'] as
18+
| string
19+
| undefined;
20+
21+
const requestPath = request.originalUrl;
22+
23+
// ignore auth endpoints
24+
if (requestPath.startsWith('/auth/')) {
25+
next();
26+
return;
27+
}
28+
29+
// strip away everything after .dev
30+
if (clientVersion !== undefined) {
31+
clientVersion = clientVersion.split('.dev')[0];
32+
}
33+
34+
// verify if version is of semver format
35+
const validVersion = /^\d+\.\d+\.\d+$/;
36+
if (clientVersion !== undefined && !validVersion.test(clientVersion)) {
37+
this.rejectRequest(response, clientVersion);
38+
return;
39+
}
40+
41+
logger.debug(
42+
`Check Client Version for call to endpoint: ${requestPath} is: ${clientVersion}`,
43+
);
44+
45+
if (clientVersion === undefined) {
46+
this.rejectRequest(response, 'undefined');
47+
return;
48+
}
49+
50+
// forbidden client versions: allows for the following notations
51+
// - '<0.40.x': versions below 0.40.x are forbidden
52+
// - '0.41.x': version 0.41.x is forbidden
53+
const forbiddenClientVersions = ['<0.40.0', '0.41.0', '0.41.1'];
54+
55+
if (this.isVersionForbidden(clientVersion, forbiddenClientVersions)) {
56+
this.rejectRequest(response, clientVersion);
57+
return;
58+
}
59+
60+
next();
61+
}
62+
63+
private rejectRequest(response: Response, clientVersion: string): void {
64+
// reject request with 426
65+
response.status(426).json({
66+
statusCode: 426,
67+
message: `Client version ${clientVersion} is not a valid version.`,
68+
});
69+
70+
response.send();
71+
}
72+
73+
private isVersionForbidden(
74+
clientVersion: string,
75+
forbiddenVersions: string[],
76+
): boolean {
77+
for (const forbiddenVersion of forbiddenVersions) {
78+
if (forbiddenVersion.startsWith('<')) {
79+
const versionToCompare = forbiddenVersion.slice(1);
80+
if (this.isLessThan(clientVersion, versionToCompare)) {
81+
return true;
82+
}
83+
} else if (forbiddenVersion.endsWith('.x')) {
84+
const baseVersion = forbiddenVersion.slice(
85+
0,
86+
Math.max(0, forbiddenVersion.length - 2),
87+
);
88+
if (clientVersion.startsWith(baseVersion)) {
89+
return true;
90+
}
91+
} else if (clientVersion === forbiddenVersion) {
92+
return true;
93+
}
94+
}
95+
return false;
96+
}
97+
98+
private isLessThan(version1: string, version2: string): boolean {
99+
const v1Parts = version1.split('.').map(Number);
100+
const v2Parts = version2.split('.').map(Number);
101+
102+
const maxLength = Math.max(v1Parts.length, v2Parts.length);
103+
104+
for (let i = 0; i < maxLength; i++) {
105+
const part1 = v1Parts[i] || 0;
106+
const part2 = v2Parts[i] || 0;
107+
108+
if (part1 < part2) {
109+
return true;
110+
} else if (part1 > part2) {
111+
return false;
112+
}
113+
}
114+
115+
return false;
116+
}
117+
}

cli/kleinkram/api/client.py

Lines changed: 31 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import logging
44
from collections import abc
5+
from pathlib import Path
56
from threading import Lock
67
from typing import Any
78
from typing import List
@@ -13,6 +14,9 @@
1314
import httpx
1415
from httpx._types import PrimitiveData
1516

17+
import kleinkram.errors
18+
from kleinkram._version import __version__
19+
from kleinkram.config import CONFIG_PATH
1620
from kleinkram.config import Config
1721
from kleinkram.config import Credentials
1822
from kleinkram.config import get_config
@@ -26,6 +30,8 @@
2630
COOKIE_REFRESH_TOKEN = "refreshtoken"
2731
COOKIE_API_KEY = "clikey"
2832

33+
CLI_VERSION_HEADER = "Kleinkram-Client-Version"
34+
2935

3036
Data = Union[PrimitiveData, Any]
3137
NestedData = Mapping[str, Data]
@@ -65,10 +71,12 @@ class AuthenticatedClient(httpx.Client):
6571
_config: Config
6672
_config_lock: Lock
6773

68-
def __init__(self, *args: Any, **kwargs: Any) -> None:
74+
def __init__(
75+
self, config_path: Path = CONFIG_PATH, *args: Any, **kwargs: Any
76+
) -> None:
6977
super().__init__(*args, **kwargs)
7078

71-
self._config = get_config()
79+
self._config = get_config(path=config_path)
7280
self._config_lock = Lock()
7381

7482
if self._config.credentials is None:
@@ -95,9 +103,7 @@ def _refresh_token(self) -> None:
95103
self.cookies.set(COOKIE_REFRESH_TOKEN, refresh_token)
96104

97105
logger.info("refreshing token...")
98-
response = self.post(
99-
"/auth/refresh-token",
100-
)
106+
response = self.post("/auth/refresh-token")
101107
response.raise_for_status()
102108
new_access_token = response.cookies[COOKIE_AUTH_TOKEN]
103109
creds = Credentials(auth_token=new_access_token, refresh_token=refresh_token)
@@ -110,6 +116,22 @@ def _refresh_token(self) -> None:
110116

111117
self.cookies.set(COOKIE_AUTH_TOKEN, new_access_token)
112118

119+
def _send_request_with_kleinkram_headers(
120+
self, *args: Any, **kwargs: Any
121+
) -> httpx.Response:
122+
# add the cli version to the headers
123+
headers = kwargs.get("headers") or {}
124+
headers.setdefault(CLI_VERSION_HEADER, __version__)
125+
kwargs["headers"] = headers
126+
127+
# send the request
128+
response = super().request(*args, **kwargs)
129+
130+
# check version compatibility
131+
if response.status_code == 426:
132+
raise kleinkram.errors.UpdateCLIVersion
133+
return response
134+
113135
def request(
114136
self,
115137
method: str,
@@ -128,7 +150,7 @@ def request(
128150
logger.info(f"requesting {method} {full_url}")
129151

130152
httpx_params = _convert_query_params_to_httpx_format(params or {})
131-
response = super().request(
153+
response = self._send_request_with_kleinkram_headers(
132154
method, full_url, params=httpx_params, *args, **kwargs
133155
)
134156

@@ -148,10 +170,10 @@ def request(
148170
raise NotAuthenticated
149171

150172
logger.info(f"retrying request {method} {full_url}")
151-
resp = super().request(
173+
response = self._send_request_with_kleinkram_headers(
152174
method, full_url, params=httpx_params, *args, **kwargs
153175
)
154-
logger.info(f"got response {resp}")
155-
return resp
176+
logger.info(f"got response {response}")
177+
return response
156178
else:
157179
return response

cli/kleinkram/core.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,10 @@ def delete_files(*, client: AuthenticatedClient, file_ids: Collection[UUID]) ->
210210
"""\
211211
deletes multiple files accross multiple missions
212212
"""
213+
if not file_ids:
214+
return
215+
216+
# we need to check that file_ids is not empty, otherwise this is bad
213217
files = list(kleinkram.api.routes.get_files(client, FileQuery(ids=list(file_ids))))
214218

215219
# check if all file_ids were actually found
@@ -220,6 +224,9 @@ def delete_files(*, client: AuthenticatedClient, file_ids: Collection[UUID]) ->
220224
f"file {file_id} not found, did not delete any files"
221225
)
222226

227+
# to prevent catastrophic mistakes from happening *again*
228+
assert set(file_ids) == set([file.id for file in files]), "unreachable"
229+
223230
# we can only batch delete files within the same mission
224231
missions_to_files: Dict[UUID, List[UUID]] = {}
225232
for file in files:

cli/kleinkram/errors.py

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from __future__ import annotations
22

33
LOGIN_MESSAGE = "Please login using `klein login`."
4+
UPDATE_MESSAGE = "Please update your CLI using `pip install --upgrade kleinkram`."
45

56

67
class ParsingError(Exception): ...
@@ -33,11 +34,6 @@ class FileNotFound(Exception): ...
3334
class AccessDenied(Exception): ...
3435

3536

36-
class NotAuthenticated(Exception):
37-
def __init__(self) -> None:
38-
super().__init__(LOGIN_MESSAGE)
39-
40-
4137
class InvalidCLIVersion(Exception): ...
4238

4339

@@ -48,3 +44,13 @@ class FileNameNotSupported(Exception): ...
4844

4945

5046
class InvalidMissionMetadata(Exception): ...
47+
48+
49+
class NotAuthenticated(Exception):
50+
def __init__(self) -> None:
51+
super().__init__(LOGIN_MESSAGE)
52+
53+
54+
class UpdateCLIVersion(Exception):
55+
def __init__(self) -> None:
56+
super().__init__(UPDATE_MESSAGE)

cli/setup.cfg

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[metadata]
22
name = kleinkram
3-
version = 0.41.1
3+
version = 0.41.2
44
description = give me your bags
55
long_description = file: README.md
66
long_description_content_type = text/markdown

cli/tests/api/test_client.py

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,73 @@
11
from __future__ import annotations
22

3+
from pathlib import Path
4+
from tempfile import TemporaryDirectory
5+
6+
import httpx
37
import pytest
48

9+
import kleinkram.errors
10+
from kleinkram._version import __version__
11+
from kleinkram.api.client import CLI_VERSION_HEADER
12+
from kleinkram.api.client import AuthenticatedClient
513
from kleinkram.api.client import _convert_list_data_query_params_values
614
from kleinkram.api.client import _convert_nested_data_query_params_values
715
from kleinkram.api.client import _convert_query_params_to_httpx_format
16+
from kleinkram.config import Config
17+
from kleinkram.config import Credentials
18+
from kleinkram.config import save_config
19+
20+
CONFIG_FILENAME = ".kleinkram.json"
21+
22+
23+
@pytest.fixture
24+
def config_path():
25+
with TemporaryDirectory() as tmpdir:
26+
yield Path(tmpdir) / CONFIG_FILENAME
27+
28+
29+
@pytest.fixture
30+
def empty_config(config_path):
31+
test_creds = Credentials(api_key="test")
32+
config = Config(
33+
endpoint_credentials={"local": test_creds}, selected_endpoint="local"
34+
)
35+
save_config(config, config_path)
36+
return config_path
37+
38+
39+
def mock_transport(request: httpx.Request) -> httpx.Response:
40+
assert CLI_VERSION_HEADER in request.headers
41+
assert request.headers[CLI_VERSION_HEADER] == __version__
42+
return httpx.Response(200)
43+
44+
45+
def test_client_sending_kleinkram_version_header(empty_config):
46+
with AuthenticatedClient(
47+
config_path=empty_config, transport=httpx.MockTransport(mock_transport)
48+
) as client:
49+
resp = client.get("/example")
50+
assert resp.status_code == 200
51+
52+
53+
def test_client_sending_kleinkram_version_header_with_custom_headers(empty_config):
54+
with AuthenticatedClient(
55+
config_path=empty_config, transport=httpx.MockTransport(mock_transport)
56+
) as client:
57+
resp = client.get("/example", headers={"foo": "bar"})
58+
assert resp.status_code == 200
59+
60+
61+
def test_client_response_on_426_status_code(empty_config):
62+
def return_426_response(request: httpx.Request) -> httpx.Response:
63+
_ = request
64+
return httpx.Response(426)
65+
66+
with AuthenticatedClient(
67+
config_path=empty_config, transport=httpx.MockTransport(return_426_response)
68+
) as client:
69+
with pytest.raises(kleinkram.errors.UpdateCLIVersion):
70+
client.get("/example")
871

972

1073
def test_convert_query_params_httpx_format():

0 commit comments

Comments
 (0)