Coverage for src/client_handler_fiber.mys : 77%

Hot-keys on this page
r m x p toggle line displays
j k next/prev highlighted chunk
0 (zero) top of page
1 (one) first highlighted chunk
1from net.tcp.server import Client
2from os import tar
3from os import OsError
4from os.path import Path
5from os.subprocess import run
6from toml import decode as toml_decode
7from toml import Value as TomlValue
8from json import decode as json_decode
9from graphql import parse as graphql_parse
10from io.buffered_reader import BufferedReader
11from fiber import Fiber
12from fiber import Event
13from http.header_parser import parse_request
14from http import HttpError
15from http.header_parser import Request
16from semver import Version
17from string import StringBuilder
18from . import create_token
19from . import Status
20from .database import Database
21from .database import Release
22from .graphql import GraphQL
23from .statistics import Statistics
24from .activities import Activities
26RE_MYS_VERSION_STANDARD_LIBRARY: regex = (
27 re"^/\d+\.\d+\.\d+[\w-]*/standard-library.html")
28RE_MYS_VERSION_ACTIVITY: regex = re"^/\d+\.\d+\.\d+[\w-]*/activity.html"
29RE_MYS_VERSION_STATISTICS: regex = re"^/\d+\.\d+\.\d+[\w-]*/statistics.html"
30RE_MYS_VERSION_WORLD_SVG: regex = re"^/\d+\.\d+\.\d+[\w-]*/_images/world.svg"
31RE_MYS_VERSION: regex = re"^/\d+\.\d+\.\d+[\w-]*/"
32RE_PACKAGE_TAR_GZ: regex = re"^/package/([\w-]+)-\d+\.\d+\.\d+[\w-]*.tar.gz$"
33RE_PACKAGE_LATEST_TAR_GZ: regex = re"^/package/([\w-]+)-latest.tar.gz$"
34RE_PACKAGE_OPERATIONS: regex = re"^/package/[\w-]+$"
35RE_PACKAGE_LATEST: regex = re"^/package/[\w-]+/latest/"
36RE_MYS_TAR_GZ: regex = re"^/mys-\d+\.\d+\.\d+[\w-]*.tar.gz$"
37RE_PACKAGE_NAME: regex = re"^[\w\-]+$"
38RE_STANDARD_LIBRARY_BUILD_LOG: regex = re"^/standard-library/([\w-]+)/build-log.html$"
39RE_STANDARD_LIBRARY_COVERAGE_TAR_GZ: regex = re"^/standard-library/([\w-]+)/coverage.tar.gz$"
40RE_STANDARD_LIBRARY_COVERAGE: regex = re"^/standard-library/([\w-]+)/coverage/"
41RE_STANDARD_LIBRARY_BUILD_RESULT: regex = re"^/standard-library/([\w-]+)/build-result.txt$"
42RE_STANDARD_LIBRARY_DEPENDENTS: regex = re"^/standard-library/([\w-]+)/dependents.txt$"
44FILE_SUFFIX_TO_CONTENT_TYPE: {string: string} = {
45 ".html": "text/html",
46 ".css": "text/css",
47 ".js": "application/javascript",
48 ".svg": "image/svg+xml",
49 ".png": "image/png",
50 ".woff2": "font/woff2",
51 ".json": "application/json",
52}
54HEADERS_END: bytes = b"\r\n\r\n"
56STATUS_STRINGS: {i64: string} = {
57 i64(Status.Continue): "Continue",
58 i64(Status.Ok): "OK",
59 i64(Status.Found): "Found",
60 i64(Status.BadRequest): "Bad Request",
61 i64(Status.Unauthorized): "Unauthorized",
62 i64(Status.NotFound): "Not Found",
63 i64(Status.MethodNotAllowed): "Method Not Allowed"
64}
66func builds_to_emoji(result: string) -> string:
67 match result:
68 case "yes":
69 return "✅"
70 case "no":
71 return "❌"
72 case _:
73 return "🤔"
75func make_package_link(message: string) -> string:
76 parts = message.split(" ")
77 name = parts[1]
78 parts[1] = f"<a href=\"/package/{name}/latest/index.html\">{name}</a>"
80 return " ".join(parts)
82func create_request_table(name: string, requests: {string: i64}) -> string:
83 row_index = 0
84 builder = StringBuilder()
85 builder += (
86 "<table class=\"docutils align-default\" "
87 f"id=\"{name}\">\n"
88 " <thead>\n"
89 " <tr class=\"row-odd\">\n"
90 f" <th class=\"head\" onclick=\"sortTable(0, '{name}')\">"
91 "Path</th>\n"
92 f" <th class=\"head\" onclick=\"sortTable(1, '{name}')\">"
93 "Count</th>\n"
94 " </tr>\n"
95 " </thead>\n"
96 " <tbody>\n"
97 )
99 for path, count in requests:
100 if (row_index % 2) == 0:
101 builder += " <tr class=\"row-even\">\n"
102 else:
103 builder += " <tr class=\"row-odd\">\n"
105 builder += f" <td>{path}</td>\n"
106 builder += f" <td>{count}</td>\n"
107 builder += " </tr>\n"
108 row_index += 1
110 builder += (
111 " </tbody>\n"
112 "</table>\n"
113 )
115 return builder.to_string()
117SORT_TABLE_JS: string = """\
118<script>
119function sortTable(n, element) {
120 var table, rows, switching, i, x, y;
121 var shouldSwitch, dir, switchcount = 0;
122 table = document.getElementById(element);
123 switching = true;
124 dir = \"asc\";
125 while (switching) {
126 switching = false;
127 rows = table.rows;
128 for (i = 1; i < (rows.length - 1); i++) {
129 shouldSwitch = false;
130 x = rows[i].getElementsByTagName(\"TD\")[n].innerHTML;
131 y = rows[i + 1].getElementsByTagName(\"TD\")[n].innerHTML;
132 if (dir == \"asc\") {
133 if (((n < 1) && (x > y))
134 || ((n == 1) && (parseInt(x) > parseInt(y)))) {
135 shouldSwitch = true;
136 break;
137 }
138 } else if (dir == \"desc\") {
139 if (((n < 1) && (x < y))
140 || ((n == 1) && (parseInt(x) < parseInt(y)))) {
141 shouldSwitch = true;
142 break;
143 }
144 }
145 }
146 if (shouldSwitch) {
147 rows[i].parentNode.insertBefore(rows[i + 1], rows[i]);
148 switching = true;
149 switchcount++;
150 } else {
151 if (switchcount == 0 && dir == \"asc\") {
152 dir = \"desc\";
153 switching = true;
154 }
155 }
156 }
157}
158</script>
159"""
161class ClientHandlerFiber(Fiber):
162 database: Database
163 statistics: Statistics
164 event: Event
165 client: Client?
166 idle_client_handlers: [ClientHandlerFiber]
167 idle_client_handlers_ready: Event
168 root_directory: Path
169 response_status: Status
170 _buffered_reader: BufferedReader?
171 _graphql: GraphQL
172 _activities: Activities
174 func __init__(self,
175 database: Database,
176 statistics: Statistics,
177 graphql: GraphQL,
178 activities: Activities,
179 idle_client_handlers: [ClientHandlerFiber],
180 idle_client_handlers_ready: Event,
181 index: i64):
182 self.database = database
183 self.statistics = statistics
184 self.idle_client_handlers = idle_client_handlers
185 self.idle_client_handlers_ready = idle_client_handlers_ready
186 self.root_directory = Path(f".website/{index}")
187 self.event = Event()
188 self.client = None
189 self.create_root_directory()
190 self.response_status = Status.Unknown
191 self._buffered_reader = None
192 self._graphql = graphql
193 self._activities = activities
195 func create_root_directory(self):
196 self.root_directory.rm(recursive=True, force=True)
197 self.root_directory.mkdir(exists_ok=True)
199 func serve_client(self, client: Client):
200 self.client = client
201 self._buffered_reader = BufferedReader(client, 1024)
202 self.event.set()
204 func run(self):
205 while True:
206 self.event.wait()
207 self.event.clear()
209 try:
210 self.serve()
211 except Error as e:
212 print(e)
214 self.client.disconnect()
215 self.idle_client_handlers.append(self)
216 self.idle_client_handlers_ready.set()
218 func make_path(self, path: string) -> Path:
219 """Prepend the database root directory path to given path. Given path
220 must not start with a slash.
222 """
224 return self.root_directory.join(path)
226 func read_header(self) -> bytes?:
227 return self._buffered_reader.read_until(HEADERS_END)
229 func handle_request(self, request: Request):
230 self.response_status = Status.Unknown
232 if request.path == "/":
233 request.path = "/index.html"
235 path = request.path
237 if path.match(RE_MYS_VERSION_STANDARD_LIBRARY) is not None:
238 self.handle_mys_version_standard_library(request)
239 return
241 if path.match(RE_MYS_VERSION_ACTIVITY) is not None:
242 self.handle_mys_version_activity(request)
243 return
245 if path.match(RE_MYS_VERSION_STATISTICS) is not None:
246 self.handle_mys_version_statistics(request)
247 return
249 if path.match(RE_MYS_VERSION_WORLD_SVG) is not None:
250 self.handle_mys_version_world_svg(request)
251 return
253 if path.match(RE_MYS_VERSION) is not None:
254 self.handle_mys_version(request)
255 return
257 mo = path.match(RE_PACKAGE_TAR_GZ)
259 if mo is not None:
260 self.handle_package_tar_gz(request, mo.group(1))
261 return
263 mo = path.match(RE_PACKAGE_LATEST_TAR_GZ)
265 if mo is not None:
266 self.handle_package_latest_tar_gz(request, mo.group(1))
267 return
269 if path.match(RE_PACKAGE_OPERATIONS) is not None:
270 self.handle_package_operations(request)
271 return
273 if path.match(RE_PACKAGE_LATEST) is not None:
274 self.handle_package_latest(request)
275 return
277 if path.starts_with("/package/"):
278 self.handle_package(request)
279 return
281 if path.match(RE_MYS_TAR_GZ) is not None:
282 self.handle_mys_tar_gz(request)
283 return
285 if path == "/favicon.ico":
286 self.handle_static_file(request)
287 return
289 if path == "/standard-library.html":
290 self.handle_mys_standard_library(request)
291 return
293 if path == "/activity.html":
294 self.handle_mys_activity(request)
295 return
297 if path == "/statistics.html":
298 self.handle_mys_statistics(request)
299 return
301 if path == "/_images/world.svg":
302 self.handle_mys_world_svg(request)
303 return
305 mo = path.match(RE_STANDARD_LIBRARY_BUILD_RESULT)
307 if mo is not None:
308 self.handle_standard_library_build_result(request, mo.group(1))
309 return
311 mo = path.match(RE_STANDARD_LIBRARY_BUILD_LOG)
313 if mo is not None:
314 self.handle_standard_library_build_log(request, mo.group(1))
315 return
317 mo = path.match(RE_STANDARD_LIBRARY_COVERAGE_TAR_GZ)
319 if mo is not None:
320 self.handle_standard_library_coverage_tar_gz(request, mo.group(1))
321 return
323 if path.match(RE_STANDARD_LIBRARY_COVERAGE) is not None:
324 self.handle_standard_library_coverage(request)
325 return
327 mo = path.match(RE_STANDARD_LIBRARY_DEPENDENTS)
329 if mo is not None:
330 self.handle_standard_library_dependents(request, mo.group(1))
331 return
333 if path == "/standard-library/list.txt":
334 self.handle_standard_library_list(request)
335 return
337 if path == "/graphql":
338 self.handle_graphql(request)
339 return
341 self.handle_mys(request)
343 func serve(self):
344 header = self.read_header()
346 if header is None:
347 return
349 try:
350 request = parse_request(header, header.length())
351 except HttpError:
352 self.write_response(Status.BadRequest)
353 return
355 # The handler method may change the path, but we want the
356 # original path in the statistics.
357 path = request.path
359 try:
360 self.handle_request(request)
361 finally:
362 request.path = path
363 self.statistics.handle_request(request, self.response_status)
365 func handle_mys_version_standard_library(self, request: Request):
366 match request.method:
367 case "GET":
368 path = self.database.make_path(request.path)
370 if path.exists():
371 self.write_static_response_ok(path)
372 row_index = 0
373 packages = StringBuilder()
374 packages += (
375 "<table class=\"docutils align-default\">\n"
376 " <thead>\n"
377 " <tr class=\"row-odd\">\n"
378 " <th class=\"head\">Name</th>\n"
379 " <th class=\"head\">Description</th>\n"
380 " <th class=\"head\">Version</th>\n"
381 " <th class=\"head\">Downloads</th>\n"
382 " <th class=\"head\">Status</th>\n"
383 " </tr>\n"
384 " </thead>\n"
385 " <tbody>\n"
386 )
387 number_of_packages = 0
388 number_of_downloads = 0
390 for package_name in self.database.get_packages():
391 package = self.database.get_package(package_name)
392 number_of_packages += 1
393 number_of_downloads += package.number_of_downloads
394 builds_emoji = builds_to_emoji(package.builds)
395 builds_log_path = (
396 f"/standard-library/{package_name}/build-log.html")
397 coverage_path = (
398 f"/standard-library/{package_name}/coverage/html/index.html")
399 database_doc_path = (
400 f"/package/{package_name}/latest/index.html")
402 if (row_index % 2) == 0:
403 packages += " <tr class=\"row-even\">\n"
404 else:
405 packages += " <tr class=\"row-odd\">\n"
407 packages += (
408 f" <td><a href=\"{database_doc_path}\">"
409 f"{package_name}</a></td>\n")
410 packages += (
411 f" <td>{package.latest_release.description}</td>\n")
412 packages += f" <td>{package.latest_release.version}</td>\n"
413 packages += f" <td>{package.number_of_downloads}</td>\n"
414 packages += " <td>"
415 packages += (
416 f"<a href=\"{builds_log_path}\">{builds_emoji}</a>")
418 if (package.builds == "yes"
419 and self.database.make_path(coverage_path).exists()):
420 packages += (
421 f" <a href=\"{coverage_path}\">📄</a>")
423 packages += "</td>\n"
424 packages += " </tr>\n"
425 row_index += 1
427 packages += (" </tbody>\n"
428 "</table>\n")
430 data = path.read_text()
431 data = data.replace("<p>{website-packages}</p>",
432 packages.to_string())
433 data = data.replace("{website-number-of-packages}",
434 str(number_of_packages))
435 data = data.replace("{website-number-of-downloads}",
436 str(number_of_downloads))
437 self.client.write(data.to_utf8())
438 else:
439 self.write_response(Status.NotFound)
440 case _:
441 self.write_response(Status.MethodNotAllowed)
443 func handle_mys_version_activity(self, request: Request):
444 match request.method:
445 case "GET":
446 path = self.database.make_path(request.path)
448 if path.exists():
449 self.write_static_response_ok(path)
450 row_index = 0
451 activities = StringBuilder()
452 activities += (
453 "<table class=\"docutils align-default\">\n"
454 " <thead>\n"
455 " <tr class=\"row-odd\">\n"
456 " <th class=\"head\">Date</th>\n"
457 " <th class=\"head\">Message</th>\n"
458 " </tr>\n"
459 " </thead>\n"
460 " <tbody>\n")
462 for activity in self._activities.recent():
463 if (row_index % 2) == 0:
464 activities += " <tr class=\"row-even\">\n"
465 else:
466 activities += " <tr class=\"row-odd\">\n"
468 activities += f" <td>{activity.date}</td>\n"
470 match activity.kind:
471 case "📦":
472 message = make_package_link(activity.message)
473 case _:
474 message = activity.message
476 activities += f" <td>{activity.kind} {message}</td>\n"
477 activities += " </tr>\n"
478 row_index += 1
480 activities += (" </tbody>\n"
481 "</table>\n")
483 data = path.read_text()
484 data = data.replace("<p>{website-activities}</p>",
485 activities.to_string())
486 self.client.write(data.to_utf8())
487 else:
488 self.write_response(Status.NotFound)
489 case _:
490 self.write_response(Status.MethodNotAllowed)
492 func handle_mys_version(self, request: Request):
493 match request.method:
494 case "GET":
495 if ".." in request.path:
496 self.write_response(Status.BadRequest)
497 return
499 path = self.database.make_path(request.path)
501 if path.exists():
502 self.write_static_response_ok(path)
503 self.client.write(path.read_binary())
504 else:
505 self.write_response(Status.NotFound)
506 case _:
507 self.write_response(Status.MethodNotAllowed)
509 func handle_mys_standard_library(self, request: Request):
510 mys = self.database.get_mys()
512 if mys is not None:
513 request.path = f"/{mys.latest_release.version}{request.path}"
514 self.handle_mys_version_standard_library(request)
515 else:
516 self.write_response(Status.NotFound)
518 func handle_mys_activity(self, request: Request):
519 mys = self.database.get_mys()
521 if mys is not None:
522 request.path = f"/{mys.latest_release.version}{request.path}"
523 self.handle_mys_version_activity(request)
524 else:
525 self.write_response(Status.NotFound)
527 func handle_mys_version_statistics(self, request: Request):
528 match request.method:
529 case "GET":
530 path = self.database.make_path(request.path)
532 if path.exists():
533 requests = create_request_table("requestsTable",
534 self.statistics.requests.count)
535 row_index = 0
536 referrers = StringBuilder()
537 referrers += (
538 "<table class=\"docutils align-default\">\n"
539 " <thead>\n"
540 " <tr class=\"row-odd\">\n"
541 " <th class=\"head\">URL</th>\n"
542 " <th class=\"head\">Count</th>\n"
543 " </tr>\n"
544 " </thead>\n"
545 " <tbody>\n"
546 )
548 for url, count in self.statistics.referrers.count:
549 if (row_index % 2) == 0:
550 referrers += " <tr class=\"row-even\">\n"
551 else:
552 referrers += " <tr class=\"row-odd\">\n"
554 referrers += f" <td><a href=\"{url}\">{url}</a></td>\n"
555 referrers += f" <td>{count}</td>\n"
556 referrers += " </tr>\n"
557 row_index += 1
559 referrers += (
560 " </tbody>\n"
561 "</table>\n"
562 )
564 self.write_response_type(Status.Ok, "text/html")
565 data = path.read_text()
566 data = data.replace("{website-start-date-time}",
567 str(self.statistics.start_date_time))
568 data = data.replace("{website-number-of-requests}",
569 str(self.statistics.number_of_requests))
570 data = data.replace("<p>{website-requests}</p>", requests)
571 data = data.replace(
572 "{website-number-of-unique-visitors}",
573 self.statistics.unique_clients())
574 data = data.replace("<p>{website-referrers}</p>",
575 referrers.to_string() + SORT_TABLE_JS)
576 self.client.write(data.to_utf8())
577 else:
578 self.write_response(Status.NotFound)
579 case _:
580 self.write_response(Status.MethodNotAllowed)
582 func handle_mys_statistics(self, request: Request):
583 mys = self.database.get_mys()
585 if mys is not None:
586 request.path = f"/{mys.latest_release.version}{request.path}"
587 self.handle_mys_version_statistics(request)
588 else:
589 self.write_response(Status.NotFound)
591 func handle_mys_world_svg(self, request: Request):
592 mys = self.database.get_mys()
594 if mys is not None:
595 request.path = f"/{mys.latest_release.version}{request.path}"
596 self.handle_mys_version_world_svg(request)
597 else:
598 self.write_response(Status.NotFound)
600 func handle_standard_library_build_result(self,
601 request: Request,
602 package_name: string):
603 match request.method:
604 case "POST":
605 content = self.read_post_content(1_000_000, request.headers)
607 if content is None:
608 return
610 builds = string(content)
612 if builds in ["yes", "no"]:
613 self.database.set_package_builds(package_name, builds)
614 self.write_response(Status.Ok)
615 else:
616 self.write_response(Status.BadRequest)
617 case _:
618 self.write_response(Status.MethodNotAllowed)
620 func handle_standard_library_build_log(self,
621 request: Request,
622 package_name: string):
623 path = self.database.make_path(request.path)
625 match request.method:
626 case "GET":
627 if path.exists():
628 self.write_response(Status.Ok,
629 headers={"Content-Type": "text/html"})
630 self.client.write(path.read_binary())
631 else:
632 self.write_response(Status.NotFound)
633 case "POST":
634 package = self.database.get_package(package_name)
636 if package is None:
637 self.write_response(Status.NotFound)
638 return
640 content = self.read_post_content(5_000_000, request.headers)
642 if content is None:
643 return
645 log_path = self.database.make_path(f"standard-library/{package_name}")
646 log_path.mkdir(exists_ok=True)
647 path.write_binary(content)
648 self.write_response(Status.Ok)
649 case _:
650 self.write_response(Status.MethodNotAllowed)
652 func handle_standard_library_coverage_tar_gz(self,
653 request: Request,
654 package_name: string):
655 match request.method:
656 case "POST":
657 package = self.database.get_package(package_name)
659 if package is None:
660 self.write_response(Status.NotFound)
661 return
663 fiber_path = self.save_post_data_to_file(5_000_000, request.headers)
665 if fiber_path is None:
666 return
668 coverage_path = self.database.make_path(
669 f"standard-library/{package_name}")
670 coverage_path.mkdir(exists_ok=True)
671 tar(fiber_path,
672 extract=True,
673 strip_components=2,
674 output_directory=coverage_path)
675 self.write_response(Status.Ok)
676 case _:
677 self.write_response(Status.MethodNotAllowed)
679 func handle_standard_library_coverage(self, request: Request):
680 match request.method:
681 case "GET":
682 if ".." in request.path:
683 self.write_response(Status.BadRequest)
684 return
686 path = self.database.make_path(request.path)
688 if path.exists():
689 self.write_static_response_ok(path)
690 self.client.write(path.read_binary())
691 else:
692 self.write_response(Status.NotFound)
693 case _:
694 self.write_response(Status.MethodNotAllowed)
696 func handle_standard_library_dependents(self,
697 request: Request,
698 package_name: string):
699 match request.method:
700 case "GET":
701 package = self.database.get_package(package_name)
703 if package is None:
704 self.write_response(Status.NotFound)
705 else:
706 dependents = self.database.get_dependents(package_name)
708 if dependents.length() > 0:
709 dependents.append("")
711 self.write_response(Status.Ok,
712 data="\n".join(dependents).to_utf8())
713 case _:
714 self.write_response(Status.MethodNotAllowed)
716 func handle_standard_library_list(self, request: Request):
717 match request.method:
718 case "GET":
719 packages = self.database.get_packages()
720 self.write_response(Status.Ok, data="\n".join(packages).to_utf8())
721 case _:
722 self.write_response(Status.MethodNotAllowed)
724 func handle_graphql_post(self, request: Request):
725 content = self.read_post_content(5_000, request.headers)
727 if content is None:
728 return
730 self.statistics.number_of_graphql_requests += 1
732 try:
733 if "__schema" in string(content):
734 path = Path(__assets__).join("schema.json")
735 self.write_static_response_ok(path)
736 self.client.write(path.read_binary())
738 return
739 else:
740 decoded = json_decode(string(content)).get("query").string()
741 document = graphql_parse(decoded)
742 response = self._graphql.resolve_query(document)
743 except Error as error:
744 message = str(error).replace("\"", "'")
745 response = f"{{\"errors\":[{{\"message\":\"{message}\"}}]}}"
747 self.write_response(Status.Ok,
748 headers={"Content-Type": "application/json"},
749 data=response.to_utf8())
751 func handle_graphql(self, request: Request):
752 match request.method:
753 case "POST":
754 self.handle_graphql_post(request)
755 case _:
756 self.write_response(Status.MethodNotAllowed)
758 func handle_mys_version_world_svg(self, request: Request):
759 match request.method:
760 case "GET":
761 path = self.database.make_path(request.path)
763 if path.exists():
764 locations = StringBuilder()
766 for _, location in self.statistics.locations:
767 # Just approximate x and y.
768 x = (112.0 / 360.0) * (180.0 + location.longitude)
769 y = 4.0 + (60.0 / 180.0) * (90.0 - location.latitude)
771 if location.response_status == Status.Ok:
772 href = "a"
773 else:
774 href = "b"
776 locations += (
777 f""" <use href="#{href}" x="{x}" y="{y}" """
778 """style="opacity: 0.6"/>\n""")
780 self.write_response(Status.Ok,
781 headers={"Content-Type": "image/svg+xml"})
782 world = path.read_text()
783 world = world.replace(" <!-- {website-world} -->",
784 locations.to_string())
785 self.client.write(world.to_utf8())
786 else:
787 self.write_response(Status.NotFound)
788 case _:
789 self.write_response(Status.MethodNotAllowed)
791 func handle_mys(self, request: Request):
792 mys = self.database.get_mys()
794 if mys is not None:
795 request.path = f"/{mys.latest_release.version}{request.path}"
796 self.handle_mys_version(request)
797 else:
798 self.write_response(Status.NotFound)
800 func handle_package_operations(self, request: Request):
801 match request.method:
802 case "DELETE":
803 package_name = request.path[9:]
804 package = self.database.get_package(package_name)
806 if package is None:
807 self.write_response(Status.NotFound)
808 return
810 if not self.validate_token(request.params, package.token):
811 return
813 self.database.delete_package(package)
814 package_database_path = self.database.make_path(
815 f"package/{package_name}")
816 package_database_path.rm(recursive=True, force=True)
818 for release in package.releases:
819 release_database_path = self.database.make_path(
820 f"package/{package.name}-{release.version}.tar.gz")
821 release_database_path.rm(force=True)
823 self.write_response(Status.Ok)
824 self._activities.add("🪦", f"Package {package_name} deleted.")
825 case _:
826 self.write_response(Status.MethodNotAllowed)
828 func handle_package_latest(self, request: Request):
829 parts = request.path.split('/')
830 package_name = parts[2]
831 package = self.database.get_package(package_name)
833 if package is not None:
834 request.path = request.path.replace("latest",
835 package.latest_release.version)
836 self.handle_mys_version(request)
837 else:
838 self.write_response(Status.NotFound)
840 func handle_package(self, request: Request):
841 match request.method:
842 case "GET":
843 if ".." in request.path:
844 self.write_response(Status.BadRequest)
845 return
847 path = self.database.make_path(request.path)
849 if path.exists():
850 self.write_static_response_ok(path)
851 self.client.write(path.read_binary())
852 else:
853 self.write_response(Status.NotFound)
854 case _:
855 self.write_response(Status.MethodNotAllowed)
857 func generate_package_documentation(self,
858 package_name: string,
859 version: string):
860 database_doc_path = self.database.make_path(
861 f"package/{package_name}/{version}")
862 database_doc_path.rm(recursive=True, force=True)
863 database_doc_path.mkdir(exists_ok=True)
865 if self.root_directory.join("doc").exists():
866 try:
867 run(f"mys -C {self.root_directory} doc")
868 database_doc_path.rm()
869 self.root_directory.join("build/doc/html").mv(database_doc_path)
870 except OsError:
871 data = b"<html>Package documentation build failed!</html>"
872 database_doc_path.join("index.html").write_binary(data)
873 else:
874 data = b"<html>No package documentation found!</html>"
875 database_doc_path.join("index.html").write_binary(data)
877 func generate_package_lines_of_code(self, package_name: string):
878 lines_of_code_path = self.database.make_path(
879 f"package/{package_name}/lines_of_code.json")
881 try:
882 result = run(f"cloc "
883 f"--read-lang-def={__assets__}/cloc_definitions.txt "
884 f"--json "
885 f"{self.root_directory}")
886 lines_of_code_path.write_binary(result.stdout)
887 except OsError:
888 pass
890 func handle_package_tar_gz(self, request: Request, package_name: string):
891 match request.method:
892 case "GET":
893 self.handle_package_tar_gz_get(package_name, request.path)
894 case "POST":
895 self.handle_package_tar_gz_post(request.path,
896 request.params,
897 request.headers)
898 case _:
899 self.write_response(Status.MethodNotAllowed)
901 func handle_package_latest_tar_gz(self,
902 request: Request,
903 package_name: string):
904 match request.method:
905 case "GET":
906 package = self.database.get_package(package_name)
908 if package is not None:
909 version = package.latest_release.version
910 self.handle_package_tar_gz_get(
911 package_name,
912 f"/package/{package_name}-{version}.tar.gz")
913 else:
914 self.write_response(Status.NotFound)
915 case _:
916 self.write_response(Status.MethodNotAllowed)
918 func handle_package_tar_gz_get(self, package_name: string, path: string):
919 database_path = self.database.make_path(path)
921 if database_path.exists():
922 data = database_path.read_binary()
923 self.write_response(Status.Ok)
924 self.client.write(data)
925 self.database.increment_package_download_count(package_name)
926 else:
927 self.write_response(Status.NotFound)
929 func read_post_content(self,
930 max_size: i64,
931 headers: {string: string}) -> bytes?:
932 content_length = i64(headers["content-length"])
934 if content_length > max_size:
935 self.write_response(Status.BadRequest)
937 return None
939 expect = headers.get("expect", "")
941 if expect == "100-continue":
942 self.write_response(Status.Continue)
944 if content_length > 0:
945 data = self._buffered_reader.read(content_length)
947 if data.length() != content_length:
948 self.write_response(Status.BadRequest)
950 return None
951 else:
952 data = b""
954 return data
956 func save_post_data_to_file(self,
957 max_size: i64,
958 headers: {string: string}) -> Path?:
959 content = self.read_post_content(max_size, headers)
961 if content is None:
962 return None
964 self.create_root_directory()
965 fiber_path = self.make_path("archive.tar.gz")
966 fiber_path.write_binary(content)
968 return fiber_path
970 func validate_token(self,
971 params: {string: string},
972 expected_token: string) -> bool:
973 token = params.get("token", None)
975 if token is None:
976 self.write_response(Status.BadRequest)
978 return False
980 if token != expected_token:
981 self.write_response(Status.Unauthorized)
983 return False
985 return True
987 func handle_package_tar_gz_post(self,
988 path: string,
989 params: {string: string},
990 headers: {string: string}):
991 fiber_path = self.save_post_data_to_file(50_000_000, headers)
993 if fiber_path is None:
994 return
996 tar(fiber_path,
997 extract=True,
998 strip_components=1,
999 output_directory=self.root_directory)
1000 package_toml = self.make_path("package.toml").read_text()
1001 config = toml_decode(package_toml)
1002 package_name = config.get("package").get("name").string()
1003 version = config.get("package").get("version").string()
1005 try:
1006 description = config.get("package").get("description").string()
1007 except KeyError:
1008 description = "No description found."
1010 if package_name.match(RE_PACKAGE_NAME) is None:
1011 self.write_response(Status.BadRequest)
1012 return
1014 self.database.begin_transaction()
1015 response_data = ""
1016 created = False
1018 try:
1019 package = self.database.get_package(package_name)
1021 if package is None:
1022 self.database.create_package(package_name, create_token())
1023 package = self.database.get_package(package_name)
1024 response_data = f"{{\"token\": \"{package.token}\"}}"
1025 created = True
1026 elif not self.validate_token(params, package.token):
1027 self.database.rollback_transaction()
1028 return
1030 self.database.add_package_release(package, version, description)
1031 release = self.database.get_package_release(package, version)
1033 if package.latest_release is None:
1034 self.database.modify_package(package, release)
1035 elif Version(version) > Version(package.latest_release.version):
1036 self.database.modify_package(package, release)
1038 self.database.remove_dependents(package_name)
1040 try:
1041 for dependency in config.get("dependencies").table().keys():
1042 self.database.add_dependent(dependency, package_name)
1043 except KeyError:
1044 pass
1046 self.database.commit_transaction()
1047 except:
1048 self.database.rollback_transaction()
1049 raise
1051 if created:
1052 self._activities.add("✨", f"Package {package_name} created.")
1054 database_path = self.database.make_path(path)
1055 database_path.rm(force=True)
1056 fiber_path.mv(database_path)
1057 self.generate_package_documentation(package_name, version)
1058 self.generate_package_lines_of_code(package_name)
1059 self.write_response(Status.Ok, data=response_data.to_utf8())
1060 self._activities.add(
1061 "📦",
1062 f"Package {package_name} version {version} released.")
1064 func handle_mys_tar_gz(self, request: Request):
1065 database_path = self.database.make_path(request.path)
1067 match request.method:
1068 case "POST":
1069 self.handle_mys_tar_gz_post(request, database_path)
1070 case _:
1071 self.write_response(Status.MethodNotAllowed)
1073 func handle_mys_tar_gz_post(self, request: Request, database_path: Path):
1074 fiber_path = self.save_post_data_to_file(50_000_000, request.headers)
1076 if fiber_path is None:
1077 return
1079 version = request.path[5:-7]
1080 self.database.make_path(version).rm(recursive=True, force=True)
1081 tar(fiber_path,
1082 extract=True,
1083 output_directory=self.database.root_directory)
1085 self.database.begin_transaction()
1086 response_data = ""
1088 try:
1089 mys = self.database.get_mys()
1091 if mys is None:
1092 self.database.create_mys(create_token())
1093 mys = self.database.get_mys()
1094 response_data = f"{{\"token\": \"{mys.token}\"}}"
1095 elif not self.validate_token(request.params, mys.token):
1096 self.database.rollback_transaction()
1097 return
1099 self.database.add_mys_release(version)
1100 release = self.database.get_mys_release(version)
1102 if mys.latest_release is None:
1103 self.database.modify_mys(release)
1104 elif Version(version) > Version(mys.latest_release.version):
1105 self.database.modify_mys(release)
1107 self.database.commit_transaction()
1108 except:
1109 self.database.rollback_transaction()
1110 raise
1112 self.write_response(Status.Ok, data=response_data.to_utf8())
1113 self._activities.add("🐭", f"Mys version {version} released.")
1115 func handle_static_file(self, request: Request):
1116 match request.method:
1117 case "GET":
1118 path = Path(__assets__).join(request.path)
1120 if path.exists() and ".." not in str(path):
1121 self.write_static_response_ok(path)
1122 self.client.write(path.read_binary())
1123 else:
1124 self.write_response(Status.NotFound)
1125 case _:
1126 self.write_response(Status.MethodNotAllowed)
1128 func write_response(self,
1129 status: Status,
1130 headers: {string: string} = {},
1131 data: bytes? = None):
1132 self.response_status = status
1133 status_string = STATUS_STRINGS[i64(status)]
1134 self.client.write(f"HTTP/1.1 {status} {status_string}\r\n".to_utf8())
1136 for name, value in headers:
1137 self.client.write(f"{name}: {value}\r\n".to_utf8())
1139 if data is None:
1140 self.client.write(b"\r\n")
1141 else:
1142 self.client.write(f"Content-Length: {data.length()}\r\n\r\n".to_utf8())
1143 self.client.write(data)
1145 func write_static_response_ok(self, path: Path):
1146 content_type = FILE_SUFFIX_TO_CONTENT_TYPE.get(path.extension(), "text/plain")
1148 if content_type == "text/html":
1149 self.write_response_type(Status.Ok, content_type)
1150 else:
1151 self.write_response(Status.Ok,
1152 headers={
1153 "Cache-Control": "public, max-age=7200",
1154 "Content-Type": content_type
1155 })
1157 func write_response_type(self, status: Status, content_type: string):
1158 self.write_response(status,
1159 headers={"Content-Type": content_type})