howler-api 3.0.0.dev374__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of howler-api might be problematic. Click here for more details.
- howler/__init__.py +0 -0
- howler/actions/__init__.py +168 -0
- howler/actions/add_label.py +111 -0
- howler/actions/add_to_bundle.py +159 -0
- howler/actions/change_field.py +76 -0
- howler/actions/demote.py +160 -0
- howler/actions/example_plugin.py +104 -0
- howler/actions/prioritization.py +93 -0
- howler/actions/promote.py +147 -0
- howler/actions/remove_from_bundle.py +133 -0
- howler/actions/remove_label.py +111 -0
- howler/actions/transition.py +200 -0
- howler/api/__init__.py +249 -0
- howler/api/base.py +88 -0
- howler/api/socket.py +114 -0
- howler/api/v1/__init__.py +97 -0
- howler/api/v1/action.py +372 -0
- howler/api/v1/analytic.py +748 -0
- howler/api/v1/auth.py +382 -0
- howler/api/v1/clue.py +99 -0
- howler/api/v1/configs.py +58 -0
- howler/api/v1/dossier.py +222 -0
- howler/api/v1/help.py +28 -0
- howler/api/v1/hit.py +1181 -0
- howler/api/v1/notebook.py +82 -0
- howler/api/v1/overview.py +191 -0
- howler/api/v1/search.py +788 -0
- howler/api/v1/template.py +206 -0
- howler/api/v1/tool.py +183 -0
- howler/api/v1/user.py +416 -0
- howler/api/v1/utils/__init__.py +0 -0
- howler/api/v1/utils/etag.py +84 -0
- howler/api/v1/view.py +288 -0
- howler/app.py +235 -0
- howler/common/README.md +125 -0
- howler/common/__init__.py +0 -0
- howler/common/classification.py +979 -0
- howler/common/classification.yml +107 -0
- howler/common/exceptions.py +167 -0
- howler/common/loader.py +154 -0
- howler/common/logging/__init__.py +241 -0
- howler/common/logging/audit.py +138 -0
- howler/common/logging/format.py +38 -0
- howler/common/net.py +79 -0
- howler/common/net_static.py +1494 -0
- howler/common/random_user.py +316 -0
- howler/common/swagger.py +117 -0
- howler/config.py +64 -0
- howler/cronjobs/__init__.py +29 -0
- howler/cronjobs/retention.py +61 -0
- howler/cronjobs/rules.py +274 -0
- howler/cronjobs/view_cleanup.py +88 -0
- howler/datastore/README.md +112 -0
- howler/datastore/__init__.py +0 -0
- howler/datastore/bulk.py +72 -0
- howler/datastore/collection.py +2342 -0
- howler/datastore/constants.py +119 -0
- howler/datastore/exceptions.py +41 -0
- howler/datastore/howler_store.py +105 -0
- howler/datastore/migrations/fix_process.py +41 -0
- howler/datastore/operations.py +130 -0
- howler/datastore/schemas.py +90 -0
- howler/datastore/store.py +231 -0
- howler/datastore/support/__init__.py +0 -0
- howler/datastore/support/build.py +215 -0
- howler/datastore/support/schemas.py +90 -0
- howler/datastore/types.py +22 -0
- howler/error.py +91 -0
- howler/external/__init__.py +0 -0
- howler/external/generate_mitre.py +96 -0
- howler/external/generate_sigma_rules.py +31 -0
- howler/external/generate_tlds.py +47 -0
- howler/external/reindex_data.py +66 -0
- howler/external/wipe_databases.py +58 -0
- howler/gunicorn_config.py +25 -0
- howler/healthz.py +47 -0
- howler/helper/__init__.py +0 -0
- howler/helper/azure.py +50 -0
- howler/helper/discover.py +59 -0
- howler/helper/hit.py +236 -0
- howler/helper/oauth.py +247 -0
- howler/helper/search.py +92 -0
- howler/helper/workflow.py +110 -0
- howler/helper/ws.py +378 -0
- howler/odm/README.md +102 -0
- howler/odm/__init__.py +1 -0
- howler/odm/base.py +1543 -0
- howler/odm/charter.txt +146 -0
- howler/odm/helper.py +416 -0
- howler/odm/howler_enum.py +25 -0
- howler/odm/models/__init__.py +0 -0
- howler/odm/models/action.py +33 -0
- howler/odm/models/analytic.py +90 -0
- howler/odm/models/assemblyline.py +48 -0
- howler/odm/models/aws.py +23 -0
- howler/odm/models/azure.py +16 -0
- howler/odm/models/cbs.py +44 -0
- howler/odm/models/config.py +558 -0
- howler/odm/models/dossier.py +33 -0
- howler/odm/models/ecs/__init__.py +0 -0
- howler/odm/models/ecs/agent.py +17 -0
- howler/odm/models/ecs/autonomous_system.py +16 -0
- howler/odm/models/ecs/client.py +149 -0
- howler/odm/models/ecs/cloud.py +141 -0
- howler/odm/models/ecs/code_signature.py +27 -0
- howler/odm/models/ecs/container.py +32 -0
- howler/odm/models/ecs/dns.py +62 -0
- howler/odm/models/ecs/egress.py +10 -0
- howler/odm/models/ecs/elf.py +74 -0
- howler/odm/models/ecs/email.py +122 -0
- howler/odm/models/ecs/error.py +14 -0
- howler/odm/models/ecs/event.py +140 -0
- howler/odm/models/ecs/faas.py +24 -0
- howler/odm/models/ecs/file.py +84 -0
- howler/odm/models/ecs/geo.py +30 -0
- howler/odm/models/ecs/group.py +18 -0
- howler/odm/models/ecs/hash.py +16 -0
- howler/odm/models/ecs/host.py +17 -0
- howler/odm/models/ecs/http.py +37 -0
- howler/odm/models/ecs/ingress.py +12 -0
- howler/odm/models/ecs/interface.py +21 -0
- howler/odm/models/ecs/network.py +30 -0
- howler/odm/models/ecs/observer.py +45 -0
- howler/odm/models/ecs/organization.py +12 -0
- howler/odm/models/ecs/os.py +21 -0
- howler/odm/models/ecs/pe.py +17 -0
- howler/odm/models/ecs/process.py +216 -0
- howler/odm/models/ecs/registry.py +26 -0
- howler/odm/models/ecs/related.py +45 -0
- howler/odm/models/ecs/rule.py +51 -0
- howler/odm/models/ecs/server.py +24 -0
- howler/odm/models/ecs/threat.py +247 -0
- howler/odm/models/ecs/tls.py +58 -0
- howler/odm/models/ecs/url.py +51 -0
- howler/odm/models/ecs/user.py +57 -0
- howler/odm/models/ecs/user_agent.py +20 -0
- howler/odm/models/ecs/vulnerability.py +41 -0
- howler/odm/models/gcp.py +16 -0
- howler/odm/models/hit.py +356 -0
- howler/odm/models/howler_data.py +328 -0
- howler/odm/models/lead.py +24 -0
- howler/odm/models/localized_label.py +13 -0
- howler/odm/models/overview.py +16 -0
- howler/odm/models/pivot.py +40 -0
- howler/odm/models/template.py +24 -0
- howler/odm/models/user.py +83 -0
- howler/odm/models/view.py +34 -0
- howler/odm/random_data.py +888 -0
- howler/odm/randomizer.py +609 -0
- howler/patched.py +5 -0
- howler/plugins/__init__.py +25 -0
- howler/plugins/config.py +123 -0
- howler/remote/__init__.py +0 -0
- howler/remote/datatypes/README.md +355 -0
- howler/remote/datatypes/__init__.py +98 -0
- howler/remote/datatypes/counters.py +63 -0
- howler/remote/datatypes/events.py +66 -0
- howler/remote/datatypes/hash.py +206 -0
- howler/remote/datatypes/lock.py +42 -0
- howler/remote/datatypes/queues/__init__.py +0 -0
- howler/remote/datatypes/queues/comms.py +59 -0
- howler/remote/datatypes/queues/multi.py +32 -0
- howler/remote/datatypes/queues/named.py +93 -0
- howler/remote/datatypes/queues/priority.py +215 -0
- howler/remote/datatypes/set.py +118 -0
- howler/remote/datatypes/user_quota_tracker.py +54 -0
- howler/security/__init__.py +253 -0
- howler/security/socket.py +108 -0
- howler/security/utils.py +185 -0
- howler/services/__init__.py +0 -0
- howler/services/action_service.py +111 -0
- howler/services/analytic_service.py +128 -0
- howler/services/auth_service.py +323 -0
- howler/services/config_service.py +128 -0
- howler/services/dossier_service.py +252 -0
- howler/services/event_service.py +93 -0
- howler/services/hit_service.py +893 -0
- howler/services/jwt_service.py +158 -0
- howler/services/lucene_service.py +286 -0
- howler/services/notebook_service.py +119 -0
- howler/services/overview_service.py +44 -0
- howler/services/template_service.py +45 -0
- howler/services/user_service.py +331 -0
- howler/utils/__init__.py +0 -0
- howler/utils/annotations.py +28 -0
- howler/utils/chunk.py +38 -0
- howler/utils/dict_utils.py +200 -0
- howler/utils/isotime.py +17 -0
- howler/utils/list_utils.py +11 -0
- howler/utils/lucene.py +77 -0
- howler/utils/path.py +27 -0
- howler/utils/socket_utils.py +61 -0
- howler/utils/str_utils.py +256 -0
- howler/utils/uid.py +47 -0
- howler_api-3.0.0.dev374.dist-info/METADATA +71 -0
- howler_api-3.0.0.dev374.dist-info/RECORD +198 -0
- howler_api-3.0.0.dev374.dist-info/WHEEL +4 -0
- howler_api-3.0.0.dev374.dist-info/entry_points.txt +8 -0
|
@@ -0,0 +1,748 @@
|
|
|
1
|
+
import typing
|
|
2
|
+
from typing import Any, Optional
|
|
3
|
+
|
|
4
|
+
from flask import Response, request
|
|
5
|
+
|
|
6
|
+
from howler.api import (
|
|
7
|
+
bad_request,
|
|
8
|
+
forbidden,
|
|
9
|
+
make_subapi_blueprint,
|
|
10
|
+
no_content,
|
|
11
|
+
not_found,
|
|
12
|
+
ok,
|
|
13
|
+
)
|
|
14
|
+
from howler.common.exceptions import HowlerException
|
|
15
|
+
from howler.common.loader import datastore
|
|
16
|
+
from howler.common.logging import get_logger
|
|
17
|
+
from howler.common.swagger import generate_swagger_docs
|
|
18
|
+
from howler.cronjobs.rules import register_rules
|
|
19
|
+
from howler.datastore.exceptions import DataStoreException
|
|
20
|
+
from howler.datastore.operations import OdmHelper
|
|
21
|
+
from howler.odm.models.analytic import Analytic, Comment, Notebook, TriageOptions
|
|
22
|
+
from howler.odm.models.template import Template
|
|
23
|
+
from howler.odm.models.user import User
|
|
24
|
+
from howler.security import api_login
|
|
25
|
+
from howler.services import analytic_service, user_service
|
|
26
|
+
|
|
27
|
+
MAX_COMMENT_LEN = 5000
|
|
28
|
+
SUB_API = "analytic"
|
|
29
|
+
analytic_api = make_subapi_blueprint(SUB_API, api_version=1)
|
|
30
|
+
analytic_api._doc = "Manage the analytics that create hits"
|
|
31
|
+
|
|
32
|
+
logger = get_logger(__file__)
|
|
33
|
+
|
|
34
|
+
analytic_helper = OdmHelper(Analytic)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@generate_swagger_docs()
|
|
38
|
+
@analytic_api.route("/", methods=["GET"])
|
|
39
|
+
@api_login(required_priv=["R"])
|
|
40
|
+
def get_analytics(**kwargs: Any) -> Response:
|
|
41
|
+
"""Get a list of analytics used to create hits in howler
|
|
42
|
+
|
|
43
|
+
Variables:
|
|
44
|
+
None
|
|
45
|
+
|
|
46
|
+
Optional Arguments:
|
|
47
|
+
None
|
|
48
|
+
|
|
49
|
+
Result Example:
|
|
50
|
+
[
|
|
51
|
+
...analytics # A list of analytics
|
|
52
|
+
]
|
|
53
|
+
"""
|
|
54
|
+
return ok(datastore().analytic.search("*:*", as_obj=False, rows=1000)["items"])
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@generate_swagger_docs()
|
|
58
|
+
@analytic_api.route("/<id>", methods=["GET"])
|
|
59
|
+
@api_login(required_priv=["R"])
|
|
60
|
+
def get_analytic(id, **kwargs):
|
|
61
|
+
"""Get a specific analytic
|
|
62
|
+
|
|
63
|
+
Variables:
|
|
64
|
+
id => The id of the analytic to retrieve
|
|
65
|
+
|
|
66
|
+
Optional Arguments:
|
|
67
|
+
None
|
|
68
|
+
|
|
69
|
+
Result Example:
|
|
70
|
+
{
|
|
71
|
+
...analytic # The requested analytic
|
|
72
|
+
}
|
|
73
|
+
"""
|
|
74
|
+
try:
|
|
75
|
+
if not analytic_service.does_analytic_exist(id):
|
|
76
|
+
return not_found(err="Analytic does not exist")
|
|
77
|
+
|
|
78
|
+
return ok(analytic_service.get_analytic(id, as_obj=False))
|
|
79
|
+
except ValueError as e:
|
|
80
|
+
return bad_request(err=str(e))
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@generate_swagger_docs()
|
|
84
|
+
@analytic_api.route("/<id>", methods=["PUT"])
|
|
85
|
+
@api_login(required_priv=["R", "W"])
|
|
86
|
+
def update_analytic(id: str, user: User, **kwargs):
|
|
87
|
+
"""Update an analytic
|
|
88
|
+
|
|
89
|
+
Variables:
|
|
90
|
+
id => The id of the analytic to modify
|
|
91
|
+
|
|
92
|
+
Optional Arguments:
|
|
93
|
+
None
|
|
94
|
+
|
|
95
|
+
Data Block:
|
|
96
|
+
{
|
|
97
|
+
...analytic # The new data to add
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
Result Example:
|
|
101
|
+
{
|
|
102
|
+
...analytic # The updated analytic data
|
|
103
|
+
}
|
|
104
|
+
"""
|
|
105
|
+
storage = datastore()
|
|
106
|
+
|
|
107
|
+
if not storage.analytic.exists(id):
|
|
108
|
+
return not_found(err="This analytic does not exist")
|
|
109
|
+
|
|
110
|
+
new_data = request.json
|
|
111
|
+
|
|
112
|
+
if not new_data:
|
|
113
|
+
return bad_request(err="You must provide updated data.")
|
|
114
|
+
|
|
115
|
+
try:
|
|
116
|
+
existing_analytic: Analytic = storage.analytic.get_if_exists(id)
|
|
117
|
+
|
|
118
|
+
existing_analytic.description = new_data.get("description", existing_analytic.description)
|
|
119
|
+
|
|
120
|
+
if existing_analytic.triage_settings is not None:
|
|
121
|
+
existing_triage_data = existing_analytic.triage_settings.as_primitives()
|
|
122
|
+
else:
|
|
123
|
+
existing_triage_data = {}
|
|
124
|
+
|
|
125
|
+
existing_analytic.triage_settings = TriageOptions(
|
|
126
|
+
{**existing_triage_data, **new_data.get("triage_settings", {})}
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
updated_rule = False
|
|
130
|
+
if existing_analytic.rule_type:
|
|
131
|
+
updated_rule = existing_analytic.rule != new_data.get(
|
|
132
|
+
"rule", existing_analytic.rule
|
|
133
|
+
) or existing_analytic.rule_crontab != new_data.get("rule_crontab", existing_analytic.rule_crontab)
|
|
134
|
+
|
|
135
|
+
existing_analytic.rule = new_data.get("rule", existing_analytic.rule)
|
|
136
|
+
existing_analytic.rule_crontab = new_data.get("rule_crontab", existing_analytic.rule_crontab)
|
|
137
|
+
|
|
138
|
+
storage.analytic.save(existing_analytic.analytic_id, existing_analytic)
|
|
139
|
+
|
|
140
|
+
if updated_rule:
|
|
141
|
+
# The registration process automatically deletes and resets the rule cronjob
|
|
142
|
+
register_rules(existing_analytic)
|
|
143
|
+
|
|
144
|
+
return ok(existing_analytic)
|
|
145
|
+
except HowlerException as e:
|
|
146
|
+
return bad_request(err=str(e))
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
@generate_swagger_docs()
|
|
150
|
+
@analytic_api.route("/rules", methods=["POST"])
|
|
151
|
+
@api_login(required_priv=["R", "W"])
|
|
152
|
+
def create_rule(user: User, **kwargs):
|
|
153
|
+
"""Create a rule analytic
|
|
154
|
+
|
|
155
|
+
Variables:
|
|
156
|
+
None
|
|
157
|
+
|
|
158
|
+
Optional Arguments:
|
|
159
|
+
None
|
|
160
|
+
|
|
161
|
+
Data Block:
|
|
162
|
+
{
|
|
163
|
+
"name": "Rule Name",
|
|
164
|
+
"description": "*markdown* _description_"
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
Result Example:
|
|
168
|
+
{
|
|
169
|
+
...analytic # The created analytic rule
|
|
170
|
+
}
|
|
171
|
+
"""
|
|
172
|
+
storage = datastore()
|
|
173
|
+
|
|
174
|
+
new_data: Optional[dict[str, Any]] = request.json
|
|
175
|
+
|
|
176
|
+
if not new_data:
|
|
177
|
+
return bad_request(err="You must provide rule data.")
|
|
178
|
+
|
|
179
|
+
required_keys = {
|
|
180
|
+
"name",
|
|
181
|
+
"description",
|
|
182
|
+
"rule",
|
|
183
|
+
"rule_type",
|
|
184
|
+
"rule_crontab",
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
for key in required_keys:
|
|
188
|
+
if key not in new_data or not new_data[key]:
|
|
189
|
+
return bad_request(err=f"You must provide a {key} for your rule.")
|
|
190
|
+
|
|
191
|
+
extra_keys = set(new_data.keys()) - required_keys
|
|
192
|
+
|
|
193
|
+
if len(extra_keys) > 0:
|
|
194
|
+
return bad_request(err=f"Additional fields ({', '.join(extra_keys)}) are not permitted.")
|
|
195
|
+
|
|
196
|
+
new_analytic = Analytic(
|
|
197
|
+
{
|
|
198
|
+
**new_data,
|
|
199
|
+
"tags": ["rule"],
|
|
200
|
+
"owner": user["uname"],
|
|
201
|
+
"contributors": [user["uname"]],
|
|
202
|
+
"detections": ["Rule"],
|
|
203
|
+
}
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
new_template = Template(
|
|
207
|
+
{
|
|
208
|
+
"analytic": new_data["name"],
|
|
209
|
+
"detection": "Rule",
|
|
210
|
+
"type": "global",
|
|
211
|
+
"owner": user["uname"],
|
|
212
|
+
# TODO: Allow custom keys
|
|
213
|
+
"keys": ["event.kind", "event.module", "event.reason", "event.type"],
|
|
214
|
+
}
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
try:
|
|
218
|
+
storage.analytic.save(new_analytic.analytic_id, new_analytic)
|
|
219
|
+
# Have to commit so the analytic is available during registration
|
|
220
|
+
storage.analytic.commit()
|
|
221
|
+
register_rules(new_analytic)
|
|
222
|
+
|
|
223
|
+
storage.template.save(new_template.template_id, new_template)
|
|
224
|
+
|
|
225
|
+
return ok(new_analytic)
|
|
226
|
+
except HowlerException as e:
|
|
227
|
+
return bad_request(err=str(e))
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
@generate_swagger_docs()
|
|
231
|
+
@analytic_api.route("/<id>", methods=["DELETE"])
|
|
232
|
+
@api_login(audit=False, required_priv=["W"])
|
|
233
|
+
def delete_rule(id: str, user: User, **kwargs):
|
|
234
|
+
"""Delete a rule
|
|
235
|
+
|
|
236
|
+
Variables:
|
|
237
|
+
id => id of the analytic whose comments we are deleting
|
|
238
|
+
|
|
239
|
+
Optional Arguments:
|
|
240
|
+
None
|
|
241
|
+
|
|
242
|
+
Data Block:
|
|
243
|
+
[
|
|
244
|
+
...comment_ids
|
|
245
|
+
]
|
|
246
|
+
|
|
247
|
+
Result Example:
|
|
248
|
+
{
|
|
249
|
+
}
|
|
250
|
+
"""
|
|
251
|
+
if not analytic_service.does_analytic_exist(id):
|
|
252
|
+
return not_found(err=f"Analytic {id} does not exist")
|
|
253
|
+
|
|
254
|
+
analytic: Analytic = analytic_service.get_analytic(id, as_obj=True)
|
|
255
|
+
|
|
256
|
+
if not analytic.rule:
|
|
257
|
+
return bad_request(err="This is not a rule analytic, and cannot be deleted.")
|
|
258
|
+
|
|
259
|
+
if user["uname"] != analytic.owner and "admin" not in user["type"]:
|
|
260
|
+
return forbidden(err="You cannot delete this analytic.")
|
|
261
|
+
|
|
262
|
+
try:
|
|
263
|
+
datastore().analytic.delete(analytic.analytic_id)
|
|
264
|
+
except DataStoreException as e:
|
|
265
|
+
return bad_request(err=str(e))
|
|
266
|
+
|
|
267
|
+
return no_content()
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
@generate_swagger_docs()
|
|
271
|
+
@analytic_api.route("/<id>/comments", methods=["POST"])
|
|
272
|
+
@api_login(audit=False, required_priv=["W"])
|
|
273
|
+
def add_comment(id: str, user: dict[str, Any], **kwargs):
|
|
274
|
+
"""Add a comment
|
|
275
|
+
|
|
276
|
+
Variables:
|
|
277
|
+
id => id of the analytic to add a comment to
|
|
278
|
+
|
|
279
|
+
Optional Arguments:
|
|
280
|
+
None
|
|
281
|
+
|
|
282
|
+
Data Block:
|
|
283
|
+
{
|
|
284
|
+
detection: "Detection to comment on (optional)",
|
|
285
|
+
value: "New comment value"
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
Result Example:
|
|
289
|
+
{
|
|
290
|
+
...analytic # The new data for the analytic
|
|
291
|
+
}
|
|
292
|
+
"""
|
|
293
|
+
comment = request.json
|
|
294
|
+
if not isinstance(comment, dict):
|
|
295
|
+
return bad_request(err="Incorrect data format!")
|
|
296
|
+
|
|
297
|
+
comment_data = comment.get("value")
|
|
298
|
+
if not comment_data:
|
|
299
|
+
return bad_request(err="Value cannot be empty.")
|
|
300
|
+
|
|
301
|
+
if len(comment_data) > MAX_COMMENT_LEN:
|
|
302
|
+
return bad_request(err="Comment is too long.")
|
|
303
|
+
|
|
304
|
+
if not analytic_service.does_analytic_exist(id):
|
|
305
|
+
return not_found(err="Analytic %s does not exist" % id)
|
|
306
|
+
|
|
307
|
+
analytic: Analytic = analytic_service.get_analytic(id, as_obj=True)
|
|
308
|
+
|
|
309
|
+
try:
|
|
310
|
+
analytic.comment.append(
|
|
311
|
+
Comment(
|
|
312
|
+
{
|
|
313
|
+
"user": user["uname"],
|
|
314
|
+
"value": comment_data,
|
|
315
|
+
"detection": comment.get("detection", None),
|
|
316
|
+
}
|
|
317
|
+
)
|
|
318
|
+
)
|
|
319
|
+
|
|
320
|
+
datastore().analytic.save(analytic.analytic_id, analytic)
|
|
321
|
+
except DataStoreException as e:
|
|
322
|
+
return bad_request(err=str(e))
|
|
323
|
+
|
|
324
|
+
analytic = analytic_service.get_analytic(id)
|
|
325
|
+
|
|
326
|
+
return ok(analytic)
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
@generate_swagger_docs()
|
|
330
|
+
@analytic_api.route("/<id>/comments/<comment_id>", methods=["PUT"])
|
|
331
|
+
@api_login(audit=False, required_priv=["W"])
|
|
332
|
+
def edit_comment(id: str, comment_id: str, user: dict[str, Any], **kwargs):
|
|
333
|
+
"""Edit a comment
|
|
334
|
+
|
|
335
|
+
Variables:
|
|
336
|
+
id => id of the analytic the comment belongs to
|
|
337
|
+
comment_id => id of the comment we are editing
|
|
338
|
+
|
|
339
|
+
Optional Arguments:
|
|
340
|
+
None
|
|
341
|
+
|
|
342
|
+
Data Block:
|
|
343
|
+
{
|
|
344
|
+
value: "New comment value"
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
Result Example:
|
|
348
|
+
{
|
|
349
|
+
...analytic # The new data for the analytic
|
|
350
|
+
}
|
|
351
|
+
"""
|
|
352
|
+
updated_comment = request.json
|
|
353
|
+
if not isinstance(updated_comment, dict):
|
|
354
|
+
return bad_request(err="Incorrect data format")
|
|
355
|
+
|
|
356
|
+
if not analytic_service.does_analytic_exist(id):
|
|
357
|
+
return not_found(err=f"Analytic {id} does not exist")
|
|
358
|
+
|
|
359
|
+
comment_data: Optional[str] = updated_comment.get("value")
|
|
360
|
+
if not comment_data:
|
|
361
|
+
return bad_request(err="Value cannot be empty.")
|
|
362
|
+
|
|
363
|
+
if len(comment_data) > MAX_COMMENT_LEN:
|
|
364
|
+
return bad_request(err="Comment is too long.")
|
|
365
|
+
|
|
366
|
+
analytic: Analytic = analytic_service.get_analytic(id, as_obj=True)
|
|
367
|
+
|
|
368
|
+
comment: Optional[Comment] = next((c for c in analytic.comment if c.id == comment_id), None)
|
|
369
|
+
|
|
370
|
+
if not comment:
|
|
371
|
+
return not_found(err=f"Comment {comment_id} does not exist")
|
|
372
|
+
|
|
373
|
+
if comment.user != user["uname"]:
|
|
374
|
+
return forbidden(err="Cannot edit comment that wasn't made by you.")
|
|
375
|
+
|
|
376
|
+
comment["value"] = comment_data
|
|
377
|
+
comment["modified"] = "NOW"
|
|
378
|
+
|
|
379
|
+
analytic.comment = [c if c.id != comment.id else comment for c in analytic.comment]
|
|
380
|
+
|
|
381
|
+
try:
|
|
382
|
+
datastore().analytic.save(analytic.analytic_id, analytic)
|
|
383
|
+
except DataStoreException as e:
|
|
384
|
+
return bad_request(err=str(e))
|
|
385
|
+
|
|
386
|
+
return ok(analytic)
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
@generate_swagger_docs()
|
|
390
|
+
@analytic_api.route("/<id>/comments/<comment_id>/react", methods=["PUT"])
|
|
391
|
+
@api_login(audit=False, required_priv=["W"])
|
|
392
|
+
def react_comment(id: str, comment_id: str, user: dict[str, Any], **kwargs):
|
|
393
|
+
"""React to a comment
|
|
394
|
+
|
|
395
|
+
Variables:
|
|
396
|
+
id => id of the analytic the comment belongs to
|
|
397
|
+
comment_id => id of the comment we are editing
|
|
398
|
+
|
|
399
|
+
Optional Arguments:
|
|
400
|
+
None
|
|
401
|
+
|
|
402
|
+
Data Block:
|
|
403
|
+
{
|
|
404
|
+
type: "thumbsup"
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
Result Example:
|
|
408
|
+
{
|
|
409
|
+
...analytic # The new data for the analytic
|
|
410
|
+
}
|
|
411
|
+
"""
|
|
412
|
+
data = request.json
|
|
413
|
+
if not isinstance(data, dict):
|
|
414
|
+
return bad_request(err="Incorrect data format")
|
|
415
|
+
|
|
416
|
+
react_data: Optional[str] = data.get("type")
|
|
417
|
+
if not react_data:
|
|
418
|
+
return bad_request(err="Type cannot be empty.")
|
|
419
|
+
|
|
420
|
+
if not analytic_service.does_analytic_exist(id):
|
|
421
|
+
return not_found(err=f"Analytic {id} does not exist")
|
|
422
|
+
|
|
423
|
+
analytic: Analytic = analytic_service.get_analytic(id, as_obj=True)
|
|
424
|
+
|
|
425
|
+
for comment in analytic.comment:
|
|
426
|
+
if comment.id == comment_id:
|
|
427
|
+
comment["reactions"] = {
|
|
428
|
+
**comment.get("reactions", {}),
|
|
429
|
+
user["uname"]: react_data,
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
datastore().analytic.save(analytic.analytic_id, analytic)
|
|
433
|
+
|
|
434
|
+
return ok(analytic)
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
@generate_swagger_docs()
|
|
438
|
+
@analytic_api.route("/<id>/comments/<comment_id>/react", methods=["DELETE"])
|
|
439
|
+
@api_login(audit=False, required_priv=["W"])
|
|
440
|
+
def remove_react_comment(id: str, comment_id: str, user: dict[str, Any], **kwargs):
|
|
441
|
+
"""React to a comment
|
|
442
|
+
|
|
443
|
+
Variables:
|
|
444
|
+
id => id of the analytic the comment belongs to
|
|
445
|
+
comment_id => id of the comment we are editing
|
|
446
|
+
|
|
447
|
+
Optional Arguments:
|
|
448
|
+
None
|
|
449
|
+
|
|
450
|
+
Result Example:
|
|
451
|
+
{
|
|
452
|
+
...analytic # The new data for the analytic
|
|
453
|
+
}
|
|
454
|
+
"""
|
|
455
|
+
if not analytic_service.does_analytic_exist(id):
|
|
456
|
+
return not_found(err=f"Analytic {id} does not exist")
|
|
457
|
+
|
|
458
|
+
analytic: Analytic = analytic_service.get_analytic(id, as_obj=True)
|
|
459
|
+
|
|
460
|
+
for comment in analytic.comment:
|
|
461
|
+
if comment.id == comment_id:
|
|
462
|
+
reactions = comment.get("reactions", {})
|
|
463
|
+
reactions.pop(user["uname"], None)
|
|
464
|
+
comment["reactions"] = {**reactions}
|
|
465
|
+
|
|
466
|
+
datastore().analytic.save(analytic.analytic_id, analytic)
|
|
467
|
+
|
|
468
|
+
return ok(analytic)
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
@generate_swagger_docs()
|
|
472
|
+
@analytic_api.route("/<id>/comments", methods=["DELETE"])
|
|
473
|
+
@api_login(audit=False, required_priv=["W"])
|
|
474
|
+
def delete_comments(id: str, user: User, **kwargs):
|
|
475
|
+
"""Delete a set of comments
|
|
476
|
+
|
|
477
|
+
Variables:
|
|
478
|
+
id => id of the analytic whose comments we are deleting
|
|
479
|
+
|
|
480
|
+
Optional Arguments:
|
|
481
|
+
None
|
|
482
|
+
|
|
483
|
+
Data Block:
|
|
484
|
+
[
|
|
485
|
+
...comment_ids
|
|
486
|
+
]
|
|
487
|
+
|
|
488
|
+
Result Example:
|
|
489
|
+
{
|
|
490
|
+
}
|
|
491
|
+
"""
|
|
492
|
+
if not analytic_service.does_analytic_exist(id):
|
|
493
|
+
return not_found(err=f"Analytic {id} does not exist")
|
|
494
|
+
|
|
495
|
+
comment_ids: list[str] = request.json or []
|
|
496
|
+
|
|
497
|
+
if len(comment_ids) == 0:
|
|
498
|
+
return bad_request(err="Supply at least one comment to delete.")
|
|
499
|
+
|
|
500
|
+
analytic: Analytic = analytic_service.get_analytic(id, as_obj=True)
|
|
501
|
+
|
|
502
|
+
new_comments = []
|
|
503
|
+
for comment in analytic.comment:
|
|
504
|
+
if comment.id in comment_ids:
|
|
505
|
+
if ("admin" not in user["type"]) and comment.user != user["uname"]:
|
|
506
|
+
return forbidden(err="You cannot delete the comment of someone else.")
|
|
507
|
+
|
|
508
|
+
continue
|
|
509
|
+
|
|
510
|
+
new_comments.append(comment)
|
|
511
|
+
|
|
512
|
+
analytic.comment = new_comments
|
|
513
|
+
|
|
514
|
+
try:
|
|
515
|
+
datastore().analytic.save(analytic.analytic_id, analytic)
|
|
516
|
+
except DataStoreException as e:
|
|
517
|
+
return bad_request(err=str(e))
|
|
518
|
+
|
|
519
|
+
return no_content()
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
@generate_swagger_docs()
|
|
523
|
+
@analytic_api.route("/<id>/owner", methods=["POST"])
|
|
524
|
+
@api_login(required_priv=["W"])
|
|
525
|
+
def set_analytic_owner(id: str, user: dict[str, Any], **kwargs):
|
|
526
|
+
"""Set the analytic's owner
|
|
527
|
+
|
|
528
|
+
Variables:
|
|
529
|
+
id => id of the analytic to claim
|
|
530
|
+
|
|
531
|
+
Arguments:
|
|
532
|
+
None
|
|
533
|
+
|
|
534
|
+
Data Block:
|
|
535
|
+
{
|
|
536
|
+
"username": "admin" # The username to set the owner as
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
Result Example:
|
|
540
|
+
{
|
|
541
|
+
...analytic # The claimed analytic
|
|
542
|
+
}
|
|
543
|
+
"""
|
|
544
|
+
if not analytic_service.does_analytic_exist(id):
|
|
545
|
+
return not_found(err=f"Analytic {id} does not exist")
|
|
546
|
+
|
|
547
|
+
data: dict[str, Any] = typing.cast(dict[str, Any], request.json)
|
|
548
|
+
if not user_service.get_user(data["username"]):
|
|
549
|
+
return not_found(err=f"User {data['username']} does not exist")
|
|
550
|
+
|
|
551
|
+
analytic: Analytic = analytic_service.get_analytic(id, as_obj=True)
|
|
552
|
+
|
|
553
|
+
analytic.owner = data["username"]
|
|
554
|
+
|
|
555
|
+
ds = datastore()
|
|
556
|
+
ds.analytic.save(analytic.analytic_id, analytic)
|
|
557
|
+
ds.analytic.commit()
|
|
558
|
+
|
|
559
|
+
return ok(analytic)
|
|
560
|
+
|
|
561
|
+
|
|
562
|
+
@generate_swagger_docs()
|
|
563
|
+
@analytic_api.route("/<id>/favourite", methods=["POST"])
|
|
564
|
+
@api_login(required_priv=["R", "W"])
|
|
565
|
+
def set_as_favourite(id, **kwargs):
|
|
566
|
+
"""Add an analytic to a list of the user's favourites
|
|
567
|
+
|
|
568
|
+
Variables:
|
|
569
|
+
id => The id of the analytic to add as a favourite
|
|
570
|
+
|
|
571
|
+
Optional Arguments:
|
|
572
|
+
None
|
|
573
|
+
|
|
574
|
+
Data Block:
|
|
575
|
+
{} # Empty
|
|
576
|
+
|
|
577
|
+
Result Example:
|
|
578
|
+
{
|
|
579
|
+
"success": True # If the operation succeeded
|
|
580
|
+
}
|
|
581
|
+
"""
|
|
582
|
+
storage = datastore()
|
|
583
|
+
|
|
584
|
+
existing_analytic: Analytic = storage.analytic.get_if_exists(id)
|
|
585
|
+
if not existing_analytic:
|
|
586
|
+
return not_found(err="This analytic does not exist")
|
|
587
|
+
|
|
588
|
+
try:
|
|
589
|
+
current_user = storage.user.get_if_exists(kwargs["user"]["uname"])
|
|
590
|
+
|
|
591
|
+
current_user["favourite_analytics"] = list(set(current_user.get("favourite_analytics", []) + [id]))
|
|
592
|
+
|
|
593
|
+
storage.user.save(current_user["uname"], current_user)
|
|
594
|
+
|
|
595
|
+
return ok()
|
|
596
|
+
except ValueError as e:
|
|
597
|
+
return bad_request(err=str(e))
|
|
598
|
+
|
|
599
|
+
|
|
600
|
+
@generate_swagger_docs()
|
|
601
|
+
@analytic_api.route("/<id>/favourite", methods=["DELETE"])
|
|
602
|
+
@api_login(required_priv=["R", "W"])
|
|
603
|
+
def remove_as_favourite(id, **kwargs):
|
|
604
|
+
"""Remove an analytic from a list of the user's favourites
|
|
605
|
+
|
|
606
|
+
Variables:
|
|
607
|
+
id => The id of the analytic to remove as a favourite
|
|
608
|
+
|
|
609
|
+
Optional Arguments:
|
|
610
|
+
None
|
|
611
|
+
|
|
612
|
+
Result Example:
|
|
613
|
+
{
|
|
614
|
+
"success": True # If the operation succeeded
|
|
615
|
+
}
|
|
616
|
+
"""
|
|
617
|
+
storage = datastore()
|
|
618
|
+
|
|
619
|
+
if not storage.analytic.exists(id):
|
|
620
|
+
return not_found(err="This analytic does not exist")
|
|
621
|
+
|
|
622
|
+
try:
|
|
623
|
+
current_user = storage.user.get_if_exists(kwargs["user"]["uname"])
|
|
624
|
+
|
|
625
|
+
current_user["favourite_analytics"] = list(
|
|
626
|
+
filter(lambda f: f != id, current_user.get("favourite_analytics", []))
|
|
627
|
+
)
|
|
628
|
+
|
|
629
|
+
storage.user.save(current_user["uname"], current_user)
|
|
630
|
+
|
|
631
|
+
return no_content()
|
|
632
|
+
except ValueError as e:
|
|
633
|
+
return bad_request(err=str(e))
|
|
634
|
+
|
|
635
|
+
|
|
636
|
+
@generate_swagger_docs()
|
|
637
|
+
@analytic_api.route("/<id>/notebooks", methods=["POST"])
|
|
638
|
+
@api_login(audit=False, required_priv=["W"])
|
|
639
|
+
def add_notebook(id: str, user: dict[str, Any], **kwargs):
|
|
640
|
+
"""Add a notebook
|
|
641
|
+
|
|
642
|
+
Variables:
|
|
643
|
+
id => id of the analytic to add a notebook to
|
|
644
|
+
|
|
645
|
+
Optional Arguments:
|
|
646
|
+
None
|
|
647
|
+
|
|
648
|
+
Data Block:
|
|
649
|
+
{
|
|
650
|
+
value: "New notebook link",
|
|
651
|
+
name: "New notebook name",
|
|
652
|
+
detection: "Detection to add a notebook about (optional)",
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
Result Example:
|
|
656
|
+
{
|
|
657
|
+
...analytic # The new data for the analytic
|
|
658
|
+
}
|
|
659
|
+
"""
|
|
660
|
+
data = request.json
|
|
661
|
+
if not isinstance(data, dict):
|
|
662
|
+
return bad_request(err="Incorrect data format")
|
|
663
|
+
|
|
664
|
+
link = data.get("value")
|
|
665
|
+
name = data.get("name")
|
|
666
|
+
detection = data.get("detection", None)
|
|
667
|
+
|
|
668
|
+
if not link or not name:
|
|
669
|
+
return bad_request(err="Value/Name cannot be empty.")
|
|
670
|
+
|
|
671
|
+
if "nbgallery." not in link:
|
|
672
|
+
return bad_request(err="Only nbgallery is supported for now.")
|
|
673
|
+
|
|
674
|
+
if not analytic_service.does_analytic_exist(id):
|
|
675
|
+
return not_found(err="Analytic %s does not exist" % id)
|
|
676
|
+
|
|
677
|
+
analytic: Analytic = analytic_service.get_analytic(id, as_obj=True)
|
|
678
|
+
|
|
679
|
+
try:
|
|
680
|
+
analytic.notebooks.append(
|
|
681
|
+
Notebook(
|
|
682
|
+
{
|
|
683
|
+
"user": user["uname"],
|
|
684
|
+
"name": name,
|
|
685
|
+
"value": link,
|
|
686
|
+
"detection": detection if detection else None,
|
|
687
|
+
}
|
|
688
|
+
)
|
|
689
|
+
)
|
|
690
|
+
|
|
691
|
+
datastore().analytic.save(analytic.analytic_id, analytic)
|
|
692
|
+
except DataStoreException as e:
|
|
693
|
+
return bad_request(err=str(e))
|
|
694
|
+
|
|
695
|
+
analytic = analytic_service.get_analytic(id)
|
|
696
|
+
|
|
697
|
+
return ok(analytic)
|
|
698
|
+
|
|
699
|
+
|
|
700
|
+
@generate_swagger_docs()
|
|
701
|
+
@analytic_api.route("/<id>/notebooks", methods=["DELETE"])
|
|
702
|
+
@api_login(audit=False, required_priv=["W"])
|
|
703
|
+
def delete_notebook(id: str, user: User, **kwargs):
|
|
704
|
+
"""Delete a notebook
|
|
705
|
+
|
|
706
|
+
Variables:
|
|
707
|
+
id => id of the analytic whose notebook we are deleting
|
|
708
|
+
|
|
709
|
+
Optional Arguments:
|
|
710
|
+
None
|
|
711
|
+
|
|
712
|
+
Data Block:
|
|
713
|
+
[
|
|
714
|
+
notebook_id
|
|
715
|
+
]
|
|
716
|
+
|
|
717
|
+
Result Example:
|
|
718
|
+
{
|
|
719
|
+
}
|
|
720
|
+
"""
|
|
721
|
+
if not analytic_service.does_analytic_exist(id):
|
|
722
|
+
return not_found(err=f"Analytic {id} does not exist")
|
|
723
|
+
|
|
724
|
+
notebook_ids: list[str] = request.json or []
|
|
725
|
+
|
|
726
|
+
if len(notebook_ids) == 0:
|
|
727
|
+
return bad_request(err="A notebook id is necessary for deletion.")
|
|
728
|
+
|
|
729
|
+
analytic: Analytic = analytic_service.get_analytic(id, as_obj=True)
|
|
730
|
+
|
|
731
|
+
new_notebooks = []
|
|
732
|
+
for notebook in analytic.notebooks:
|
|
733
|
+
if notebook.id in notebook_ids:
|
|
734
|
+
if ("admin" not in user["type"]) and notebook.user != user["uname"]:
|
|
735
|
+
return forbidden(err="You cannot delete the notebook of someone else.")
|
|
736
|
+
|
|
737
|
+
continue
|
|
738
|
+
|
|
739
|
+
new_notebooks.append(notebook)
|
|
740
|
+
|
|
741
|
+
analytic.notebooks = new_notebooks
|
|
742
|
+
|
|
743
|
+
try:
|
|
744
|
+
datastore().analytic.save(analytic.analytic_id, analytic)
|
|
745
|
+
except DataStoreException as e:
|
|
746
|
+
return bad_request(err=str(e))
|
|
747
|
+
|
|
748
|
+
return no_content()
|