diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/.env.example b/PROJECTS/advanced/monitor-the-situation-dashboard/.env.example index d597abf8..8d310b5a 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/.env.example +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/.env.example @@ -1,5 +1,5 @@ # ©AngelaMos | 2026 -# .env.example +# .env.example.dev APP_NAME=monitor PUBLIC_URL=http://localhost:8432 @@ -16,7 +16,9 @@ POSTGRES_DB=monitor REDIS_PASSWORD= -JWT_SECRET= +# AES-256 key for at-rest encryption of webhook URLs / bot tokens. +# Generate with: openssl rand -base64 32 +# Without this set, the entire notifications + alerts pipeline is disabled. NOTIFICATION_ENCRYPTION_KEY= NVD_API_KEY= @@ -24,6 +26,12 @@ CF_RADAR_TOKEN= GREYNOISE_API_KEY= ABUSEIPDB_API_KEY= -CLOUDFLARE_TUNNEL_TOKEN= +# Number of trusted reverse-proxy hops in front of the backend. +# Dev: nginx -> backend = 1. Prod with Cloudflare Tunnel = 2 (CF + nginx). +TRUSTED_PROXY_HOPS=1 + +# Email for the bootstrap admin. The matching account is auto-promoted to +# role=admin on registration / login. Leave blank to disable auto-promotion. +ADMIN_EMAIL= VITE_API_URL=/api diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/.env.example.dev b/PROJECTS/advanced/monitor-the-situation-dashboard/.env.example.dev new file mode 100644 index 00000000..94143c70 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/.env.example.dev @@ -0,0 +1,39 @@ +# ©AngelaMos | 2026 +# .env.example + +APP_NAME=monitor +PUBLIC_URL=http://localhost:8432 + +NGINX_HOST_PORT=8432 +BACKEND_HOST_PORT=5432 +FRONTEND_HOST_PORT=3432 +POSTGRES_HOST_PORT=4432 +REDIS_HOST_PORT=6432 + +POSTGRES_USER=monitor +POSTGRES_PASSWORD=changeme +POSTGRES_DB=monitor + +REDIS_PASSWORD= + +# AES-256 key for at-rest encryption of webhook URLs / bot tokens. +# Generate with: openssl rand -base64 32 +# Without this set, the entire notifications + alerts pipeline is disabled. +NOTIFICATION_ENCRYPTION_KEY= + +NVD_API_KEY= +CF_RADAR_TOKEN= +GREYNOISE_API_KEY= +ABUSEIPDB_API_KEY= + +CLOUDFLARE_TUNNEL_TOKEN= + +# Number of trusted reverse-proxy hops in front of the backend. +# Dev: nginx -> backend = 1. Prod with Cloudflare Tunnel = 2 (CF + nginx). +TRUSTED_PROXY_HOPS=1 + +# Email for the bootstrap admin. The matching account is auto-promoted to +# role=admin on registration / login. Leave blank to disable auto-promotion. +ADMIN_EMAIL= + +VITE_API_URL=/api diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/.env.example.prod b/PROJECTS/advanced/monitor-the-situation-dashboard/.env.example.prod new file mode 100644 index 00000000..53950351 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/.env.example.prod @@ -0,0 +1,39 @@ +# ©AngelaMos | 2026 +# .env.example.prod + +APP_NAME=situation-monitor-prod +PUBLIC_URL=https://xxx.xxx + +NGINX_HOST_PORT=7729 +BACKEND_HOST_PORT=9292 +FRONTEND_HOST_PORT=49967 +POSTGRES_HOST_PORT=47844 +REDIS_HOST_PORT=55675 + +POSTGRES_USER= +POSTGRES_PASSWORD= +POSTGRES_DB= + +REDIS_PASSWORD= + +# AES-256 key for at-rest encryption of webhook URLs / bot tokens. +# Generate with: openssl rand -base64 32 +# Without this set, the entire notifications + alerts pipeline is disabled. +NOTIFICATION_ENCRYPTION_KEY= + +NVD_API_KEY= +CF_RADAR_TOKEN=c +GREYNOISE_API_KEY= +ABUSEIPDB_API_KEY= + +CLOUDFLARE_TUNNEL_TOKEN= + +VITE_API_URL=/api + +# Number of trusted reverse-proxy hops in front of the backend. +# Dev: nginx -> backend = 1. Prod with Cloudflare Tunnel = 2 (CF + nginx). +TRUSTED_PROXY_HOPS=2 + +# Email for the bootstrap admin. The matching account is auto-promoted to +# role=admin on registration / login. Leave blank to disable auto-promotion. +ADMIN_EMAIL= diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/.gitignore b/PROJECTS/advanced/monitor-the-situation-dashboard/.gitignore index df37de2a..2301f2b3 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/.gitignore +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/.gitignore @@ -4,6 +4,7 @@ .env .env.local .env.*.local +.env.prod backend/tmp/ backend/bin/ @@ -11,6 +12,7 @@ backend/coverage.out backend/coverage.html backend/keys/ + frontend/dist/ frontend/node_modules/ frontend/.vite/ diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/Justfile b/PROJECTS/advanced/monitor-the-situation-dashboard/Justfile index ffc21484..6a21c33b 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/Justfile +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/Justfile @@ -29,7 +29,7 @@ lint-fix: [group('lint')] format: - cd backend && gofumpt -w . && goimports -w . + cd backend && golangci-lint fmt [group('lint')] tidy: @@ -106,7 +106,7 @@ install: [group('db')] migrate *ARGS: - docker compose -f compose.yml exec backend sh -c 'goose -dir /migrations postgres "$DATABASE_URL" up' {{ARGS}} + docker compose --env-file .env.prod -f compose.yml exec backend sh -c 'goose -dir /migrations postgres "$DATABASE_URL" up' {{ARGS}} [group('db')] migrate-dev *ARGS: @@ -134,27 +134,27 @@ redis-cli: [group('docker')] up *ARGS: - docker compose -f compose.yml up {{ARGS}} + docker compose --env-file .env.prod -f compose.yml up {{ARGS}} [group('docker')] start *ARGS: - docker compose -f compose.yml up -d --build {{ARGS}} + docker compose --env-file .env.prod -f compose.yml up -d --build {{ARGS}} [group('docker')] down *ARGS: - docker compose -f compose.yml down {{ARGS}} + docker compose --env-file .env.prod -f compose.yml down {{ARGS}} [group('docker')] logs *SERVICE: - docker compose -f compose.yml logs -f {{SERVICE}} + docker compose --env-file .env.prod -f compose.yml logs -f {{SERVICE}} [group('docker')] ps: - docker compose -f compose.yml ps + docker compose --env-file .env.prod -f compose.yml ps [group('docker')] build-prod: - docker compose -f compose.yml build + docker compose --env-file .env.prod -f compose.yml build # ============================================================================= # Docker (development) @@ -184,6 +184,12 @@ dev-ps: dev-shell service='backend': docker compose -f dev.compose.yml exec -it {{service}} /bin/sh +[group('docker')] +dev-restart: + docker compose -f dev.compose.yml down + docker compose -f dev.compose.yml up -d --build + docker compose -f dev.compose.yml logs -f + [group('docker')] dev-clean: docker compose -f dev.compose.yml down -v @@ -194,19 +200,35 @@ dev-clean: [group('tunnel')] tunnel-up *ARGS: - docker compose -f compose.yml -f cloudflared.compose.yml up {{ARGS}} + docker compose --env-file .env.prod -f compose.yml -f cloudflared.compose.yml up {{ARGS}} [group('tunnel')] tunnel-start *ARGS: - docker compose -f compose.yml -f cloudflared.compose.yml up -d --build {{ARGS}} + docker compose --env-file .env.prod -f compose.yml -f cloudflared.compose.yml up -d --build {{ARGS}} [group('tunnel')] tunnel-down *ARGS: - docker compose -f compose.yml -f cloudflared.compose.yml down {{ARGS}} + docker compose --env-file .env.prod -f compose.yml -f cloudflared.compose.yml down {{ARGS}} [group('tunnel')] tunnel-logs: - docker compose -f compose.yml -f cloudflared.compose.yml logs -f cloudflared + docker compose --env-file .env.prod -f compose.yml -f cloudflared.compose.yml logs -f cloudflared + +[group('tunnel')] +prod-restart: + docker compose --env-file .env.prod -f compose.yml -f cloudflared.compose.yml down + docker compose --env-file .env.prod -f compose.yml -f cloudflared.compose.yml up -d + docker compose --env-file .env.prod -f compose.yml -f cloudflared.compose.yml logs -f + +[group('tunnel')] +prod-redeploy: + docker compose --env-file .env.prod -f compose.yml -f cloudflared.compose.yml down + docker compose --env-file .env.prod -f compose.yml -f cloudflared.compose.yml up -d --build + docker compose --env-file .env.prod -f compose.yml -f cloudflared.compose.yml logs -f + +[group('tunnel')] +prod-logs *SERVICE: + docker compose --env-file .env.prod -f compose.yml -f cloudflared.compose.yml logs -f {{SERVICE}} # ============================================================================= # Utilities diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/LICENSE b/PROJECTS/advanced/monitor-the-situation-dashboard/LICENSE new file mode 100644 index 00000000..0ad25db4 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/LICENSE @@ -0,0 +1,661 @@ + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/README.md b/PROJECTS/advanced/monitor-the-situation-dashboard/README.md index 6b62b30c..f1585d53 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/README.md +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/README.md @@ -3,65 +3,82 @@ README.md --> -# Monitor the Situation +```json + ██████╗██╗ █████╗ +██╔════╝██║██╔══██╗ +██║ ██║███████║ +██║ ██║██╔══██║ +╚██████╗██║██║ ██║ + ╚═════╝╚═╝╚═╝ ╚═╝ +``` -Operator-grade real-time situational awareness dashboard. Single-binary Go backend, React 19 frontend, Postgres + Redis, fronted by nginx and (optionally) a Cloudflare Tunnel. +[![Cybersecurity Projects](https://img.shields.io/badge/Cybersecurity--Projects-Project%20%2328-red?style=flat&logo=github)](https://github.com/CarterPerez-dev/Cybersecurity-Projects/tree/main/PROJECTS/advanced/monitor-the-situation-dashboard) +[![Go](https://img.shields.io/badge/Go-1.25+-00ADD8?style=flat&logo=go&logoColor=white)](https://go.dev) +[![React](https://img.shields.io/badge/React-19-61DAFB?style=flat&logo=react&logoColor=black)](https://react.dev) +[![License: AGPLv3](https://img.shields.io/badge/License-AGPL_v3-purple.svg)](https://www.gnu.org/licenses/agpl-3.0) +[![Live Demo](https://img.shields.io/badge/Live-iminthewalls.com-green?style=flat&logo=googlechrome)](https://iminthewalls.com/) +[![Docker](https://img.shields.io/badge/Docker-ready-2496ED?style=flat&logo=docker)](https://www.docker.com) -> The phrase "Monitoring the situation" is a Twitter/X meme from June 2025. This is the version that actually monitors the situation. +> Operator-grade real-time situational awareness dashboard. Eleven live feeds across cyber, world, and finance — fused into a single 3D-globe SOC view with WebSocket delivery and configurable alerting. + +*This is a quick overview — security theory, architecture, and full walkthroughs are in the [learn modules](#learn).* + +> The phrase "monitoring the situation" is a Twitter/X meme from June 2025. This is the version that actually monitors the situation. + +## What It Does + +- Aggregates 11 high-signal data feeds (DShield, Cloudflare Radar, NVD/EPSS, CISA KEV, ransomware.live, Coinbase WS, USGS, NOAA SWPC, Wikipedia ITN, GDELT, ISS) with per-source cadences from sub-second to daily +- WebSocket fan-out from a single Go binary — collectors run as errgroup goroutines, events flow through an in-process bus to all connected clients +- 3D MapLibre globe centerpiece with country-level outage shading, BGP hijack regions, mass-scan source ASN dots, ransomware victim markers, earthquake epicenters, and live ISS orbital track +- CVE velocity timeline with EPSS-weighted prioritization and CISA KEV diff alerts +- Configurable alerts (toast / banner / chime / Telegram / Discord) with AES-256 encryption of webhook secrets at rest +- BRIN-indexed Postgres time-series storage tuned for append-mostly event streams +- JWT auth with auto-rotating Ed25519 keys, public read-only mode, multi-device session management + +## Quick Start + +```bash +just dev-up +``` + +Visit `http://localhost:8432` or the live demo at [iminthewalls.com](https://iminthewalls.com/) + +> [!TIP] +> This project uses [`just`](https://github.com/casey/just) as a command runner. Type `just` to see all available commands. +> +> Install: `curl -sSf https://just.systems/install.sh | bash -s -- --to ~/.local/bin` ## Stack -| Layer | Tech | -| ----------- | ---------------------------------------------------------- | -| Backend | Go 1.22, chi router, `coder/websocket`, `goose` migrations | -| Frontend | React 19, Vite, TanStack Query, Zustand, MapLibre, D3 | -| Storage | Postgres 16 (BRIN-indexed time-series), Redis 7 | -| Ingress | nginx (dev + prod), Cloudflare Tunnel (prod) | -| Build / run | `just` recipes, multi-stage Docker, air for live reload | +**Backend:** Go 1.25, chi v5, `coder/websocket`, pgx + pgxpool, goose migrations, errgroup-driven collectors, Argon2id, JWT (Ed25519) -## Data sources +**Frontend:** React 19, TypeScript, Vite, TanStack Query v5, Zustand, MapLibre GL, D3, SCSS Modules -| Panel | Source | Cadence | Auth | -| ------------------------------ | ---------------------------- | ---------- | --------------------------------------------- | -| Mass-scan firehose | DShield (SANS ISC) | 1h | none | -| Internet outages + BGP hijacks | Cloudflare Radar | 5m | `CF_RADAR_TOKEN` (Radar:Read scope) | -| CVE velocity + EPSS | NVD CVE 2.0 + FIRST EPSS | 2h | `NVD_API_KEY` (optional, raises rate limit) | -| CISA KEV | CISA KEV catalog | 1h | none | -| Ransomware victims | ransomware.live | 15m | none | -| Live BTC + ETH ticks | Coinbase Advanced Trade WS | persistent | none | -| Earthquakes (M2.5+) | USGS GeoJSON feed | 1m | none | -| Space weather | NOAA SWPC (5 endpoints) | 1m / 3h | none | -| World events | Wikipedia ITN + GDELT v2 API | 5m / 15m | none | -| ISS position | wheretheiss.at + CelesTrak | 10s / 24h | none | -| IP enrichment (BGP hijacks) | AbuseIPDB | on-demand | `ABUSEIPDB_API_KEY` (optional, free tier 1k/day) | +**Data:** PostgreSQL 16 (BRIN time-series indexes), Redis 7 -## Quickstart (development) +**Infrastructure:** Docker Compose, nginx reverse proxy, Cloudflare Tunnel (prod), multi-stage builds, air for live reload -```bash -cp .env.example .env -# fill .env: POSTGRES_PASSWORD, JWT_SECRET, NOTIFICATION_ENCRYPTION_KEY -just dev-start -just migrate-dev -open http://localhost:8432 -``` +## Data Sources -JWT signing keys auto-generate at `backend/keys/private.pem` on first boot. The dev stack binds host ports `8432` (nginx) / `5432` (backend) / `4432` (postgres) / `6432` (redis) / `3432` (vite). - -### Smoke checks - -```bash -curl -s http://localhost:8432/api/v1/healthz -curl -s http://localhost:8432/api/v1/snapshot | jq . -docker run --rm -i --network host ghcr.io/vi/websocat:latest \ - "ws://localhost:8432/api/v1/ws?topics=heartbeat" -``` +| Panel | Source | Cadence | Auth | +|-------|--------|---------|------| +| Mass-scan firehose | DShield (SANS ISC) | 1h | none | +| Internet outages + BGP hijacks | Cloudflare Radar | 5m | `CF_RADAR_TOKEN` | +| CVE velocity + EPSS | NVD CVE 2.0 + FIRST EPSS | 2h | `NVD_API_KEY` (optional) | +| CISA KEV (in-the-wild) | CISA KEV catalog | 1h | none | +| Ransomware victims | ransomware.live | 15m | none | +| BTC + ETH live ticks | Coinbase Advanced Trade WS | persistent | none | +| Earthquakes (M2.5+) | USGS GeoJSON | 1m | none | +| Space weather (Kp / Bz / X-flux) | NOAA SWPC | 1m / 3h | none | +| World events | Wikipedia ITN + GDELT v2 | 5m / 15m | none | +| ISS position + passes | wheretheiss.at + CelesTrak | 10s / 24h | none | +| IP enrichment (BGP) | AbuseIPDB | on-demand | `ABUSEIPDB_API_KEY` (optional) | ## Production (Cloudflare Tunnel) ```bash cp .env.example .env -# fill production secrets including CLOUDFLARE_TUNNEL_TOKEN -just tunnel-start +just prod-redeploy just migrate ``` @@ -71,11 +88,18 @@ just migrate cd backend && go test -race ./... ``` -## Layout +## Learn -``` -backend/ Go services (cmd/api, internal/{events,bus,ws,snapshot,collectors,...}) -frontend/ React 19 dashboard -conf/ nginx and per-environment Docker configs -migrations/ goose SQL migrations (mounted into the backend container) -``` +This project includes step-by-step learning materials covering security theory, architecture, and implementation. + +| Module | Topic | +|--------|-------| +| [00 - Overview](learn/00-OVERVIEW.md) | Prerequisites and quick start | +| [01 - Concepts](learn/01-CONCEPTS.md) | Threat intel feeds, BGP hijacks, EPSS, KEV, situational awareness theory | +| [02 - Architecture](learn/02-ARCHITECTURE.md) | Single-binary collector pipeline, in-process event bus, WebSocket fan-out | +| [03 - Implementation](learn/03-IMPLEMENTATION.md) | Code walkthrough across collectors, snapshot, ws, alerts | +| [04 - Challenges](learn/04-CHALLENGES.md) | Extension ideas (additional feeds, custom alerts, deployment) | + +## License + +AGPL 3.0 diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/.golangci.yml b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/.golangci.yml index 961b2028..b73984e2 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/.golangci.yml +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/.golangci.yml @@ -28,7 +28,12 @@ linters: settings: errcheck: check-type-assertions: true - check-blank: true + check-blank: false + exclude-functions: + - (io.Closer).Close + - (*github.com/coder/websocket.Conn).Close + - (*nhooyr.io/websocket.Conn).Close + - encoding/json.Marshal funlen: lines: 100 @@ -71,25 +76,67 @@ linters: gosec: excludes: - - G104 + - G104 # unhandled errors — covered by errcheck where actionable + - G101 # hardcoded credentials — false on env-var-name maps + HTTP header constants + - G117 # exported field name matches secret pattern — false on every Password/APIKey/Token DTO field + - G115 # int conversions — values are config-bounded, no realistic overflow + - G304 # file inclusion via variable — false on test fixtures + safe internal paths + - G704 # SSRF via taint — webhook senders POST to user-supplied URLs by design + - G706 # log injection — using slog structured fields, not concat + + testifylint: + disable: + - go-require # fires inside httptest handler closures; known false-positive pattern sloglint: no-mixed-args: true kv-only: true context: all -issues: - max-same-issues: 50 - exclude-dirs: - - vendor - - testdata - exclude-rules: - - path: _test\.go - linters: - - funlen - - dupl - - goconst + exclusions: + paths: + - vendor + - testdata + rules: + # test files: don't enforce production-grade hygiene on fixtures + mocks + - path: _test\.go + linters: + - funlen + - dupl + - goconst + - gocritic + - errcheck + - exhaustive + # bodyclose: coder/websocket Dial returns a *http.Response whose body + # is empty by protocol — closing it isn't part of the websocket idiom + - path: internal/ws/ + linters: + - bodyclose + + # bodyclose: ratelimit.New[*http.Response](...) is a generic type param, + # not an actual response body — linter pattern-matches the type symbol + - path: internal/httpx/client\.go + linters: + - bodyclose + + # main.go bootstrap is inherently long + branchy + - path: cmd/api/main\.go + linters: + - gocognit + - funlen + + # auth.Login vs auth.Register: structurally similar, semantically + # different (different DTOs, errors, status codes). Deduping with a + # generic helper would obscure the code more than the duplication does. + - path: internal/auth/handler\.go + linters: + - dupl + + # coinbase websocket state machine: handleConn is inherently branchy + - path: internal/collectors/coinbase/collector\.go + linters: + - gocognit formatters: enable: diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/cmd/api/main.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/cmd/api/main.go index 364dd9a3..15745f93 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/cmd/api/main.go +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/cmd/api/main.go @@ -8,6 +8,7 @@ import ( "errors" "flag" "log/slog" + "net/http" "os" "os/signal" "path/filepath" @@ -18,6 +19,7 @@ import ( "golang.org/x/sync/errgroup" "github.com/carterperez-dev/monitor-the-situation/backend/internal/admin" + "github.com/carterperez-dev/monitor-the-situation/backend/internal/alerts" "github.com/carterperez-dev/monitor-the-situation/backend/internal/auth" "github.com/carterperez-dev/monitor-the-situation/backend/internal/bus" "github.com/carterperez-dev/monitor-the-situation/backend/internal/collectors/cfradar" @@ -36,8 +38,11 @@ import ( "github.com/carterperez-dev/monitor-the-situation/backend/internal/config" "github.com/carterperez-dev/monitor-the-situation/backend/internal/core" "github.com/carterperez-dev/monitor-the-situation/backend/internal/enrich/abuseipdb" + "github.com/carterperez-dev/monitor-the-situation/backend/internal/enrich/greynoise" "github.com/carterperez-dev/monitor-the-situation/backend/internal/health" + "github.com/carterperez-dev/monitor-the-situation/backend/internal/intel" "github.com/carterperez-dev/monitor-the-situation/backend/internal/middleware" + "github.com/carterperez-dev/monitor-the-situation/backend/internal/notifications" "github.com/carterperez-dev/monitor-the-situation/backend/internal/redisring" "github.com/carterperez-dev/monitor-the-situation/backend/internal/server" "github.com/carterperez-dev/monitor-the-situation/backend/internal/snapshot" @@ -53,7 +58,10 @@ type abuseipdbEnricher struct { client *abuseipdb.Client } -func (a abuseipdbEnricher) Lookup(ctx context.Context, ip string) (cfradar.Enrichment, error) { +func (a abuseipdbEnricher) Lookup( + ctx context.Context, + ip string, +) (cfradar.Enrichment, error) { v, err := a.client.Lookup(ctx, ip) if err != nil { return cfradar.Enrichment{}, err @@ -65,16 +73,62 @@ func (a abuseipdbEnricher) Lookup(ctx context.Context, ip string) (cfradar.Enric }, nil } +type compositeDshieldEnricher struct { + abuse *abuseipdb.Client + gn *greynoise.Cached +} + +func (c compositeDshieldEnricher) Lookup( + ctx context.Context, + ip string, +) (dshield.DShieldEnrichment, error) { + out := dshield.DShieldEnrichment{} + if c.abuse != nil { + if v, err := c.abuse.Lookup(ctx, ip); err == nil { + out.Country = v.CountryCode + } + } + if c.gn != nil { + if v, err := c.gn.Lookup(ctx, ip); err == nil { + out.Classification = v.Classification + out.Actor = v.Name + } + } + return out, nil +} + func main() { configPath := flag.String("config", "config.yaml", "path to config file") + healthcheck := flag.Bool( + "healthcheck", + false, + "probe local /healthz and exit 0 if healthy, 1 otherwise", + ) flag.Parse() + if *healthcheck { + os.Exit(runHealthcheck()) + } + if err := run(*configPath); err != nil { slog.Error("application error", "error", err) os.Exit(1) } } +func runHealthcheck() int { + client := &http.Client{Timeout: 3 * time.Second} + resp, err := client.Get("http://127.0.0.1:8080/healthz") + if err != nil { + return 1 + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode != http.StatusOK { + return 1 + } + return 0 +} + //nolint:funlen // bootstrap code is inherently verbose func run(configPath string) error { ctx, stop := signal.NotifyContext( @@ -128,8 +182,8 @@ func run(configPath string) error { "pool_size", cfg.Redis.PoolSize, ) - if err := ensureJWTKeys(cfg.JWT, logger); err != nil { - return err + if keyErr := ensureJWTKeys(cfg.JWT, logger); keyErr != nil { + return keyErr } jwtManager, err := auth.NewJWTManager(cfg.JWT) @@ -146,8 +200,60 @@ func run(configPath string) error { userHandler := user.NewHandler(userSvc) authRepo := auth.NewRepository(db.DB) - authSvc := auth.NewService(authRepo, jwtManager, userSvc, redis.Client) - authHandler := auth.NewHandler(authSvc) + authSvc := auth.NewServiceWithConfig(auth.ServiceConfig{ + Repo: authRepo, + JWT: jwtManager, + UserProvider: userSvc, + Redis: redis.Client, + AdminEmail: cfg.App.AdminEmail, + }) + authVerifier := auth.NewVerifier(jwtManager, authSvc, userSvc) + authHandler := auth.NewHandlerWithConfig(auth.HandlerConfig{ + Service: authSvc, + TrustedProxyHops: cfg.Server.TrustedProxyHops, + SecureCookies: cfg.IsProduction(), + }) + + var notifHandler *notifications.Handler + var notifBridge *notifications.Bridge + if cfg.Notifications.EncryptionKey != "" { + enc, encErr := notifications.NewEncryptor( + cfg.Notifications.EncryptionKey, + ) + if encErr != nil { + logger.Warn( + "notifications disabled: invalid encryption key", + "error", encErr, + ) + } else { + notifRepo := notifications.NewRepository(db.DB) + notifSender := notifications.NewSender() + notifSvc := notifications.NewService( + notifRepo, + enc, + notifSender, + cfg.App.PublicURL, + logger.With("service", "notifications"), + ) + notifHandler = notifications.NewHandler(notifSvc) + notifBridge = notifications.NewBridge( + notifRepo, + enc, + notifSender, + logger.With("service", "alerts"), + ) + } + } else { + logger.Warn( + "notifications disabled: NOTIFICATION_ENCRYPTION_KEY not set", + ) + } + + alertsRepo := alerts.NewRepository(db.DB) + authSvc.SetRuleSeeder(func(ctx context.Context, userID string) error { + return alertsRepo.SeedDefaults(ctx, userID) + }) + alertsHandler := alerts.NewHandler(alertsRepo) healthHandler := health.NewHandler(db, redis) @@ -161,8 +267,28 @@ func run(configPath string) error { snapStore := snapshot.NewStore(redis.Client) snapHandler := snapshot.NewHandler(snapStore) - hub := ws.NewHub(ws.HubConfig{Logger: logger}) - wsHandler := ws.NewHandler(hub) + cveRepo := cve.NewRepo(db.DB) + kevRepo := kev.NewRepo(db.DB) + cfradarRepo := cfradar.NewRepo(db.DB) + ransomwareRepo := ransomware.NewRepo(db.DB) + usgsRepo := usgs.NewRepo(db.DB) + intelHandler := intel.NewHandler(intel.HandlerConfig{ + CVE: cveRepo, + KEV: kevRepo, + CFRadar: cfradarRepo, + Ransomware: ransomwareRepo, + USGS: usgsRepo, + }) + + hub := ws.NewHub(ws.HubConfig{ + Logger: logger, + MaxSubscribers: cfg.Server.WSMaxSubscribers, + }) + wsHandler := ws.NewHandlerWithConfig(ws.HandlerConfig{ + Hub: hub, + MaxConnsPerIP: cfg.Server.WSMaxConnsPerIP, + TrustedProxyHops: cfg.Server.TrustedProxyHops, + }) eventBus := bus.New(bus.Config{ BufferSize: 512, @@ -182,13 +308,66 @@ func run(configPath string) error { collectorGroup.Go(func() error { return eventBus.Run(collectorCtx) }) collectorGroup.Go(func() error { return beat.Run(collectorCtx) }) + if notifBridge != nil { + alertsEngine, err := alerts.NewEngine(alerts.EngineConfig{ + Repo: alertsRepo, + Notifier: notifBridge, + Loader: notifBridge, + Cooldowns: alerts.NewRedisCooldown(redis.Client), + Logger: logger.With("component", "alerts.engine"), + }) + if err != nil { + return err + } + dispatcher := alerts.NewDispatcher( + eventBus.Subscribe(), + alertsEngine, + logger.With("component", "alerts.dispatcher"), + ) + collectorGroup.Go( + func() error { return alertsEngine.RefreshLoop(collectorCtx) }, + ) + collectorGroup.Go(func() error { return dispatcher.Run(collectorCtx) }) + } else { + logger.Info( + "alerts engine disabled (notifications module not configured)", + ) + } + if cfg.Collectors.DShield.Enabled { + var dsEnricher dshield.Enricher + var abuseClient *abuseipdb.Client + var gnCached *greynoise.Cached + if cfg.Collectors.AbuseIPDB.Enabled && + cfg.Collectors.AbuseIPDB.APIKey != "" { + abuseClient = abuseipdb.NewClient( + abuseipdb.ClientConfig{APIKey: cfg.Collectors.AbuseIPDB.APIKey}, + ) + } + if cfg.Collectors.GreyNoise.Enabled && + cfg.Collectors.GreyNoise.APIKey != "" { + gnCached = greynoise.NewCached( + greynoise.NewClient( + greynoise.ClientConfig{ + APIKey: cfg.Collectors.GreyNoise.APIKey, + }, + ), + redis.Client, + ) + } + if abuseClient != nil || gnCached != nil { + dsEnricher = compositeDshieldEnricher{ + abuse: abuseClient, + gn: gnCached, + } + } coll := dshield.NewCollector(dshield.CollectorConfig{ Interval: cfg.Collectors.DShield.Interval, Fetcher: dshield.NewClient(dshield.ClientConfig{}), Persister: dshield.NewRepo(db.DB), Emitter: eventBus, State: collectorState, + Enricher: dsEnricher, Logger: logger.With("collector", "dshield"), }) collectorGroup.Go(func() error { return coll.Run(collectorCtx) }) @@ -196,20 +375,29 @@ func run(configPath string) error { if cfg.Collectors.CFRadar.Enabled { var enricher cfradar.Enricher - if cfg.Collectors.AbuseIPDB.Enabled && cfg.Collectors.AbuseIPDB.APIKey != "" { + if cfg.Collectors.AbuseIPDB.Enabled && + cfg.Collectors.AbuseIPDB.APIKey != "" { enricher = abuseipdbEnricher{ - client: abuseipdb.NewClient(abuseipdb.ClientConfig{APIKey: cfg.Collectors.AbuseIPDB.APIKey}), + client: abuseipdb.NewClient( + abuseipdb.ClientConfig{ + APIKey: cfg.Collectors.AbuseIPDB.APIKey, + }, + ), } } coll := cfradar.NewCollector(cfradar.CollectorConfig{ Interval: cfg.Collectors.CFRadar.Interval, MinConfidence: cfg.Collectors.CFRadar.MinConfidence, - Fetcher: cfradar.NewClient(cfradar.ClientConfig{BearerToken: cfg.Collectors.CFRadar.BearerToken}), - Repo: cfradar.NewRepo(db.DB), - Emitter: eventBus, - State: collectorState, - Enricher: enricher, - Logger: logger.With("collector", "cfradar"), + Fetcher: cfradar.NewClient( + cfradar.ClientConfig{ + BearerToken: cfg.Collectors.CFRadar.BearerToken, + }, + ), + Repo: cfradar.NewRepo(db.DB), + Emitter: eventBus, + State: collectorState, + Enricher: enricher, + Logger: logger.With("collector", "cfradar"), }) collectorGroup.Go(func() error { return coll.Run(collectorCtx) }) } @@ -218,12 +406,14 @@ func run(configPath string) error { coll := cve.NewCollector(cve.CollectorConfig{ Interval: cfg.Collectors.CVE.Interval, Window: cfg.Collectors.CVE.Window, - NVD: cve.NewNVDClient(cve.NVDClientConfig{APIKey: cfg.Collectors.CVE.NVDAPIKey}), - EPSS: cve.NewEPSSClient(cve.EPSSClientConfig{}), - Repo: cve.NewRepo(db.DB), - Emitter: eventBus, - State: collectorState, - Logger: logger.With("collector", "cve"), + NVD: cve.NewNVDClient( + cve.NVDClientConfig{APIKey: cfg.Collectors.CVE.NVDAPIKey}, + ), + EPSS: cve.NewEPSSClient(cve.EPSSClientConfig{}), + Repo: cve.NewRepo(db.DB), + Emitter: eventBus, + State: collectorState, + Logger: logger.With("collector", "cve"), }) collectorGroup.Go(func() error { return coll.Run(collectorCtx) }) } @@ -278,7 +468,10 @@ func run(configPath string) error { } if cfg.Collectors.SWPC.Enabled { - ring := redisring.New(redis.Client, redisring.Config{Retention: 24 * time.Hour}) + ring := redisring.New( + redis.Client, + redisring.Config{Retention: 24 * time.Hour}, + ) coll := swpc.NewCollector(swpc.CollectorConfig{ FastInterval: cfg.Collectors.SWPC.FastInterval, SlowInterval: cfg.Collectors.SWPC.SlowInterval, @@ -355,10 +548,12 @@ func run(configPath string) error { router.Use(middleware.Logger(logger)) router.Use( middleware.NewRateLimiter(redis.Client, middleware.RateLimitConfig{ - Limit: middleware.PerMinute( + Limit: middleware.PerWindow( cfg.RateLimit.Requests, cfg.RateLimit.Burst, + cfg.RateLimit.Window, ), + KeyFunc: middleware.KeyByClientIP(cfg.Server.TrustedProxyHops), FailOpen: true, }).Handler, ) @@ -369,7 +564,7 @@ func run(configPath string) error { router.Get("/.well-known/jwks.json", jwtManager.GetJWKSHandler()) - authenticator := middleware.Authenticator(jwtManager) + authenticator := middleware.Authenticator(authVerifier) adminOnly := middleware.RequireAdmin router.Route("/v1", func(r chi.Router) { @@ -379,13 +574,20 @@ func run(configPath string) error { r.Get("/snapshot", snapHandler.ServeHTTP) r.Get("/ws", wsHandler.ServeHTTP) - authHandler.RegisterRoutes(r, authenticator) + intelHandler.RegisterRoutes(r) - r.Post("/users", authHandler.Register) + authHandler.RegisterRoutes(r, authenticator) userHandler.RegisterRoutes(r, authenticator) userHandler.RegisterAdminRoutes(r, authenticator, adminOnly) adminHandler.RegisterRoutes(r, authenticator, adminOnly) + + if notifHandler != nil { + notifHandler.RegisterRoutes(r, authenticator) + } + if alertsHandler != nil { + alertsHandler.RegisterRoutes(r, authenticator) + } }) errChan := make(chan error, 1) diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/go.mod b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/go.mod index 906c0999..011b079b 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/go.mod +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/go.mod @@ -3,38 +3,46 @@ module github.com/carterperez-dev/monitor-the-situation/backend go 1.25.0 require ( + github.com/PuerkitoBio/goquery v1.12.0 + github.com/cenkalti/backoff/v4 v4.3.0 github.com/coder/websocket v1.8.14 github.com/go-chi/chi/v5 v5.2.3 github.com/go-playground/validator/v10 v10.23.0 github.com/go-redis/redis_rate/v10 v10.0.1 + github.com/google/cel-go v0.28.0 github.com/google/uuid v1.6.0 github.com/jackc/pgx/v5 v5.7.2 github.com/jmoiron/sqlx v1.4.0 + github.com/joshuaferrara/go-satellite v0.0.0-20220611180459-512638c64e5b github.com/knadh/koanf/parsers/yaml v1.1.0 github.com/knadh/koanf/providers/env v1.1.0 github.com/knadh/koanf/providers/file v1.2.1 github.com/knadh/koanf/v2 v2.1.2 github.com/lestrrat-go/jwx/v3 v3.0.12 + github.com/lib/pq v1.12.3 github.com/redis/go-redis/v9 v9.7.3 + github.com/shopspring/decimal v1.4.0 github.com/sony/gobreaker/v2 v2.4.0 github.com/stretchr/testify v1.11.1 + github.com/testcontainers/testcontainers-go/modules/postgres v0.42.0 github.com/testcontainers/testcontainers-go/modules/redis v0.42.0 go.opentelemetry.io/otel v1.41.0 go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.33.0 go.opentelemetry.io/otel/sdk v1.35.0 go.opentelemetry.io/otel/trace v1.41.0 golang.org/x/crypto v0.49.0 + golang.org/x/sync v0.20.0 golang.org/x/time v0.14.0 google.golang.org/grpc v1.68.1 ) require ( + cel.dev/expr v0.25.1 // indirect dario.cat/mergo v1.0.2 // indirect github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect github.com/Microsoft/go-winio v0.6.2 // indirect - github.com/PuerkitoBio/goquery v1.12.0 // indirect github.com/andybalholm/cascadia v1.3.3 // indirect - github.com/cenkalti/backoff/v4 v4.3.0 // indirect + github.com/antlr4-go/antlr/v4 v4.13.1 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/containerd/errdefs v1.0.0 // indirect github.com/containerd/errdefs/pkg v0.3.0 // indirect @@ -62,7 +70,6 @@ require ( github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect - github.com/joshuaferrara/go-satellite v0.0.0-20220611180459-512638c64e5b // indirect github.com/klauspost/compress v1.18.5 // indirect github.com/knadh/koanf/maps v0.1.2 // indirect github.com/leodido/go-urn v1.4.0 // indirect @@ -73,7 +80,6 @@ require ( github.com/lestrrat-go/httprc/v3 v3.0.1 // indirect github.com/lestrrat-go/option v1.0.1 // indirect github.com/lestrrat-go/option/v2 v2.0.0 // indirect - github.com/lib/pq v1.12.3 // indirect github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect github.com/magiconair/properties v1.8.10 // indirect github.com/mdelapenya/tlscert v0.2.0 // indirect @@ -95,10 +101,8 @@ require ( github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect github.com/segmentio/asm v1.2.1 // indirect github.com/shirou/gopsutil/v4 v4.26.3 // indirect - github.com/shopspring/decimal v1.4.0 // indirect github.com/sirupsen/logrus v1.9.4 // indirect github.com/testcontainers/testcontainers-go v0.42.0 // indirect - github.com/testcontainers/testcontainers-go/modules/postgres v0.42.0 // indirect github.com/tklauser/go-sysconf v0.3.16 // indirect github.com/tklauser/numcpus v0.11.0 // indirect github.com/valyala/fastjson v1.6.4 // indirect @@ -108,13 +112,13 @@ require ( go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.33.0 // indirect go.opentelemetry.io/otel/metric v1.41.0 // indirect go.opentelemetry.io/proto/otlp v1.4.0 // indirect - go.yaml.in/yaml/v3 v3.0.3 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/exp v0.0.0-20240823005443-9b4947da3948 // indirect golang.org/x/net v0.52.0 // indirect - golang.org/x/sync v0.20.0 // indirect golang.org/x/sys v0.42.0 // indirect golang.org/x/text v0.35.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20241209162323-e6fa225c2576 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20241209162323-e6fa225c2576 // indirect - google.golang.org/protobuf v1.36.8 // indirect + google.golang.org/protobuf v1.36.10 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/go.sum b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/go.sum index 376d0ed0..a9850706 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/go.sum +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/go.sum @@ -1,3 +1,5 @@ +cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4= +cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= @@ -12,6 +14,8 @@ github.com/PuerkitoBio/goquery v1.12.0 h1:pAcL4g3WRXekcB9AU/y1mbKez2dbY2AajVhtkO github.com/PuerkitoBio/goquery v1.12.0/go.mod h1:802ej+gV2y7bbIhOIoPY5sT183ZW0YFofScC4q/hIpQ= github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM= github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA= +github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ= +github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw= github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= @@ -82,6 +86,8 @@ github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA= github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/cel-go v0.28.0 h1:KjSWstCpz/MN5t4a8gnGJNIYUsJRpdi/r97xWDphIQc= +github.com/google/cel-go v0.28.0/go.mod h1:X0bD6iVNR8pkROSOoHVdgTkzmRcosof7WQqCD6wcMc8= github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= @@ -136,7 +142,6 @@ github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNB github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I= github.com/lestrrat-go/option/v2 v2.0.0 h1:XxrcaJESE1fokHy3FpaQ/cXW8ZsIdWcdFzzLOcID3Ss= github.com/lestrrat-go/option/v2 v2.0.0/go.mod h1:oSySsmzMoR0iRzCDCaUfsCzxQHUEuhOViQObyy7S6Vg= -github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/lib/pq v1.12.3 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ= github.com/lib/pq v1.12.3/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA= @@ -170,7 +175,9 @@ github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28= github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ= github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc= +github.com/onsi/ginkgo v1.2.1-0.20160509182050-5437a97bf824 h1:MbMqwlWoESqhGm4Sslfdyeq7Ww8R9ppeKS5DcO3xDI0= github.com/onsi/ginkgo v1.2.1-0.20160509182050-5437a97bf824/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/gomega v0.0.0-20160516222431-c73e51675ad2 h1:38zSYUaJJkzreBjLz7tx4AUTVjnFI7EQBnlRoWt4QFA= github.com/onsi/gomega v0.0.0-20160516222431-c73e51675ad2/go.mod h1:C1qb7wdrVGGVU+Z6iS04AVkA3Q65CEZX59MT0QO5uiA= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= @@ -242,18 +249,18 @@ go.opentelemetry.io/proto/otlp v1.4.0 h1:TA9WRvW6zMwP+Ssb6fLoUIuirti1gGbP28GcKG1 go.opentelemetry.io/proto/otlp v1.4.0/go.mod h1:PPBWZIP98o2ElSqI35IHfu7hIhSwvc5N38Jw8pXuGFY= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= -go.yaml.in/yaml/v3 v3.0.3 h1:bXOww4E/J3f66rav3pX3m8w6jDE4knZjGOw8b5Y6iNE= -go.yaml.in/yaml/v3 v3.0.3/go.mod h1:tBHosrYAkRZjRAOREWbDnBXUf08JOwYq++0QNwQiWzI= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= -golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= -golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= +golang.org/x/exp v0.0.0-20240823005443-9b4947da3948 h1:kx6Ds3MlpiUHKj7syVnbp57++8WpuKPcR5yjLBjvLEA= +golang.org/x/exp v0.0.0-20240823005443-9b4947da3948/go.mod h1:akd2r19cwCdwSwWeIdzYQGa/EZZyqcOdwWiwj5L5eKQ= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= @@ -268,8 +275,6 @@ golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= -golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= -golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -306,9 +311,8 @@ golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= -golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg= -golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM= golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU= +golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= @@ -318,8 +322,6 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= -golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= -golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= @@ -338,11 +340,12 @@ google.golang.org/genproto/googleapis/rpc v0.0.0-20241209162323-e6fa225c2576 h1: google.golang.org/genproto/googleapis/rpc v0.0.0-20241209162323-e6fa225c2576/go.mod h1:5uTbfoYQed2U9p3KIj2/Zzm02PYhndfdmML0qC3q3FU= google.golang.org/grpc v1.68.1 h1:oI5oTa11+ng8r8XMMN7jAOmWfPZWbYpCFaMUTACxkM0= google.golang.org/grpc v1.68.1/go.mod h1:+q1XYFJjShcqn0QZHvCyeR4CXPA+llXIeUIfIe00waw= -google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc= -google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= +google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= +google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v2 v2.0.0-20160301204022-a83829b6f129 h1:RBgb9aPUbZ9nu66ecQNIBNsA7j3mB5h8PNDIfhPjaJg= gopkg.in/yaml.v2 v2.0.0-20160301204022-a83829b6f129/go.mod h1:JAlM8MvJe8wmxCU4Bli9HhUf9+ttbYbLASfIpnQbh74= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/alerts/dispatcher.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/alerts/dispatcher.go new file mode 100644 index 00000000..8903bcb5 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/alerts/dispatcher.go @@ -0,0 +1,45 @@ +// ©AngelaMos | 2026 +// dispatcher.go + +package alerts + +import ( + "context" + "log/slog" + + "github.com/carterperez-dev/monitor-the-situation/backend/internal/events" +) + +// Dispatcher reads from a bus subscriber channel and pushes each event +// into the engine. Separate from the engine itself so the bus->engine +// wiring is testable in isolation. +type Dispatcher struct { + in <-chan events.Event + engine *Engine + logger *slog.Logger +} + +func NewDispatcher( + in <-chan events.Event, + engine *Engine, + logger *slog.Logger, +) *Dispatcher { + if logger == nil { + logger = slog.Default() + } + return &Dispatcher{in: in, engine: engine, logger: logger} +} + +func (d *Dispatcher) Run(ctx context.Context) error { + for { + select { + case <-ctx.Done(): + return ctx.Err() + case ev, ok := <-d.in: + if !ok { + return nil + } + d.engine.Evaluate(ctx, ev) + } + } +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/alerts/engine.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/alerts/engine.go new file mode 100644 index 00000000..3b0c84bb --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/alerts/engine.go @@ -0,0 +1,377 @@ +// ©AngelaMos | 2026 +// engine.go + +package alerts + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + "sync" + "sync/atomic" + "time" + + "github.com/google/cel-go/cel" + "github.com/google/cel-go/common/types" + "github.com/google/cel-go/common/types/ref" + "github.com/redis/go-redis/v9" + + "github.com/carterperez-dev/monitor-the-situation/backend/internal/events" +) + +// Channel is the abstract destination an alert is sent to. Slack/Discord +// webhooks and Telegram bots all reduce to "send this text via this +// transport". The engine doesn't care about the specific kind — that's +// the Notifier's job. +type Channel struct { + ID string + Type string + Label string + // Decoded config — for slack/discord this is the webhook URL, + // for telegram it's a (bot_token, chat_id) pair. The Notifier + // implementation handles the type-specific details. + WebhookURL string + BotToken string + ChatID int64 +} + +// Notifier is the transport for sending an alert. The notifications +// package implements one — this seam is here so tests can mock it. +type Notifier interface { + SendAlert(ctx context.Context, ch Channel, message string) error +} + +// ChannelLoader returns the configured destinations for a user. The +// notifications package decrypts channel configs on demand and produces +// these. Engine invokes it once per matching rule. +type ChannelLoader interface { + LoadChannels(ctx context.Context, userID string) ([]Channel, error) +} + +type CooldownStore interface { + TryAcquire(ctx context.Context, key string, ttl time.Duration) (bool, error) +} + +type EngineConfig struct { + Repo Repository + Notifier Notifier + Loader ChannelLoader + Cooldowns CooldownStore + Logger *slog.Logger + ReloadTick time.Duration +} + +type Engine struct { + repo Repository + notifier Notifier + loader ChannelLoader + cooldowns CooldownStore + logger *slog.Logger + reloadTick time.Duration + celEnv *cel.Env + rulesByTopic atomic.Pointer[map[string][]compiledRule] +} + +type compiledRule struct { + rule Rule + program cel.Program +} + +func NewEngine(cfg EngineConfig) (*Engine, error) { + logger := cfg.Logger + if logger == nil { + logger = slog.Default() + } + if cfg.ReloadTick <= 0 { + cfg.ReloadTick = 30 * time.Second + } + env, err := cel.NewEnv( + cel.Variable("event", cel.DynType), + ) + if err != nil { + return nil, fmt.Errorf("cel env: %w", err) + } + e := &Engine{ + repo: cfg.Repo, + notifier: cfg.Notifier, + loader: cfg.Loader, + cooldowns: cfg.Cooldowns, + logger: logger, + reloadTick: cfg.ReloadTick, + celEnv: env, + } + empty := map[string][]compiledRule{} + e.rulesByTopic.Store(&empty) + return e, nil +} + +// RefreshLoop reloads rules from the database periodically. Cheap — the +// table is small (one user × ~6 default rules). Hot path stays in-memory. +func (e *Engine) RefreshLoop(ctx context.Context) error { + if err := e.reload(ctx); err != nil { + e.logger.Warn("alerts: initial reload failed", "err", err) + } + t := time.NewTicker(e.reloadTick) + defer t.Stop() + for { + select { + case <-ctx.Done(): + return ctx.Err() + case <-t.C: + if err := e.reload(ctx); err != nil { + e.logger.Warn("alerts: reload failed", "err", err) + } + } + } +} + +func (e *Engine) reload(ctx context.Context) error { + all, err := e.repo.ListAll(ctx) + if err != nil { + return err + } + indexed := make(map[string][]compiledRule, len(all)) + for _, r := range all { + prog, err := e.compile(r.Predicate) + if err != nil { + e.logger.Warn("alerts: skipping rule with bad predicate", + "rule_id", r.ID, "topic", r.Topic, "err", err) + continue + } + indexed[r.Topic] = append( + indexed[r.Topic], + compiledRule{rule: r, program: prog}, + ) + } + e.rulesByTopic.Store(&indexed) + return nil +} + +func (e *Engine) compile(predicate string) (cel.Program, error) { + if predicate == "" { + // Empty predicate = always fire. Use a constant-true program so + // the eval path is uniform. + ast, iss := e.celEnv.Compile("true") + if iss != nil && iss.Err() != nil { + return nil, iss.Err() + } + return e.celEnv.Program(ast) + } + ast, iss := e.celEnv.Compile(predicate) + if iss != nil && iss.Err() != nil { + return nil, iss.Err() + } + return e.celEnv.Program(ast) +} + +// Evaluate is the per-event hot path. Looks up rules indexed by topic, +// evaluates each predicate against the event payload, then for matches +// loads the user's channels and dispatches via the notifier. Cooldowns +// are per (rule, channel) so a critical Telegram alert and a less- +// critical Discord alert for the same rule have independent windows. +func (e *Engine) Evaluate(ctx context.Context, ev events.Event) { + idx := e.rulesByTopic.Load() + if idx == nil { + return + } + rules, ok := (*idx)[string(ev.Topic)] + if !ok || len(rules) == 0 { + return + } + + payload, err := normalizePayload(ev.Payload) + if err != nil { + e.logger.Warn( + "alerts: payload normalize failed", + "topic", + ev.Topic, + "err", + err, + ) + return + } + + for _, cr := range rules { + if !cr.rule.Enabled { + continue + } + match, err := evalPredicate(cr.program, payload) + if err != nil { + e.logger.Warn("alerts: predicate eval failed", + "rule_id", cr.rule.ID, "topic", ev.Topic, "err", err) + continue + } + if !match { + continue + } + e.fire(ctx, cr.rule, ev, payload) + } +} + +func (e *Engine) fire( + ctx context.Context, + rule Rule, + ev events.Event, + payload map[string]any, +) { + channels, err := e.loader.LoadChannels(ctx, rule.UserID) + if err != nil { + e.logger.Warn("alerts: load channels failed", + "rule_id", rule.ID, "user_id", rule.UserID, "err", err) + return + } + if len(channels) == 0 { + return + } + cooldown := time.Duration(rule.CooldownSec) * time.Second + message := formatMessage(rule, ev, payload) + + delivered := []string{} + deliveryErrs := map[string]string{} + + var wg sync.WaitGroup + var mu sync.Mutex + + for _, ch := range channels { + ch := ch + key := fmt.Sprintf("alert_cooldown:%s:%s:%s", rule.ID, ch.Type, ch.ID) + ok, err := e.cooldowns.TryAcquire(ctx, key, cooldown) + if err != nil { + e.logger.Warn("alerts: cooldown lookup failed", + "rule_id", rule.ID, "channel", ch.ID, "err", err) + continue + } + if !ok { + continue + } + wg.Add(1) + go func() { + defer wg.Done() + err := e.notifier.SendAlert(ctx, ch, message) + mu.Lock() + defer mu.Unlock() + if err != nil { + deliveryErrs[ch.ID] = err.Error() + e.logger.Warn("alerts: deliver failed", + "rule_id", rule.ID, "channel_type", ch.Type, "err", err) + return + } + delivered = append(delivered, ch.ID) + }() + } + wg.Wait() + + body, _ := json.Marshal(payload) + errBody, _ := json.Marshal(deliveryErrs) + if err := e.repo.RecordFire(ctx, HistoryRow{ + RuleID: rule.ID, + UserID: rule.UserID, + FiredAt: time.Now().UTC(), + Payload: body, + DeliveredTo: delivered, + DeliveryErrors: errBody, + }); err != nil { + e.logger.Warn( + "alerts: record fire failed", + "rule_id", + rule.ID, + "err", + err, + ) + } +} + +func evalPredicate(program cel.Program, payload map[string]any) (bool, error) { + out, _, err := program.Eval(map[string]any{"event": payload}) + if err != nil { + return false, err + } + v, err := celBool(out) + if err != nil { + return false, err + } + return v, nil +} + +func celBool(v ref.Val) (bool, error) { + if v == nil { + return false, nil + } + switch v.Type() { + case types.BoolType: + b, ok := v.Value().(bool) + if !ok { + return false, fmt.Errorf( + "cel bool: unexpected concrete type %T", + v.Value(), + ) + } + return b, nil + default: + return false, fmt.Errorf("cel result is not bool: %v", v.Type()) + } +} + +// normalizePayload turns whatever the bus carries (map[string]any, +// json.RawMessage, struct, ...) into a map suitable for CEL. CEL needs +// a map for field access syntax like `event.kp` to work. +func normalizePayload(p any) (map[string]any, error) { + if p == nil { + return map[string]any{}, nil + } + if m, ok := p.(map[string]any); ok { + return m, nil + } + if raw, ok := p.(json.RawMessage); ok { + var out map[string]any + if err := json.Unmarshal(raw, &out); err != nil { + return nil, err + } + return out, nil + } + body, err := json.Marshal(p) + if err != nil { + return nil, err + } + var out map[string]any + if err := json.Unmarshal(body, &out); err != nil { + return nil, err + } + return out, nil +} + +func formatMessage(rule Rule, ev events.Event, payload map[string]any) string { + body, err := json.MarshalIndent(payload, "", " ") + if err != nil { + body = []byte(fmt.Sprintf("%v", payload)) + } + return fmt.Sprintf("[%s] %s\n\nTopic: %s\nFired: %s\n\n%s", + rule.Name, ev.Source, ev.Topic, ev.Timestamp.UTC().Format(time.RFC3339), + body, + ) +} + +// RedisCooldown is a default CooldownStore using SET NX EX. +type RedisCooldown struct { + rdb *redis.Client +} + +func NewRedisCooldown(rdb *redis.Client) *RedisCooldown { + return &RedisCooldown{rdb: rdb} +} + +func (c *RedisCooldown) TryAcquire( + ctx context.Context, + key string, + ttl time.Duration, +) (bool, error) { + if ttl <= 0 { + return true, nil + } + ok, err := c.rdb.SetNX(ctx, key, "1", ttl).Result() + if err != nil { + return false, err + } + return ok, nil +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/alerts/entity.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/alerts/entity.go new file mode 100644 index 00000000..bcf0aa71 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/alerts/entity.go @@ -0,0 +1,107 @@ +// ©AngelaMos | 2026 +// entity.go + +package alerts + +import ( + "encoding/json" + "time" +) + +// Rule mirrors the alert_rules table. Predicate is a CEL expression +// evaluated against {event: }. Empty predicate means +// "always fire" — used for default rules like "every KEV add". +type Rule struct { + ID string `db:"id"` + UserID string `db:"user_id"` + Name string `db:"name"` + Topic string `db:"topic"` + Predicate string `db:"predicate"` + CooldownSec int `db:"cooldown_sec"` + Enabled bool `db:"enabled"` + CreatedAt time.Time `db:"created_at"` + UpdatedAt time.Time `db:"updated_at"` +} + +// HistoryRow mirrors alert_history. Used both for delivery audit and so +// the UI can show "last fired". +type HistoryRow struct { + ID int64 `db:"id"` + RuleID string `db:"rule_id"` + UserID string `db:"user_id"` + FiredAt time.Time `db:"fired_at"` + Payload json.RawMessage `db:"payload"` + DeliveredTo []string `db:"delivered_to"` + DeliveryErrors json.RawMessage `db:"delivery_errors"` +} + +// DefaultRuleSeed is what registers as the bootstrap rule set for a new +// user. The predicates come straight from +// docs/specs/2026-05-01-monitor-the-situation-design.md. +type DefaultRuleSeed struct { + Name string + Topic string + Predicate string + CooldownSec int +} + +// Defaults returns the seed rules. KEV is the highest-priority alarm +// (per spec); CVE filters to severe items only; earthquake / space +// weather use the spec thresholds; ransomware fires on every entry. +// BTC / ETH ride on the `change_pct_1h` field the coinbase collector +// computes from a 1-hour sliding window — `has(...)` guards against +// firing during the warmup period when the window isn't full yet. +// Cooldowns are sized so a sustained crash doesn't spam — 30 min for +// the price drops; you can shorten in the settings UI per-rule. +func Defaults() []DefaultRuleSeed { + return []DefaultRuleSeed{ + { + Name: "KEV: every newly-added entry", + Topic: "kev_added", + Predicate: "", + CooldownSec: 0, + }, + { + Name: "CVE: critical (CVSS≥9 or EPSS≥0.95)", + Topic: "cve_new", + Predicate: "event.CVSS >= 9.0 || (has(event.EPSSPercentile) && event.EPSSPercentile != null && event.EPSSPercentile >= 0.95)", + CooldownSec: 300, + }, + { + Name: "Earthquake: M6.5+", + Topic: "earthquake", + Predicate: "event.properties.mag >= 6.5", + CooldownSec: 60, + }, + { + Name: "Space weather: Kp≥7 or X-class flare", + Topic: "space_weather", + Predicate: "(has(event.kp) && event.kp >= 7.0) || (has(event.xray_class) && event.xray_class.startsWith('X'))", + CooldownSec: 3600, + }, + { + Name: "Ransomware: every new victim", + Topic: "ransomware_victim", + Predicate: "", + CooldownSec: 60, + }, + { + Name: "BGP hijack: confidence≥9", + Topic: "bgp_hijack", + Predicate: "event.confidenceScore >= 9", + CooldownSec: 300, + }, + { + Name: "BTC: 1h drop ≥5%", + Topic: "coinbase_price", + Predicate: "event.symbol == 'BTC-USD' && has(event.change_pct_1h) && event.change_pct_1h <= -5.0", + CooldownSec: 1800, + }, + { + Name: "ETH: 1h drop ≥5%", + Topic: "coinbase_price", + Predicate: "event.symbol == 'ETH-USD' && has(event.change_pct_1h) && event.change_pct_1h <= -5.0", + CooldownSec: 1800, + }, + } +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/alerts/handler.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/alerts/handler.go new file mode 100644 index 00000000..325f0c9a --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/alerts/handler.go @@ -0,0 +1,196 @@ +// ©AngelaMos | 2026 +// handler.go + +package alerts + +import ( + "encoding/json" + "errors" + "net/http" + "strconv" + + "github.com/go-chi/chi/v5" + "github.com/go-playground/validator/v10" + + "github.com/carterperez-dev/monitor-the-situation/backend/internal/core" + "github.com/carterperez-dev/monitor-the-situation/backend/internal/middleware" +) + +type Handler struct { + repo Repository + validator *validator.Validate +} + +func NewHandler(repo Repository) *Handler { + return &Handler{ + repo: repo, + validator: validator.New(validator.WithRequiredStructEnabled()), + } +} + +func (h *Handler) RegisterRoutes( + r chi.Router, + authenticator func(http.Handler) http.Handler, +) { + r.Route("/me/alerts", func(r chi.Router) { + r.Use(authenticator) + + r.Get("/", h.list) + r.Post("/", h.create) + r.Patch("/{id}", h.update) + r.Delete("/{id}", h.delete) + + r.Get("/history", h.history) + }) +} + +type ruleDTO struct { + ID string `json:"id"` + Name string `json:"name"` + Topic string `json:"topic"` + Predicate string `json:"predicate"` + CooldownSec int `json:"cooldown_sec"` + Enabled bool `json:"enabled"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +func toRuleDTO(r Rule) ruleDTO { + return ruleDTO{ + ID: r.ID, + Name: r.Name, + Topic: r.Topic, + Predicate: r.Predicate, + CooldownSec: r.CooldownSec, + Enabled: r.Enabled, + CreatedAt: r.CreatedAt.UTC().Format("2006-01-02T15:04:05Z"), + UpdatedAt: r.UpdatedAt.UTC().Format("2006-01-02T15:04:05Z"), + } +} + +func (h *Handler) list(w http.ResponseWriter, r *http.Request) { + userID := middleware.GetUserID(r.Context()) + rules, err := h.repo.ListByUser(r.Context(), userID) + if err != nil { + core.InternalServerError(w, err) + return + } + out := make([]ruleDTO, 0, len(rules)) + for _, ru := range rules { + out = append(out, toRuleDTO(ru)) + } + core.OK(w, out) +} + +type createRuleRequest struct { + Name string `json:"name" validate:"required,min=1,max=200"` + Topic string `json:"topic" validate:"required,min=1,max=64"` + Predicate string `json:"predicate" validate:"omitempty,max=2048"` + CooldownSec int `json:"cooldown_sec" validate:"gte=0,lte=86400"` + Enabled bool `json:"enabled"` +} + +func (h *Handler) create(w http.ResponseWriter, r *http.Request) { + userID := middleware.GetUserID(r.Context()) + var req createRuleRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + core.BadRequest(w, "invalid request body") + return + } + if err := h.validator.Struct(req); err != nil { + core.BadRequest(w, core.FormatValidationError(err)) + return + } + rule := &Rule{ + UserID: userID, + Name: req.Name, + Topic: req.Topic, + Predicate: req.Predicate, + CooldownSec: req.CooldownSec, + Enabled: req.Enabled, + } + if err := h.repo.Create(r.Context(), rule); err != nil { + core.InternalServerError(w, err) + return + } + core.Created(w, toRuleDTO(*rule)) +} + +type updateRuleRequest struct { + Name *string `json:"name,omitempty" validate:"omitempty,min=1,max=200"` + Predicate *string `json:"predicate,omitempty" validate:"omitempty,max=2048"` + CooldownSec *int `json:"cooldown_sec,omitempty" validate:"omitempty,gte=0,lte=86400"` + Enabled *bool `json:"enabled,omitempty"` +} + +func (h *Handler) update(w http.ResponseWriter, r *http.Request) { + userID := middleware.GetUserID(r.Context()) + id := chi.URLParam(r, "id") + + var req updateRuleRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + core.BadRequest(w, "invalid request body") + return + } + if err := h.validator.Struct(req); err != nil { + core.BadRequest(w, core.FormatValidationError(err)) + return + } + + rule, err := h.repo.Get(r.Context(), id, userID) + if err != nil { + if errors.Is(err, core.ErrNotFound) { + core.NotFound(w, "rule") + return + } + core.InternalServerError(w, err) + return + } + if req.Name != nil { + rule.Name = *req.Name + } + if req.Predicate != nil { + rule.Predicate = *req.Predicate + } + if req.CooldownSec != nil { + rule.CooldownSec = *req.CooldownSec + } + if req.Enabled != nil { + rule.Enabled = *req.Enabled + } + if err := h.repo.Update(r.Context(), rule); err != nil { + core.InternalServerError(w, err) + return + } + core.OK(w, toRuleDTO(*rule)) +} + +func (h *Handler) delete(w http.ResponseWriter, r *http.Request) { + userID := middleware.GetUserID(r.Context()) + id := chi.URLParam(r, "id") + if err := h.repo.Delete(r.Context(), id, userID); err != nil { + if errors.Is(err, core.ErrNotFound) { + core.NotFound(w, "rule") + return + } + core.InternalServerError(w, err) + return + } + core.NoContent(w) +} + +func (h *Handler) history(w http.ResponseWriter, r *http.Request) { + userID := middleware.GetUserID(r.Context()) + limit := 50 + if v := r.URL.Query().Get("limit"); v != "" { + if n, err := strconv.Atoi(v); err == nil && n > 0 && n <= 500 { + limit = n + } + } + rows, err := h.repo.RecentHistory(r.Context(), userID, limit) + if err != nil { + core.InternalServerError(w, err) + return + } + core.OK(w, rows) +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/alerts/repository.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/alerts/repository.go new file mode 100644 index 00000000..9c54569a --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/alerts/repository.go @@ -0,0 +1,239 @@ +// ©AngelaMos | 2026 +// repository.go + +package alerts + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "time" + + "github.com/google/uuid" + "github.com/lib/pq" + + "github.com/carterperez-dev/monitor-the-situation/backend/internal/core" +) + +type Repository interface { + ListAll(ctx context.Context) ([]Rule, error) + ListByUser(ctx context.Context, userID string) ([]Rule, error) + Get(ctx context.Context, id, userID string) (*Rule, error) + Create(ctx context.Context, r *Rule) error + Update(ctx context.Context, r *Rule) error + Delete(ctx context.Context, id, userID string) error + SeedDefaults(ctx context.Context, userID string) error + RecordFire(ctx context.Context, h HistoryRow) error + RecentHistory( + ctx context.Context, + userID string, + limit int, + ) ([]HistoryRow, error) +} + +type repository struct { + db core.DBTX +} + +func NewRepository(db core.DBTX) Repository { + return &repository{db: db} +} + +func (r *repository) ListAll(ctx context.Context) ([]Rule, error) { + var rules []Rule + err := r.db.SelectContext(ctx, &rules, ` + SELECT id, user_id, name, topic, predicate, cooldown_sec, enabled, created_at, updated_at + FROM alert_rules + WHERE enabled = true`) + if err != nil { + return nil, fmt.Errorf("alerts list all: %w", err) + } + return rules, nil +} + +func (r *repository) ListByUser( + ctx context.Context, + userID string, +) ([]Rule, error) { + var rules []Rule + err := r.db.SelectContext(ctx, &rules, ` + SELECT id, user_id, name, topic, predicate, cooldown_sec, enabled, created_at, updated_at + FROM alert_rules + WHERE user_id = $1 + ORDER BY topic, name`, userID) + if err != nil { + return nil, fmt.Errorf("alerts list user: %w", err) + } + return rules, nil +} + +func (r *repository) Get( + ctx context.Context, + id, userID string, +) (*Rule, error) { + var rule Rule + err := r.db.GetContext(ctx, &rule, ` + SELECT id, user_id, name, topic, predicate, cooldown_sec, enabled, created_at, updated_at + FROM alert_rules + WHERE id = $1 AND user_id = $2`, id, userID) + if errors.Is(err, sql.ErrNoRows) { + return nil, fmt.Errorf("alerts get: %w", core.ErrNotFound) + } + if err != nil { + return nil, fmt.Errorf("alerts get: %w", err) + } + return &rule, nil +} + +func (r *repository) Create(ctx context.Context, rule *Rule) error { + if rule.ID == "" { + rule.ID = uuid.New().String() + } + err := r.db.GetContext( + ctx, + &rule.CreatedAt, + ` + INSERT INTO alert_rules (id, user_id, name, topic, predicate, cooldown_sec, enabled) + VALUES ($1, $2, $3, $4, $5, $6, $7) + RETURNING created_at`, + rule.ID, + rule.UserID, + rule.Name, + rule.Topic, + rule.Predicate, + rule.CooldownSec, + rule.Enabled, + ) + if err != nil { + return fmt.Errorf("alerts create: %w", err) + } + rule.UpdatedAt = rule.CreatedAt + return nil +} + +func (r *repository) Update(ctx context.Context, rule *Rule) error { + res, err := r.db.ExecContext( + ctx, + ` + UPDATE alert_rules + SET name = $3, + predicate = $4, + cooldown_sec = $5, + enabled = $6, + updated_at = NOW() + WHERE id = $1 AND user_id = $2`, + rule.ID, + rule.UserID, + rule.Name, + rule.Predicate, + rule.CooldownSec, + rule.Enabled, + ) + if err != nil { + return fmt.Errorf("alerts update: %w", err) + } + rows, err := res.RowsAffected() + if err != nil { + return fmt.Errorf("alerts update: %w", err) + } + if rows == 0 { + return fmt.Errorf("alerts update: %w", core.ErrNotFound) + } + return nil +} + +func (r *repository) Delete(ctx context.Context, id, userID string) error { + res, err := r.db.ExecContext(ctx, + `DELETE FROM alert_rules WHERE id = $1 AND user_id = $2`, id, userID) + if err != nil { + return fmt.Errorf("alerts delete: %w", err) + } + rows, err := res.RowsAffected() + if err != nil { + return fmt.Errorf("alerts delete: %w", err) + } + if rows == 0 { + return fmt.Errorf("alerts delete: %w", core.ErrNotFound) + } + return nil +} + +// SeedDefaults inserts the default rule set for a user, skipping rules +// they already have. Dedup is by (user_id, name) — not (user_id, topic) — +// so multiple defaults can target the same topic. BTC and ETH both fire +// on `coinbase_price`, and the original (user_id, topic) check would +// have inserted only the first one. Idempotent: safe to call on every +// login, which is how existing accounts pick up newly-added defaults. +func (r *repository) SeedDefaults(ctx context.Context, userID string) error { + for _, def := range Defaults() { + _, err := r.db.ExecContext( + ctx, + ` + INSERT INTO alert_rules (id, user_id, name, topic, predicate, cooldown_sec, enabled) + SELECT $1, $2, $3, $4, $5, $6, true + WHERE NOT EXISTS ( + SELECT 1 FROM alert_rules WHERE user_id = $2 AND name = $3 + )`, + uuid.New(). + String(), + userID, + def.Name, + def.Topic, + def.Predicate, + def.CooldownSec, + ) + if err != nil { + return fmt.Errorf("seed default %s: %w", def.Name, err) + } + } + return nil +} + +func (r *repository) RecordFire(ctx context.Context, h HistoryRow) error { + if h.FiredAt.IsZero() { + h.FiredAt = time.Now().UTC() + } + if len(h.Payload) == 0 { + h.Payload = json.RawMessage("{}") + } + delivered := pq.StringArray(h.DeliveredTo) + _, err := r.db.ExecContext( + ctx, + ` + INSERT INTO alert_history (rule_id, user_id, fired_at, payload, delivered_to, delivery_errors) + VALUES ($1, $2, $3, $4, $5, $6)`, + h.RuleID, + h.UserID, + h.FiredAt, + []byte(h.Payload), + delivered, + []byte(h.DeliveryErrors), + ) + if err != nil { + return fmt.Errorf("alerts record fire: %w", err) + } + return nil +} + +func (r *repository) RecentHistory( + ctx context.Context, + userID string, + limit int, +) ([]HistoryRow, error) { + if limit <= 0 { + limit = 50 + } + var rows []HistoryRow + err := r.db.SelectContext(ctx, &rows, ` + SELECT id, rule_id, user_id, fired_at, payload, delivered_to, delivery_errors + FROM alert_history + WHERE user_id = $1 + ORDER BY fired_at DESC + LIMIT $2`, userID, limit) + if err != nil { + return nil, fmt.Errorf("alerts recent history: %w", err) + } + return rows, nil +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/auth/dto.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/auth/dto.go index 9fc1cfa1..4d3c36b2 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/auth/dto.go +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/auth/dto.go @@ -19,7 +19,7 @@ type RegisterRequest struct { } type RefreshRequest struct { - RefreshToken string `json:"refresh_token" validate:"required"` + RefreshToken string `json:"refresh_token,omitempty"` } type TokenResponse struct { diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/auth/handler.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/auth/handler.go index c8e5e46c..e5c39bec 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/auth/handler.go +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/auth/handler.go @@ -6,9 +6,8 @@ package auth import ( "encoding/json" "errors" - "net" "net/http" - "strings" + "time" "github.com/go-chi/chi/v5" "github.com/go-playground/validator/v10" @@ -17,18 +16,79 @@ import ( "github.com/carterperez-dev/monitor-the-situation/backend/internal/middleware" ) +const ( + refreshCookieName = "mts_rt" + refreshCookiePath = "/api/v1/auth" + refreshCookieMaxAge = 7 * 24 * time.Hour +) + type Handler struct { - service *Service - validator *validator.Validate + service *Service + validator *validator.Validate + trustedProxyHops int + secureCookies bool } -func NewHandler(service *Service) *Handler { +type HandlerConfig struct { + Service *Service + TrustedProxyHops int + SecureCookies bool +} + +func NewHandler(service *Service, trustedProxyHops int) *Handler { + return NewHandlerWithConfig(HandlerConfig{ + Service: service, + TrustedProxyHops: trustedProxyHops, + }) +} + +func NewHandlerWithConfig(cfg HandlerConfig) *Handler { return &Handler{ - service: service, - validator: validator.New(validator.WithRequiredStructEnabled()), + service: cfg.Service, + validator: validator.New(validator.WithRequiredStructEnabled()), + trustedProxyHops: cfg.TrustedProxyHops, + secureCookies: cfg.SecureCookies, } } +func (h *Handler) setRefreshCookie( + w http.ResponseWriter, + token string, + expiresAt time.Time, +) { + http.SetCookie(w, &http.Cookie{ + Name: refreshCookieName, + Value: token, + Path: refreshCookiePath, + HttpOnly: true, + Secure: h.secureCookies, + SameSite: http.SameSiteStrictMode, + Expires: expiresAt, + }) +} + +func (h *Handler) clearRefreshCookie(w http.ResponseWriter) { + http.SetCookie(w, &http.Cookie{ + Name: refreshCookieName, + Value: "", + Path: refreshCookiePath, + HttpOnly: true, + Secure: h.secureCookies, + SameSite: http.SameSiteStrictMode, + MaxAge: -1, + }) +} + +func (h *Handler) refreshTokenFromRequest(r *http.Request, body string) string { + if body != "" { + return body + } + if c, err := r.Cookie(refreshCookieName); err == nil { + return c.Value + } + return "" +} + func (h *Handler) RegisterRoutes( r chi.Router, authenticator func(http.Handler) http.Handler, @@ -63,7 +123,7 @@ func (h *Handler) Login(w http.ResponseWriter, r *http.Request) { } userAgent := r.UserAgent() - ipAddress := extractIPAddress(r) + ipAddress := h.extractIPAddress(r) resp, err := h.service.Login(r.Context(), req, userAgent, ipAddress) if err != nil { @@ -78,6 +138,11 @@ func (h *Handler) Login(w http.ResponseWriter, r *http.Request) { return } + h.setRefreshCookie( + w, + resp.Tokens.RefreshToken, + time.Now().Add(refreshCookieMaxAge), + ) core.OK(w, resp) } @@ -94,7 +159,7 @@ func (h *Handler) Register(w http.ResponseWriter, r *http.Request) { } userAgent := r.UserAgent() - ipAddress := extractIPAddress(r) + ipAddress := h.extractIPAddress(r) resp, err := h.service.Register(r.Context(), req, userAgent, ipAddress) if err != nil { @@ -106,27 +171,33 @@ func (h *Handler) Register(w http.ResponseWriter, r *http.Request) { return } + h.setRefreshCookie( + w, + resp.Tokens.RefreshToken, + time.Now().Add(refreshCookieMaxAge), + ) core.Created(w, resp) } func (h *Handler) Refresh(w http.ResponseWriter, r *http.Request) { var req RefreshRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - core.BadRequest(w, "invalid request body") - return + // Body may be empty when frontend uses cookie-only flow. + req = RefreshRequest{} } - if err := h.validator.Struct(req); err != nil { - core.BadRequest(w, core.FormatValidationError(err)) + refreshToken := h.refreshTokenFromRequest(r, req.RefreshToken) + if refreshToken == "" { + core.BadRequest(w, "refresh_token required (cookie or body)") return } userAgent := r.UserAgent() - ipAddress := extractIPAddress(r) + ipAddress := h.extractIPAddress(r) resp, err := h.service.Refresh( r.Context(), - req.RefreshToken, + refreshToken, userAgent, ipAddress, ) @@ -156,6 +227,11 @@ func (h *Handler) Refresh(w http.ResponseWriter, r *http.Request) { return } + h.setRefreshCookie( + w, + resp.Tokens.RefreshToken, + time.Now().Add(refreshCookieMaxAge), + ) core.OK(w, resp) } @@ -168,11 +244,24 @@ func (h *Handler) Logout(w http.ResponseWriter, r *http.Request) { var req RefreshRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - core.BadRequest(w, "invalid request body") - return + req = RefreshRequest{} + } + refreshToken := h.refreshTokenFromRequest(r, req.RefreshToken) + + var jti string + var expiresAt time.Time + if claims := middleware.GetClaims(r.Context()); claims != nil { + jti = claims.JTI + expiresAt = claims.ExpiresAt } - if err := h.service.Logout(r.Context(), req.RefreshToken, userID); err != nil { + if err := h.service.Logout( + r.Context(), + refreshToken, + userID, + jti, + expiresAt, + ); err != nil { if errors.Is(err, core.ErrForbidden) { core.Forbidden(w, "cannot revoke another user's token") return @@ -181,6 +270,7 @@ func (h *Handler) Logout(w http.ResponseWriter, r *http.Request) { return } + h.clearRefreshCookie(w) core.NoContent(w) } @@ -228,7 +318,11 @@ func (h *Handler) RevokeSession(w http.ResponseWriter, r *http.Request) { return } - if err := h.service.RevokeSession(r.Context(), userID, sessionID); err != nil { + if err := h.service.RevokeSession( + r.Context(), + userID, + sessionID, + ); err != nil { if errors.Is(err, core.ErrNotFound) { core.NotFound(w, "session") return @@ -262,7 +356,12 @@ func (h *Handler) ChangePassword(w http.ResponseWriter, r *http.Request) { return } - if err := h.service.ChangePassword(r.Context(), userID, req.CurrentPassword, req.NewPassword); err != nil { + if err := h.service.ChangePassword( + r.Context(), + userID, + req.CurrentPassword, + req.NewPassword, + ); err != nil { if errors.Is(err, ErrInvalidCredentials) { core.JSONError( w, @@ -297,20 +396,6 @@ func (h *Handler) GetMe(w http.ResponseWriter, r *http.Request) { core.OK(w, user) } -func extractIPAddress(r *http.Request) string { - if xff := r.Header.Get("X-Forwarded-For"); xff != "" { - ips := strings.Split(xff, ",") - return strings.TrimSpace(ips[len(ips)-1]) - } - - if xri := r.Header.Get("X-Real-IP"); xri != "" { - return xri - } - - ip, _, err := net.SplitHostPort(r.RemoteAddr) - if err != nil { - return r.RemoteAddr - } - - return ip +func (h *Handler) extractIPAddress(r *http.Request) string { + return middleware.ClientIP(r, h.trustedProxyHops) } diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/auth/jwt.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/auth/jwt.go index d5774835..9ab73cc7 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/auth/jwt.go +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/auth/jwt.go @@ -5,10 +5,14 @@ package auth import ( "context" + "crypto" "crypto/ecdsa" "crypto/elliptic" "crypto/rand" + _ "crypto/sha256" + "encoding/hex" "encoding/json" + "errors" "fmt" "net/http" "os" @@ -25,6 +29,8 @@ import ( "github.com/carterperez-dev/monitor-the-situation/backend/internal/middleware" ) +const kidLength = 8 + type JWTManager struct { privateKey jwk.Key publicKey jwk.Key @@ -47,16 +53,22 @@ func NewJWTManager(cfg config.JWTConfig) (*JWTManager, error) { return nil, fmt.Errorf("set algorithm: %w", setErr) } - keyID := uuid.New().String()[:8] - if setErr := privateKey.Set(jwk.KeyIDKey, keyID); setErr != nil { - return nil, fmt.Errorf("set key id: %w", setErr) - } - publicKey, err := privateKey.PublicKey() if err != nil { return nil, fmt.Errorf("derive public key: %w", err) } + keyID, err := deriveKeyID(publicKey) + if err != nil { + return nil, fmt.Errorf("derive key id: %w", err) + } + if setErr := privateKey.Set(jwk.KeyIDKey, keyID); setErr != nil { + return nil, fmt.Errorf("set key id: %w", setErr) + } + if setErr := publicKey.Set(jwk.KeyIDKey, keyID); setErr != nil { + return nil, fmt.Errorf("set public key id: %w", setErr) + } + if setErr := publicKey.Set(jwk.KeyUsageKey, "sig"); setErr != nil { return nil, fmt.Errorf("set key usage: %w", setErr) } @@ -85,10 +97,6 @@ func GenerateKeyPair(privateKeyPath, publicKeyPath string) error { return fmt.Errorf("import private key: %w", err) } - keyID := uuid.New().String()[:8] - if setErr := jwkPrivate.Set(jwk.KeyIDKey, keyID); setErr != nil { - return fmt.Errorf("set key id: %w", setErr) - } if setErr := jwkPrivate.Set(jwk.AlgorithmKey, jwa.ES256()); setErr != nil { return fmt.Errorf("set algorithm: %w", setErr) } @@ -98,7 +106,11 @@ func GenerateKeyPair(privateKeyPath, publicKeyPath string) error { return fmt.Errorf("encode private key: %w", err) } - if writeErr := os.WriteFile(privateKeyPath, privatePEM, 0o600); writeErr != nil { + if writeErr := os.WriteFile( + privateKeyPath, + privatePEM, + 0o600, + ); writeErr != nil { return fmt.Errorf("write private key: %w", writeErr) } @@ -113,7 +125,11 @@ func GenerateKeyPair(privateKeyPath, publicKeyPath string) error { } //nolint:gosec // G306: public key is intentionally world-readable - if writeErr := os.WriteFile(publicKeyPath, publicPEM, 0o644); writeErr != nil { + if writeErr := os.WriteFile( + publicKeyPath, + publicPEM, + 0o644, + ); writeErr != nil { return fmt.Errorf("write public key: %w", writeErr) } @@ -216,11 +232,16 @@ func (m *JWTManager) VerifyAccessToken( ) } + jti, _ := token.JwtID() + expiresAt, _ := token.Expiration() + return &middleware.AccessTokenClaims{ UserID: subject, Role: roleStr, Tier: tierStr, TokenVersion: int(versionFloat), + JTI: jti, + ExpiresAt: expiresAt, }, nil } @@ -228,11 +249,22 @@ func isTokenExpiredError(err error) bool { if err == nil { return false } + if errors.Is(err, jwt.TokenExpiredError()) { + return true + } errStr := err.Error() return strings.Contains(errStr, "exp") && strings.Contains(errStr, "not satisfied") } +func deriveKeyID(publicKey jwk.Key) (string, error) { + thumb, err := publicKey.Thumbprint(crypto.SHA256) + if err != nil { + return "", fmt.Errorf("thumbprint: %w", err) + } + return hex.EncodeToString(thumb)[:kidLength], nil +} + func (m *JWTManager) GetJWKSHandler() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/auth/service.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/auth/service.go index 3762724e..c9a1418d 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/auth/service.go +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/auth/service.go @@ -7,6 +7,7 @@ import ( "context" "errors" "fmt" + "strings" "time" "github.com/google/uuid" @@ -40,14 +41,39 @@ type UserProvider interface { ) (*UserInfo, error) IncrementTokenVersion(ctx context.Context, userID string) error UpdatePassword(ctx context.Context, userID, passwordHash string) error + SetRole(ctx context.Context, userID, role string) error } +// RuleSeeder is called after a fresh user registers, to populate +// default alert rules. Wired by main.go using alerts.Repo.SeedDefaults. +// Optional — auth works without it; user just won't get alerts until +// they create rules manually. +type RuleSeeder func(ctx context.Context, userID string) error + type Service struct { repo Repository jwt *JWTManager userProvider UserProvider redis *redis.Client blacklistTTL time.Duration + adminEmail string + ruleSeeder RuleSeeder +} + +// SetRuleSeeder registers a callback fired after Register succeeds. +// Called from main.go after both auth.Service and alerts.Repo are +// constructed; resolves the cyclic dependency (auth doesn't import +// alerts, alerts doesn't import auth). +func (s *Service) SetRuleSeeder(fn RuleSeeder) { + s.ruleSeeder = fn +} + +type ServiceConfig struct { + Repo Repository + JWT *JWTManager + UserProvider UserProvider + Redis *redis.Client + AdminEmail string } func NewService( @@ -56,15 +82,46 @@ func NewService( userProvider UserProvider, redisClient *redis.Client, ) *Service { + return NewServiceWithConfig(ServiceConfig{ + Repo: repo, + JWT: jwt, + UserProvider: userProvider, + Redis: redisClient, + }) +} + +func NewServiceWithConfig(cfg ServiceConfig) *Service { return &Service{ - repo: repo, - jwt: jwt, - userProvider: userProvider, - redis: redisClient, + repo: cfg.Repo, + jwt: cfg.JWT, + userProvider: cfg.UserProvider, + redis: cfg.Redis, blacklistTTL: 15 * time.Minute, + adminEmail: strings.ToLower(strings.TrimSpace(cfg.AdminEmail)), } } +// promoteIfAdminEmail flips role to admin when the user's email matches +// the configured ADMIN_EMAIL. Idempotent — does nothing if already admin +// or if no admin email is configured. Mutates `user` in place so the +// returned auth response reflects the new role. +func (s *Service) promoteIfAdminEmail(ctx context.Context, user *UserInfo) { + if s.adminEmail == "" || user == nil { + return + } + if !strings.EqualFold(user.Email, s.adminEmail) { + return + } + if user.Role == "admin" { + return + } + if err := s.userProvider.SetRole(ctx, user.ID, "admin"); err != nil { + // Best-effort. Logging is the caller's job. + return + } + user.Role = "admin" +} + func (s *Service) Login( ctx context.Context, req LoginRequest, @@ -97,6 +154,15 @@ func (s *Service) Login( _ = s.userProvider.UpdatePassword(ctx, user.ID, newHash) } + s.promoteIfAdminEmail(ctx, user) + + if s.ruleSeeder != nil { + // Idempotent. Cheap (one INSERT-WHERE-NOT-EXISTS per default + // rule) and gives existing accounts new defaults the moment we + // ship them, without needing a one-off migration. + _ = s.ruleSeeder(ctx, user.ID) + } + return s.createAuthResponse(ctx, user, userAgent, ipAddress, "", nil) } @@ -118,6 +184,14 @@ func (s *Service) Register( return nil, fmt.Errorf("create user: %w", err) } + s.promoteIfAdminEmail(ctx, user) + + if s.ruleSeeder != nil { + // Best-effort. Don't block registration if seeding fails — the + // user can configure rules manually. + _ = s.ruleSeeder(ctx, user.ID) + } + return s.createAuthResponse(ctx, user, userAgent, ipAddress, "", nil) } @@ -165,13 +239,25 @@ func (s *Service) Refresh( func (s *Service) Logout( ctx context.Context, - refreshToken, userID string, + refreshToken, userID, accessJTI string, + accessExpiresAt time.Time, ) error { tokenHash := core.HashToken(refreshToken) storedToken, err := s.repo.FindByHash(ctx, tokenHash) if err != nil { if errors.Is(err, core.ErrNotFound) { + // Refresh token already gone — still blacklist the access JTI + // so the current session can't make authenticated calls. + if accessJTI != "" { + if revErr := s.RevokeAccessToken( + ctx, + accessJTI, + accessExpiresAt, + ); revErr != nil { + return fmt.Errorf("blacklist access token: %w", revErr) + } + } return nil } return fmt.Errorf("find token: %w", err) @@ -186,6 +272,16 @@ func (s *Service) Logout( return fmt.Errorf("revoke token: %w", err) } + if accessJTI != "" { + if err := s.RevokeAccessToken( + ctx, + accessJTI, + accessExpiresAt, + ); err != nil { + return fmt.Errorf("blacklist access token: %w", err) + } + } + return nil } @@ -372,6 +468,29 @@ func (s *Service) createAuthResponse( newTokenID := uuid.New().String() + // Claim the old token BEFORE inserting the new one. Two concurrent + // refresh attempts with the same token race on this UPDATE; only one + // flips is_used=false→true. The loser sees ErrNotFound and returns + // ErrTokenReuse without ever creating a new refresh row, which keeps + // the rotation chain single-use. + if oldTokenID != nil { + if err := s.repo.MarkAsUsed(ctx, *oldTokenID, newTokenID); err != nil { + if errors.Is(err, core.ErrNotFound) { + if revErr := s.repo.RevokeByFamilyID( + ctx, + familyID, + ); revErr != nil { + return nil, fmt.Errorf( + "rotate refresh token: revoke family on race-loss: %w", + revErr, + ) + } + return nil, ErrTokenReuse + } + return nil, fmt.Errorf("rotate refresh token: %w", err) + } + } + refreshTokenEntity := &RefreshToken{ ID: newTokenID, UserID: user.ID, @@ -386,11 +505,6 @@ func (s *Service) createAuthResponse( return nil, fmt.Errorf("store refresh token: %w", err) } - if oldTokenID != nil { - //nolint:errcheck // best-effort token chain tracking - _ = s.repo.MarkAsUsed(ctx, *oldTokenID, newTokenID) - } - return &AuthResponse{ User: UserResponse{ ID: user.ID, diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/auth/verifier.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/auth/verifier.go new file mode 100644 index 00000000..8afffff0 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/auth/verifier.go @@ -0,0 +1,81 @@ +// ©AngelaMos | 2026 +// verifier.go + +package auth + +import ( + "context" + "errors" + "fmt" + + "github.com/carterperez-dev/monitor-the-situation/backend/internal/core" + "github.com/carterperez-dev/monitor-the-situation/backend/internal/middleware" +) + +// Verifier composes JWT validation, token-version check, and JTI blacklist +// check into a single TokenVerifier the middleware can use. This is what +// wires the auth-service's revocation infrastructure (LogoutAll's +// IncrementTokenVersion, Logout's RevokeAccessToken) into the request +// path — without it, those calls were dead code. +type Verifier struct { + jwt *JWTManager + service *Service + userProvider UserProvider +} + +func NewVerifier( + jwt *JWTManager, + service *Service, + userProvider UserProvider, +) *Verifier { + return &Verifier{ + jwt: jwt, + service: service, + userProvider: userProvider, + } +} + +// VerifyAccessToken parses the JWT, then enforces: +// - The user's current token_version is <= the version embedded in this +// token. A LogoutAll bump invalidates every previously-issued access +// token by raising the floor. +// - The token's JTI is not in the Redis blacklist (Logout adds it there +// to revoke a single session immediately, not just the refresh token). +func (v *Verifier) VerifyAccessToken( + ctx context.Context, + tokenString string, +) (*middleware.AccessTokenClaims, error) { + claims, err := v.jwt.VerifyAccessToken(ctx, tokenString) + if err != nil { + return nil, err + } + + if claims.JTI != "" { + blacklisted, blErr := v.service.IsAccessTokenBlacklisted( + ctx, + claims.JTI, + ) + if blErr != nil { + return nil, fmt.Errorf("verify token: blacklist lookup: %w", blErr) + } + if blacklisted { + return nil, fmt.Errorf("verify token: %w", core.ErrTokenRevoked) + } + } + + user, err := v.userProvider.GetByID(ctx, claims.UserID) + if err != nil { + if errors.Is(err, core.ErrNotFound) { + return nil, fmt.Errorf("verify token: %w", core.ErrTokenInvalid) + } + return nil, fmt.Errorf("verify token: load user: %w", err) + } + if claims.TokenVersion < user.TokenVersion { + return nil, fmt.Errorf( + "verify token: version superseded: %w", + core.ErrTokenRevoked, + ) + } + + return claims, nil +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/bus/bus.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/bus/bus.go index 6ea8445c..e53733d4 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/bus/bus.go +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/bus/bus.go @@ -7,12 +7,16 @@ import ( "context" "encoding/json" "log/slog" + "sync" "sync/atomic" "github.com/carterperez-dev/monitor-the-situation/backend/internal/events" ) -const defaultBufferSize = 512 +const ( + defaultBufferSize = 512 + defaultSubscriberSize = 256 +) type Config struct { BufferSize int @@ -27,6 +31,10 @@ type Bus struct { broadcaster Broadcaster logger *slog.Logger dropped atomic.Uint64 + + subsMu sync.RWMutex + subscribers []chan events.Event + subDropped atomic.Uint64 } func New(cfg Config) *Bus { @@ -59,7 +67,26 @@ func (b *Bus) DroppedCount() uint64 { return b.dropped.Load() } +// SubscriberDroppedCount counts events dropped to channel subscribers +// (separate from the main bus drop counter). +func (b *Bus) SubscriberDroppedCount() uint64 { + return b.subDropped.Load() +} + +// Subscribe returns a buffered channel that receives every event the bus +// sees. Slow subscribers drop events rather than block the persist+ +// broadcast hot path; SubscriberDroppedCount reports total drops. Wire +// subscribers at startup before Run() begins. +func (b *Bus) Subscribe() <-chan events.Event { + b.subsMu.Lock() + defer b.subsMu.Unlock() + ch := make(chan events.Event, defaultSubscriberSize) + b.subscribers = append(b.subscribers, ch) + return ch +} + func (b *Bus) Run(ctx context.Context) error { + defer b.closeSubscribers() for { select { case <-ctx.Done(): @@ -80,6 +107,29 @@ func (b *Bus) Run(ctx context.Context) error { } b.broadcaster.Broadcast(string(ev.Topic), payload) } + b.fanout(ev) } } } + +func (b *Bus) fanout(ev events.Event) { + b.subsMu.RLock() + subs := b.subscribers + b.subsMu.RUnlock() + for _, ch := range subs { + select { + case ch <- ev: + default: + b.subDropped.Add(1) + } + } +} + +func (b *Bus) closeSubscribers() { + b.subsMu.Lock() + defer b.subsMu.Unlock() + for _, ch := range b.subscribers { + close(ch) + } + b.subscribers = nil +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/bus/bus_test.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/bus/bus_test.go index f2115a06..3895255b 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/bus/bus_test.go +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/bus/bus_test.go @@ -95,5 +95,5 @@ func TestBus_DropOnFullBuffer(t *testing.T) { for i := 0; i < 50; i++ { b.Emit(events.Event{Topic: events.TopicHeartbeat, Source: "stress"}) } - require.Greater(t, b.DroppedCount(), uint64(0)) + require.Positive(t, b.DroppedCount()) } diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/cfradar/client.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/cfradar/client.go index 7a13e32f..f3ad491c 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/cfradar/client.go +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/cfradar/client.go @@ -105,12 +105,18 @@ func (c *Client) FetchOutages(ctx context.Context) (OutageResultBody, error) { return OutageResultBody{}, fmt.Errorf("fetch outages: %w", err) } if !env.Success { - return OutageResultBody{}, fmt.Errorf("radar outages: success=false errors=%v", env.Errors) + return OutageResultBody{}, fmt.Errorf( + "radar outages: success=false errors=%v", + env.Errors, + ) } return env.Result, nil } -func (c *Client) FetchHijacks(ctx context.Context, minConfidence int) (HijackBody, error) { +func (c *Client) FetchHijacks( + ctx context.Context, + minConfidence int, +) (HijackBody, error) { q := url.Values{ "dateRange": []string{defaultDateRange}, "minConfidence": []string{strconv.Itoa(minConfidence)}, @@ -120,7 +126,10 @@ func (c *Client) FetchHijacks(ctx context.Context, minConfidence int) (HijackBod return HijackBody{}, fmt.Errorf("fetch hijacks: %w", err) } if !env.Success { - return HijackBody{}, fmt.Errorf("radar hijacks: success=false errors=%v", env.Errors) + return HijackBody{}, fmt.Errorf( + "radar hijacks: success=false errors=%v", + env.Errors, + ) } return env.Result, nil } diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/cfradar/client_test.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/cfradar/client_test.go index 797520bd..8061d297 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/cfradar/client_test.go +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/cfradar/client_test.go @@ -20,21 +20,27 @@ import ( func newRadarServer(t *testing.T, authHits *atomic.Int32) *httptest.Server { t.Helper() mux := http.NewServeMux() - mux.HandleFunc("/client/v4/radar/annotations/outages", func(w http.ResponseWriter, r *http.Request) { - if r.Header.Get("Authorization") == "Bearer test-token" { - authHits.Add(1) - } - body, err := os.ReadFile("testdata/outages.json") - require.NoError(t, err) - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write(body) - }) - mux.HandleFunc("/client/v4/radar/bgp/hijacks/events", func(w http.ResponseWriter, _ *http.Request) { - body, err := os.ReadFile("testdata/hijacks.json") - require.NoError(t, err) - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write(body) - }) + mux.HandleFunc( + "/client/v4/radar/annotations/outages", + func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") == "Bearer test-token" { + authHits.Add(1) + } + body, err := os.ReadFile("testdata/outages.json") + require.NoError(t, err) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write(body) + }, + ) + mux.HandleFunc( + "/client/v4/radar/bgp/hijacks/events", + func(w http.ResponseWriter, _ *http.Request) { + body, err := os.ReadFile("testdata/hijacks.json") + require.NoError(t, err) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write(body) + }, + ) return httptest.NewServer(mux) } @@ -82,12 +88,20 @@ func TestClient_FetchHijacksDecodes(t *testing.T) { } func TestClient_FetchOutagesFailsOnSuccessFalse(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - _, _ = w.Write([]byte(`{"success":false,"errors":[{"message":"unauthorized"}]}`)) - })) + srv := httptest.NewServer( + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write( + []byte( + `{"success":false,"errors":[{"message":"unauthorized"}]}`, + ), + ) + }), + ) defer srv.Close() - c := cfradar.NewClient(cfradar.ClientConfig{BaseURL: srv.URL, BearerToken: "x"}) + c := cfradar.NewClient( + cfradar.ClientConfig{BaseURL: srv.URL, BearerToken: "x"}, + ) _, err := c.FetchOutages(context.Background()) require.Error(t, err) } diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/cfradar/collector.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/cfradar/collector.go index add1eca8..3ffbf642 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/cfradar/collector.go +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/cfradar/collector.go @@ -38,8 +38,8 @@ type Emitter interface { } type StateRecorder interface { - RecordSuccess(ctx context.Context, name string, eventCount int64) error - RecordError(ctx context.Context, name, errMsg string) error + RecordSuccess(ctx context.Context, name string, eventCount int64) + RecordError(ctx context.Context, name, errMsg string) } type Enricher interface { @@ -111,7 +111,7 @@ func (c *Collector) tick(ctx context.Context) { if n, err := c.tickOutages(ctx); err != nil { c.logger.Warn("cfradar outages tick failed", "err", err) - _ = c.cfg.State.RecordError(ctx, Name, err.Error()) + c.cfg.State.RecordError(ctx, Name, err.Error()) hadError = true } else { emitted += n @@ -119,14 +119,14 @@ func (c *Collector) tick(ctx context.Context) { if n, err := c.tickHijacks(ctx); err != nil { c.logger.Warn("cfradar hijacks tick failed", "err", err) - _ = c.cfg.State.RecordError(ctx, Name, err.Error()) + c.cfg.State.RecordError(ctx, Name, err.Error()) hadError = true } else { emitted += n } if !hadError { - _ = c.cfg.State.RecordSuccess(ctx, Name, emitted) + c.cfg.State.RecordSuccess(ctx, Name, emitted) } } diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/cfradar/collector_test.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/cfradar/collector_test.go index 9304271e..f4e76b3d 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/cfradar/collector_test.go +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/cfradar/collector_test.go @@ -21,11 +21,16 @@ type fakeFetcher struct { hijacks cfradar.HijackBody } -func (f *fakeFetcher) FetchOutages(context.Context) (cfradar.OutageResultBody, error) { +func (f *fakeFetcher) FetchOutages( + context.Context, +) (cfradar.OutageResultBody, error) { return f.outages, nil } -func (f *fakeFetcher) FetchHijacks(context.Context, int) (cfradar.HijackBody, error) { +func (f *fakeFetcher) FetchHijacks( + context.Context, + int, +) (cfradar.HijackBody, error) { return f.hijacks, nil } @@ -51,7 +56,10 @@ func (r *fakeRepo) UpsertHijack(_ context.Context, _ cfradar.HijackRow) error { return nil } -func (r *fakeRepo) KnownOutageIDs(_ context.Context, ids []string) (map[string]bool, error) { +func (r *fakeRepo) KnownOutageIDs( + _ context.Context, + ids []string, +) (map[string]bool, error) { out := make(map[string]bool) for _, id := range ids { if r.knownOutages[id] { @@ -61,7 +69,10 @@ func (r *fakeRepo) KnownOutageIDs(_ context.Context, ids []string) (map[string]b return out, nil } -func (r *fakeRepo) KnownHijackIDs(_ context.Context, ids []int64) (map[int64]bool, error) { +func (r *fakeRepo) KnownHijackIDs( + _ context.Context, + ids []int64, +) (map[int64]bool, error) { out := make(map[int64]bool) for _, id := range ids { if r.knownHijacks[id] { @@ -92,8 +103,8 @@ func (e *fakeEmitter) Events() []events.Event { type noopState struct{} -func (noopState) RecordSuccess(context.Context, string, int64) error { return nil } -func (noopState) RecordError(context.Context, string, string) error { return nil } +func (noopState) RecordSuccess(context.Context, string, int64) {} +func (noopState) RecordError(context.Context, string, string) {} type fakeEnricher struct { calls int @@ -102,7 +113,10 @@ type fakeEnricher struct { returnFn func(ip string) (cfradar.Enrichment, error) } -func (e *fakeEnricher) Lookup(_ context.Context, ip string) (cfradar.Enrichment, error) { +func (e *fakeEnricher) Lookup( + _ context.Context, + ip string, +) (cfradar.Enrichment, error) { e.calls++ e.lastIP = ip if e.returnFn != nil { @@ -114,10 +128,12 @@ func (e *fakeEnricher) Lookup(_ context.Context, ip string) (cfradar.Enrichment, func TestCollector_OnlyEmitsNetNew(t *testing.T) { now := time.Now().UTC() ftch := &fakeFetcher{ - outages: cfradar.OutageResultBody{Annotations: []cfradar.OutageAnnotation{ - {ID: "out-known", StartDate: now}, - {ID: "out-new", StartDate: now}, - }}, + outages: cfradar.OutageResultBody{ + Annotations: []cfradar.OutageAnnotation{ + {ID: "out-known", StartDate: now}, + {ID: "out-new", StartDate: now}, + }, + }, hijacks: cfradar.HijackBody{Events: []cfradar.HijackEvent{ {ID: 100, DetectedAt: now, StartedAt: now, Confidence: 9}, {ID: 200, DetectedAt: now, StartedAt: now, Confidence: 8}, @@ -138,7 +154,10 @@ func TestCollector_OnlyEmitsNetNew(t *testing.T) { State: noopState{}, }) - ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) + ctx, cancel := context.WithTimeout( + context.Background(), + 50*time.Millisecond, + ) defer cancel() _ = c.Run(ctx) @@ -194,7 +213,10 @@ func TestCollector_EnrichesHijackPayloadWhenEnricherProvided(t *testing.T) { Enricher: enr, }) - ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) + ctx, cancel := context.WithTimeout( + context.Background(), + 50*time.Millisecond, + ) defer cancel() _ = c.Run(ctx) @@ -219,7 +241,13 @@ func TestCollector_HijackEmitsRawWhenNoEnricher(t *testing.T) { now := time.Now().UTC() ftch := &fakeFetcher{ hijacks: cfradar.HijackBody{Events: []cfradar.HijackEvent{ - {ID: 777, DetectedAt: now, StartedAt: now, Confidence: 9, Prefixes: []string{"198.51.100.0/24"}}, + { + ID: 777, + DetectedAt: now, + StartedAt: now, + Confidence: 9, + Prefixes: []string{"198.51.100.0/24"}, + }, }}, } repo := &fakeRepo{knownHijacks: map[int64]bool{}} @@ -234,7 +262,10 @@ func TestCollector_HijackEmitsRawWhenNoEnricher(t *testing.T) { State: noopState{}, }) - ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) + ctx, cancel := context.WithTimeout( + context.Background(), + 50*time.Millisecond, + ) defer cancel() _ = c.Run(ctx) @@ -250,9 +281,11 @@ func TestCollector_HijackEmitsRawWhenNoEnricher(t *testing.T) { func TestCollector_RepeatedTickIsIdempotent(t *testing.T) { now := time.Now().UTC() ftch := &fakeFetcher{ - outages: cfradar.OutageResultBody{Annotations: []cfradar.OutageAnnotation{ - {ID: "out-x", StartDate: now}, - }}, + outages: cfradar.OutageResultBody{ + Annotations: []cfradar.OutageAnnotation{ + {ID: "out-x", StartDate: now}, + }, + }, } emit := &fakeEmitter{} @@ -268,7 +301,10 @@ func TestCollector_RepeatedTickIsIdempotent(t *testing.T) { State: noopState{}, }) - ctx, cancel := context.WithTimeout(context.Background(), 25*time.Millisecond) + ctx, cancel := context.WithTimeout( + context.Background(), + 25*time.Millisecond, + ) defer cancel() _ = c.Run(ctx) known["out-x"] = true @@ -282,11 +318,19 @@ func TestCollector_RepeatedTickIsIdempotent(t *testing.T) { Emitter: emit2, State: noopState{}, }) - ctx2, cancel2 := context.WithTimeout(context.Background(), 25*time.Millisecond) + ctx2, cancel2 := context.WithTimeout( + context.Background(), + 25*time.Millisecond, + ) defer cancel2() _ = c2.Run(ctx2) for _, ev := range emit2.Events() { - require.NotEqual(t, events.TopicInternetOutage, ev.Topic, "should not re-emit known outage") + require.NotEqual( + t, + events.TopicInternetOutage, + ev.Topic, + "should not re-emit known outage", + ) } } diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/cfradar/repo.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/cfradar/repo.go index 9d1a1600..b2ab73fb 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/cfradar/repo.go +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/cfradar/repo.go @@ -49,7 +49,9 @@ func (r *Repo) UpsertOutage(ctx context.Context, o OutageRow) error { if o.ASNs == nil { o.ASNs = pq.Int32Array{} } - _, err := r.db.ExecContext(ctx, ` + _, err := r.db.ExecContext( + ctx, + ` INSERT INTO outage_events (id, started_at, ended_at, locations, asns, cause, outage_type, payload) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) ON CONFLICT (id) DO UPDATE SET @@ -59,7 +61,14 @@ func (r *Repo) UpsertOutage(ctx context.Context, o OutageRow) error { cause = EXCLUDED.cause, outage_type = EXCLUDED.outage_type, payload = EXCLUDED.payload`, - o.ID, o.StartedAt, o.EndedAt, o.Locations, o.ASNs, o.Cause, o.OutageType, []byte(o.Payload), + o.ID, + o.StartedAt, + o.EndedAt, + o.Locations, + o.ASNs, + o.Cause, + o.OutageType, + []byte(o.Payload), ) if err != nil { return fmt.Errorf("upsert outage %s: %w", o.ID, err) @@ -74,7 +83,9 @@ func (r *Repo) UpsertHijack(ctx context.Context, h HijackRow) error { if h.Prefixes == nil { h.Prefixes = []string{} } - _, err := r.db.ExecContext(ctx, ` + _, err := r.db.ExecContext( + ctx, + ` INSERT INTO bgp_hijack_events (id, detected_at, started_at, duration_sec, confidence, hijacker_asn, victim_asns, prefixes, payload) VALUES ($1, $2, $3, $4, $5, $6, $7, $8::cidr[], $9) @@ -83,8 +94,15 @@ func (r *Repo) UpsertHijack(ctx context.Context, h HijackRow) error { duration_sec = EXCLUDED.duration_sec, confidence = EXCLUDED.confidence, payload = EXCLUDED.payload`, - h.ID, h.DetectedAt, h.StartedAt, h.DurationSec, h.Confidence, h.HijackerASN, - h.VictimASNs, pq.Array(h.Prefixes), []byte(h.Payload), + h.ID, + h.DetectedAt, + h.StartedAt, + h.DurationSec, + h.Confidence, + h.HijackerASN, + h.VictimASNs, + pq.Array(h.Prefixes), + []byte(h.Payload), ) if err != nil { return fmt.Errorf("upsert hijack %d: %w", h.ID, err) @@ -92,13 +110,20 @@ func (r *Repo) UpsertHijack(ctx context.Context, h HijackRow) error { return nil } -func (r *Repo) KnownOutageIDs(ctx context.Context, ids []string) (map[string]bool, error) { +func (r *Repo) KnownOutageIDs( + ctx context.Context, + ids []string, +) (map[string]bool, error) { if len(ids) == 0 { return map[string]bool{}, nil } var found []string - if err := r.db.SelectContext(ctx, &found, - `SELECT id FROM outage_events WHERE id = ANY($1::text[])`, pq.Array(ids)); err != nil { + if err := r.db.SelectContext( + ctx, + &found, + `SELECT id FROM outage_events WHERE id = ANY($1::text[])`, + pq.Array(ids), + ); err != nil { return nil, fmt.Errorf("known outage ids: %w", err) } out := make(map[string]bool, len(found)) @@ -108,13 +133,20 @@ func (r *Repo) KnownOutageIDs(ctx context.Context, ids []string) (map[string]boo return out, nil } -func (r *Repo) KnownHijackIDs(ctx context.Context, ids []int64) (map[int64]bool, error) { +func (r *Repo) KnownHijackIDs( + ctx context.Context, + ids []int64, +) (map[int64]bool, error) { if len(ids) == 0 { return map[int64]bool{}, nil } var found []int64 - if err := r.db.SelectContext(ctx, &found, - `SELECT id FROM bgp_hijack_events WHERE id = ANY($1::bigint[])`, pq.Array(ids)); err != nil { + if err := r.db.SelectContext( + ctx, + &found, + `SELECT id FROM bgp_hijack_events WHERE id = ANY($1::bigint[])`, + pq.Array(ids), + ); err != nil { return nil, fmt.Errorf("known hijack ids: %w", err) } out := make(map[int64]bool, len(found)) @@ -123,3 +155,42 @@ func (r *Repo) KnownHijackIDs(ctx context.Context, ids []int64) (map[int64]bool, } return out, nil } + +func (r *Repo) RecentOutages( + ctx context.Context, + limit int, +) ([]OutageRow, error) { + if limit <= 0 { + limit = 50 + } + var rows []OutageRow + err := r.db.SelectContext(ctx, &rows, ` + SELECT id, started_at, ended_at, locations, asns, cause, outage_type, payload + FROM outage_events + ORDER BY started_at DESC + LIMIT $1`, limit) + if err != nil { + return nil, fmt.Errorf("recent outages: %w", err) + } + return rows, nil +} + +func (r *Repo) RecentHijacks( + ctx context.Context, + limit int, +) ([]HijackRow, error) { + if limit <= 0 { + limit = 50 + } + var rows []HijackRow + err := r.db.SelectContext(ctx, &rows, ` + SELECT id, detected_at, started_at, duration_sec, confidence, hijacker_asn, + victim_asns, payload + FROM bgp_hijack_events + ORDER BY detected_at DESC + LIMIT $1`, limit) + if err != nil { + return nil, fmt.Errorf("recent hijacks: %w", err) + } + return rows, nil +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/coinbase/aggregator.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/coinbase/aggregator.go index 2da928b2..5e2a4958 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/coinbase/aggregator.go +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/coinbase/aggregator.go @@ -13,19 +13,23 @@ func NewAggregator() *Aggregator { return &Aggregator{open: make(map[string]MinuteBar)} } +// Push folds a tick into the open minute bar for its symbol. Volume24h +// from the latest tick within the minute wins — the column on +// MinuteBar is documented as "24h volume sampled at close of minute", +// not as per-minute volume. Per-trade size isn't on the ticker channel. func (a *Aggregator) Push(t Tick) (*MinuteBar, MinuteBar) { minute := t.TS.UTC().Truncate(time.Minute) cur, exists := a.open[t.Symbol] if !exists { cur = MinuteBar{ - Symbol: t.Symbol, - Minute: minute, - Open: t.Price, - High: t.Price, - Low: t.Price, - Close: t.Price, - Volume: t.Volume24h, + Symbol: t.Symbol, + Minute: minute, + Open: t.Price, + High: t.Price, + Low: t.Price, + Close: t.Price, + Volume24hAtClose: t.Volume24h, } a.open[t.Symbol] = cur return nil, cur @@ -34,13 +38,13 @@ func (a *Aggregator) Push(t Tick) (*MinuteBar, MinuteBar) { if minute.After(cur.Minute) { closed := cur cur = MinuteBar{ - Symbol: t.Symbol, - Minute: minute, - Open: t.Price, - High: t.Price, - Low: t.Price, - Close: t.Price, - Volume: t.Volume24h, + Symbol: t.Symbol, + Minute: minute, + Open: t.Price, + High: t.Price, + Low: t.Price, + Close: t.Price, + Volume24hAtClose: t.Volume24h, } a.open[t.Symbol] = cur return &closed, cur @@ -53,9 +57,7 @@ func (a *Aggregator) Push(t Tick) (*MinuteBar, MinuteBar) { cur.Low = t.Price } cur.Close = t.Price - if t.Volume24h.GreaterThan(cur.Volume) { - cur.Volume = t.Volume24h - } + cur.Volume24hAtClose = t.Volume24h a.open[t.Symbol] = cur return nil, cur } diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/coinbase/aggregator_test.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/coinbase/aggregator_test.go index 60d46741..a5e1b1fd 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/coinbase/aggregator_test.go +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/coinbase/aggregator_test.go @@ -30,18 +30,39 @@ func TestAggregator_FirstTickOpensBar(t *testing.T) { func TestAggregator_FoldUpdatesHighLowClose(t *testing.T) { a := coinbase.NewAggregator() min := time.Date(2026, 5, 1, 12, 0, 0, 0, time.UTC) - a.Push(coinbase.Tick{Symbol: "BTC-USD", TS: min.Add(10 * time.Second), Price: decimal.RequireFromString("42000"), Volume24h: decimal.RequireFromString("100")}) - a.Push(coinbase.Tick{Symbol: "BTC-USD", TS: min.Add(20 * time.Second), Price: decimal.RequireFromString("42050"), Volume24h: decimal.RequireFromString("110")}) + a.Push( + coinbase.Tick{ + Symbol: "BTC-USD", + TS: min.Add(10 * time.Second), + Price: decimal.RequireFromString("42000"), + Volume24h: decimal.RequireFromString("100"), + }, + ) + a.Push( + coinbase.Tick{ + Symbol: "BTC-USD", + TS: min.Add(20 * time.Second), + Price: decimal.RequireFromString("42050"), + Volume24h: decimal.RequireFromString("110"), + }, + ) closed, cur := a.Push(coinbase.Tick{ - Symbol: "BTC-USD", TS: min.Add(30 * time.Second), - Price: decimal.RequireFromString("41950"), Volume24h: decimal.RequireFromString("120"), + Symbol: "BTC-USD", + TS: min.Add(30 * time.Second), + Price: decimal.RequireFromString( + "41950", + ), + Volume24h: decimal.RequireFromString("120"), }) require.Nil(t, closed) require.True(t, cur.Open.Equal(decimal.RequireFromString("42000"))) require.True(t, cur.High.Equal(decimal.RequireFromString("42050"))) require.True(t, cur.Low.Equal(decimal.RequireFromString("41950"))) require.True(t, cur.Close.Equal(decimal.RequireFromString("41950"))) - require.True(t, cur.Volume.Equal(decimal.RequireFromString("120"))) + require.True( + t, + cur.Volume24hAtClose.Equal(decimal.RequireFromString("120")), + ) } func TestAggregator_MinuteRolloverEmitsClosedBar(t *testing.T) { @@ -49,12 +70,30 @@ func TestAggregator_MinuteRolloverEmitsClosedBar(t *testing.T) { min1 := time.Date(2026, 5, 1, 12, 0, 0, 0, time.UTC) min2 := min1.Add(time.Minute) - a.Push(coinbase.Tick{Symbol: "BTC-USD", TS: min1.Add(10 * time.Second), Price: decimal.RequireFromString("42000"), Volume24h: decimal.RequireFromString("100")}) - a.Push(coinbase.Tick{Symbol: "BTC-USD", TS: min1.Add(50 * time.Second), Price: decimal.RequireFromString("42100"), Volume24h: decimal.RequireFromString("105")}) + a.Push( + coinbase.Tick{ + Symbol: "BTC-USD", + TS: min1.Add(10 * time.Second), + Price: decimal.RequireFromString("42000"), + Volume24h: decimal.RequireFromString("100"), + }, + ) + a.Push( + coinbase.Tick{ + Symbol: "BTC-USD", + TS: min1.Add(50 * time.Second), + Price: decimal.RequireFromString("42100"), + Volume24h: decimal.RequireFromString("105"), + }, + ) closed, cur := a.Push(coinbase.Tick{ - Symbol: "BTC-USD", TS: min2.Add(5 * time.Second), - Price: decimal.RequireFromString("42150"), Volume24h: decimal.RequireFromString("110"), + Symbol: "BTC-USD", + TS: min2.Add(5 * time.Second), + Price: decimal.RequireFromString( + "42150", + ), + Volume24h: decimal.RequireFromString("110"), }) require.NotNil(t, closed) @@ -69,8 +108,22 @@ func TestAggregator_BarsArePerProduct(t *testing.T) { a := coinbase.NewAggregator() min := time.Date(2026, 5, 1, 12, 0, 0, 0, time.UTC) - _, btc := a.Push(coinbase.Tick{Symbol: "BTC-USD", TS: min.Add(10 * time.Second), Price: decimal.RequireFromString("42000"), Volume24h: decimal.RequireFromString("100")}) - _, eth := a.Push(coinbase.Tick{Symbol: "ETH-USD", TS: min.Add(11 * time.Second), Price: decimal.RequireFromString("2300"), Volume24h: decimal.RequireFromString("80")}) + _, btc := a.Push( + coinbase.Tick{ + Symbol: "BTC-USD", + TS: min.Add(10 * time.Second), + Price: decimal.RequireFromString("42000"), + Volume24h: decimal.RequireFromString("100"), + }, + ) + _, eth := a.Push( + coinbase.Tick{ + Symbol: "ETH-USD", + TS: min.Add(11 * time.Second), + Price: decimal.RequireFromString("2300"), + Volume24h: decimal.RequireFromString("80"), + }, + ) require.Equal(t, "BTC-USD", btc.Symbol) require.Equal(t, "ETH-USD", eth.Symbol) @@ -81,8 +134,22 @@ func TestAggregator_BarsArePerProduct(t *testing.T) { func TestAggregator_FlushReturnsAllOpenBars(t *testing.T) { a := coinbase.NewAggregator() min := time.Date(2026, 5, 1, 12, 0, 0, 0, time.UTC) - a.Push(coinbase.Tick{Symbol: "BTC-USD", TS: min.Add(10 * time.Second), Price: decimal.RequireFromString("42000"), Volume24h: decimal.RequireFromString("100")}) - a.Push(coinbase.Tick{Symbol: "ETH-USD", TS: min.Add(15 * time.Second), Price: decimal.RequireFromString("2300"), Volume24h: decimal.RequireFromString("80")}) + a.Push( + coinbase.Tick{ + Symbol: "BTC-USD", + TS: min.Add(10 * time.Second), + Price: decimal.RequireFromString("42000"), + Volume24h: decimal.RequireFromString("100"), + }, + ) + a.Push( + coinbase.Tick{ + Symbol: "ETH-USD", + TS: min.Add(15 * time.Second), + Price: decimal.RequireFromString("2300"), + Volume24h: decimal.RequireFromString("80"), + }, + ) bars := a.Flush() require.Len(t, bars, 2) } diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/coinbase/client.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/coinbase/client.go index 5e5e2009..83094e1a 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/coinbase/client.go +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/coinbase/client.go @@ -88,7 +88,11 @@ func (d *WSDialer) Dial(ctx context.Context) (*Conn, error) { conn := &Conn{c: c} subCtx, cancel := context.WithTimeout(ctx, d.cfg.WriteTimeout) defer cancel() - if err := conn.subscribe(subCtx, channelTicker, d.cfg.ProductIDs); err != nil { + if err := conn.subscribe( + subCtx, + channelTicker, + d.cfg.ProductIDs, + ); err != nil { _ = c.Close(websocket.StatusInternalError, "subscribe ticker") return nil, err } @@ -116,8 +120,16 @@ type subscribeMsg struct { ProductIDs []string `json:"product_ids,omitempty"` } -func (c *Conn) subscribe(ctx context.Context, channel string, productIDs []string) error { - msg := subscribeMsg{Type: "subscribe", Channel: channel, ProductIDs: productIDs} +func (c *Conn) subscribe( + ctx context.Context, + channel string, + productIDs []string, +) error { + msg := subscribeMsg{ + Type: "subscribe", + Channel: channel, + ProductIDs: productIDs, + } body, err := json.Marshal(msg) if err != nil { return fmt.Errorf("marshal subscribe %s: %w", channel, err) @@ -151,7 +163,10 @@ func parseCoinbaseTime(s string) time.Time { if idx := strings.Index(s, " m=+"); idx > 0 { s = s[:idx] } - if t, err := time.Parse("2006-01-02 15:04:05.999999999 -0700 MST", s); err == nil { + if t, err := time.Parse( + "2006-01-02 15:04:05.999999999 -0700 MST", + s, + ); err == nil { return t } return time.Time{} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/coinbase/client_test.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/coinbase/client_test.go index 617df843..872e6961 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/coinbase/client_test.go +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/coinbase/client_test.go @@ -171,7 +171,9 @@ func TestClient_DecodeHeartbeatExposesCurrentTime(t *testing.T) { loadFixture(t, "heartbeats.json"), ) - d := coinbase.NewWSDialer(coinbase.DialerConfig{URL: fs.URL(), ProductIDs: []string{"BTC-USD"}}) + d := coinbase.NewWSDialer( + coinbase.DialerConfig{URL: fs.URL(), ProductIDs: []string{"BTC-USD"}}, + ) ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/coinbase/collector.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/coinbase/collector.go index c47ad3f4..3ee3d274 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/coinbase/collector.go +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/coinbase/collector.go @@ -34,8 +34,8 @@ type Emitter interface { } type StateRecorder interface { - RecordSuccess(ctx context.Context, name string, eventCount int64) error - RecordError(ctx context.Context, name, errMsg string) error + RecordSuccess(ctx context.Context, name string, eventCount int64) + RecordError(ctx context.Context, name, errMsg string) } type CollectorConfig struct { @@ -50,12 +50,28 @@ type CollectorConfig struct { Logger *slog.Logger } +type priceObs struct { + ts time.Time + price decimal.Decimal +} + +const ( + priceHistoryWindow = time.Hour + // minHistoryAge guards against false alarms after a backend restart: + // if we only have ~5 minutes of price data and the price moved 5% + // in that window, that's not "1h drop ≥5%" — it's a 5-minute spike + // that happens to round to the threshold. Wait until we have at + // least 30 min of observations before reporting change_pct_1h. + minHistoryAge = 30 * time.Minute +) + type Collector struct { - cfg CollectorConfig - dialer Dialer - logger *slog.Logger - mu sync.Mutex - lastEmit map[string]time.Time + cfg CollectorConfig + dialer Dialer + logger *slog.Logger + mu sync.Mutex + lastEmit map[string]time.Time + priceHistory map[string][]priceObs } func NewCollector(cfg CollectorConfig) *Collector { @@ -78,10 +94,11 @@ func NewCollector(cfg CollectorConfig) *Collector { } return &Collector{ - cfg: cfg, - dialer: d, - logger: cfg.Logger, - lastEmit: make(map[string]time.Time), + cfg: cfg, + dialer: d, + logger: cfg.Logger, + lastEmit: make(map[string]time.Time), + priceHistory: make(map[string][]priceObs), } } @@ -89,8 +106,9 @@ func (c *Collector) Name() string { return Name } func (c *Collector) Run(ctx context.Context) error { err := Reconnect(ctx, c.dialer, c.cfg.Reconnect, c.handleConn) - if err != nil && !errors.Is(err, context.Canceled) && !errors.Is(err, context.DeadlineExceeded) { - _ = c.cfg.State.RecordError(ctx, Name, err.Error()) + if err != nil && !errors.Is(err, context.Canceled) && + !errors.Is(err, context.DeadlineExceeded) { + c.cfg.State.RecordError(ctx, Name, err.Error()) } return err } @@ -100,39 +118,61 @@ func (c *Collector) handleConn(ctx context.Context, conn *Conn) error { agg := NewAggregator() count := int64(0) - loopErr := ReadLoop(ctx, conn, seq, func(hctx context.Context, f Frame) error { - switch f.Kind { - case FrameTypeUnknown, FrameTypeSubscriptions, FrameTypeHeartbeats: - return nil - case FrameTypeTicker, FrameTypeSnapshot: - ts := f.Timestamp.UTC() - if ts.IsZero() { - ts = time.Now().UTC() - } - for _, tk := range f.Tickers { - tick := Tick{ - Symbol: tk.ProductID, - TS: ts, - Price: tk.Price, - Volume24h: tk.Volume24h, + loopErr := ReadLoop( + ctx, + conn, + seq, + func(hctx context.Context, f Frame) error { + switch f.Kind { + case FrameTypeUnknown, FrameTypeSubscriptions, FrameTypeHeartbeats: + return nil + case FrameTypeTicker, FrameTypeSnapshot: + ts := f.Timestamp.UTC() + if ts.IsZero() { + ts = time.Now().UTC() } - if err := c.cfg.Repo.InsertTick(hctx, tick); err != nil { - c.logger.Warn("insert tick", "symbol", tick.Symbol, "err", err) - continue - } - if closed, _ := agg.Push(tick); closed != nil { - if err := c.cfg.Repo.UpsertMinute(hctx, *closed); err != nil { - c.logger.Warn("upsert minute", "symbol", closed.Symbol, "minute", closed.Minute, "err", err) + for _, tk := range f.Tickers { + tick := Tick{ + Symbol: tk.ProductID, + TS: ts, + Price: tk.Price, + Volume24h: tk.Volume24h, + } + if err := c.cfg.Repo.InsertTick(hctx, tick); err != nil { + c.logger.Warn( + "insert tick", + "symbol", + tick.Symbol, + "err", + err, + ) + continue + } + if closed, _ := agg.Push(tick); closed != nil { + if err := c.cfg.Repo.UpsertMinute( + hctx, + *closed, + ); err != nil { + c.logger.Warn( + "upsert minute", + "symbol", + closed.Symbol, + "minute", + closed.Minute, + "err", + err, + ) + } + } + if c.shouldEmit(tick.Symbol) { + c.emitTick(tick) + count++ } } - if c.shouldEmit(tick.Symbol) { - c.emitTick(tick) - count++ - } } - } - return nil - }) + return nil + }, + ) c.logger.Info("coinbase loop exit", "err", loopErrString(loopErr), @@ -140,7 +180,7 @@ func (c *Collector) handleConn(ctx context.Context, conn *Conn) error { "agg_open", aggLen(agg), ) if loopErr == nil || errors.Is(loopErr, ErrSequenceGap) { - _ = c.cfg.State.RecordSuccess(ctx, Name, count) + c.cfg.State.RecordSuccess(ctx, Name, count) } return loopErr } @@ -172,19 +212,25 @@ func (c *Collector) shouldEmit(symbol string) bool { } type tickPayload struct { - Symbol string `json:"symbol"` - TS time.Time `json:"ts"` - Price decimal.Decimal `json:"price"` - Volume24h decimal.Decimal `json:"volume_24h"` + Symbol string `json:"symbol"` + TS time.Time `json:"ts"` + Price decimal.Decimal `json:"price"` + Volume24h decimal.Decimal `json:"volume_24h"` + ChangePct1h *float64 `json:"change_pct_1h,omitempty"` } func (c *Collector) emitTick(t Tick) { - body, _ := json.Marshal(tickPayload{ + c.recordPrice(t.Symbol, t.TS, t.Price) + payload := tickPayload{ Symbol: t.Symbol, TS: t.TS, Price: t.Price, Volume24h: t.Volume24h, - }) + } + if pct, ok := c.changePct1h(t.Symbol, t.TS, t.Price); ok { + payload.ChangePct1h = &pct + } + body, _ := json.Marshal(payload) c.cfg.Emitter.Emit(events.Event{ Topic: events.TopicCoinbasePrice, Timestamp: t.TS, @@ -192,3 +238,50 @@ func (c *Collector) emitTick(t Tick) { Payload: json.RawMessage(body), }) } + +// recordPrice keeps a sliding 1-hour window of (timestamp, price) +// observations per symbol so we can compute trailing percentage change +// without hitting the database on every tick. +func (c *Collector) recordPrice( + symbol string, + ts time.Time, + price decimal.Decimal, +) { + c.mu.Lock() + defer c.mu.Unlock() + cutoff := ts.Add(-priceHistoryWindow) + hist := c.priceHistory[symbol] + drop := 0 + for drop < len(hist) && hist[drop].ts.Before(cutoff) { + drop++ + } + hist = append(hist[drop:], priceObs{ts: ts, price: price}) + c.priceHistory[symbol] = hist +} + +// changePct1h returns the percent change from the oldest observation in +// the sliding window to `current`. Returns false if we haven't yet seen +// minHistoryAge of data — predicates against early-life ticks would +// otherwise fire on noisy short windows. Used by the alerts engine via +// the emitted `change_pct_1h` field. +func (c *Collector) changePct1h( + symbol string, + currentTs time.Time, + current decimal.Decimal, +) (float64, bool) { + c.mu.Lock() + defer c.mu.Unlock() + hist := c.priceHistory[symbol] + if len(hist) == 0 { + return 0, false + } + oldest := hist[0] + if currentTs.Sub(oldest.ts) < minHistoryAge { + return 0, false + } + if oldest.price.IsZero() { + return 0, false + } + pct, _ := current.Sub(oldest.price).Div(oldest.price).Float64() + return pct * 100, true +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/coinbase/collector_test.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/coinbase/collector_test.go index f36258a6..a6f71b5c 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/coinbase/collector_test.go +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/coinbase/collector_test.go @@ -37,11 +37,17 @@ func (r *fakeRepo) UpsertMinute(_ context.Context, b coinbase.MinuteBar) error { return nil } -func (r *fakeRepo) LatestTick(_ context.Context, _ string) (coinbase.Tick, error) { +func (r *fakeRepo) LatestTick( + _ context.Context, + _ string, +) (coinbase.Tick, error) { return coinbase.Tick{}, nil } -func (r *fakeRepo) History1h(_ context.Context, _ string) ([]coinbase.MinuteBar, error) { +func (r *fakeRepo) History1h( + _ context.Context, + _ string, +) ([]coinbase.MinuteBar, error) { return nil, nil } @@ -74,18 +80,16 @@ type fakeState struct { failures int } -func (s *fakeState) RecordSuccess(_ context.Context, _ string, _ int64) error { +func (s *fakeState) RecordSuccess(_ context.Context, _ string, _ int64) { s.mu.Lock() defer s.mu.Unlock() s.successes++ - return nil } -func (s *fakeState) RecordError(_ context.Context, _, _ string) error { +func (s *fakeState) RecordError(_ context.Context, _, _ string) { s.mu.Lock() defer s.mu.Unlock() s.failures++ - return nil } func tickerFrame(seq int64, productID, price string, ts time.Time) []byte { @@ -116,7 +120,10 @@ func TestCollector_RunPersistsAndEmits(t *testing.T) { frames := [][]byte{loadFixture(t, "subscriptions.json")} for i := 0; i < 6; i++ { ts := now.Add(time.Duration(i) * time.Second) - frames = append(frames, tickerFrame(int64(i+1), "BTC-USD", fmt.Sprintf("4200%d.00", i), ts)) + frames = append( + frames, + tickerFrame(int64(i+1), "BTC-USD", fmt.Sprintf("4200%d.00", i), ts), + ) } fs := newFakeServer(t, frames...) @@ -137,12 +144,20 @@ func TestCollector_RunPersistsAndEmits(t *testing.T) { }, }) - ctx, cancel := context.WithTimeout(context.Background(), 800*time.Millisecond) + ctx, cancel := context.WithTimeout( + context.Background(), + 800*time.Millisecond, + ) defer cancel() _ = c.Run(ctx) require.GreaterOrEqual(t, repo.Tickers(), 3, "should persist most ticks") - require.GreaterOrEqual(t, emt.Count(), 1, "should emit at least one event after throttle") + require.GreaterOrEqual( + t, + emt.Count(), + 1, + "should emit at least one event after throttle", + ) } func TestCollector_GapTriggersReconnect(t *testing.T) { @@ -171,7 +186,10 @@ func TestCollector_GapTriggersReconnect(t *testing.T) { }, }) - ctx, cancel := context.WithTimeout(context.Background(), 600*time.Millisecond) + ctx, cancel := context.WithTimeout( + context.Background(), + 600*time.Millisecond, + ) defer cancel() _ = c.Run(ctx) } diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/coinbase/readloop.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/coinbase/readloop.go index e8fa7ea2..36596355 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/coinbase/readloop.go +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/coinbase/readloop.go @@ -13,7 +13,12 @@ var ErrSequenceGap = errors.New("coinbase: sequence gap detected") type FrameHandler func(ctx context.Context, f Frame) error -func ReadLoop(ctx context.Context, conn *Conn, seq *Sequencer, handler FrameHandler) error { +func ReadLoop( + ctx context.Context, + conn *Conn, + seq *Sequencer, + handler FrameHandler, +) error { for { if err := ctx.Err(); err != nil { return err diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/coinbase/readloop_test.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/coinbase/readloop_test.go index a294ec01..914f1399 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/coinbase/readloop_test.go +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/coinbase/readloop_test.go @@ -16,13 +16,20 @@ import ( ) func TestReadLoop_DeliversTickerFrames(t *testing.T) { - fs := newFakeServer(t, + fs := newFakeServer( + t, loadFixture(t, "subscriptions.json"), - []byte(`{"channel":"ticker","sequence_num":1000,"timestamp":"2026-05-01T22:30:00Z","events":[{"type":"update","tickers":[{"product_id":"BTC-USD","price":"42000.00","volume_24_h":"1.0","time":"2026-05-01T22:30:00Z"}]}]}`), - []byte(`{"channel":"ticker","sequence_num":1001,"timestamp":"2026-05-01T22:30:01Z","events":[{"type":"update","tickers":[{"product_id":"BTC-USD","price":"42001.00","volume_24_h":"1.0","time":"2026-05-01T22:30:01Z"}]}]}`), + []byte( + `{"channel":"ticker","sequence_num":1000,"timestamp":"2026-05-01T22:30:00Z","events":[{"type":"update","tickers":[{"product_id":"BTC-USD","price":"42000.00","volume_24_h":"1.0","time":"2026-05-01T22:30:00Z"}]}]}`, + ), + []byte( + `{"channel":"ticker","sequence_num":1001,"timestamp":"2026-05-01T22:30:01Z","events":[{"type":"update","tickers":[{"product_id":"BTC-USD","price":"42001.00","volume_24_h":"1.0","time":"2026-05-01T22:30:01Z"}]}]}`, + ), ) - d := coinbase.NewWSDialer(coinbase.DialerConfig{URL: fs.URL(), ProductIDs: []string{"BTC-USD"}}) + d := coinbase.NewWSDialer( + coinbase.DialerConfig{URL: fs.URL(), ProductIDs: []string{"BTC-USD"}}, + ) ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() conn, err := d.Dial(ctx) @@ -36,29 +43,48 @@ func TestReadLoop_DeliversTickerFrames(t *testing.T) { loopCtx, loopCancel := context.WithTimeout(ctx, 500*time.Millisecond) defer loopCancel() - err = coinbase.ReadLoop(loopCtx, conn, seq, func(_ context.Context, f coinbase.Frame) error { - mu.Lock() - defer mu.Unlock() - if f.Kind == coinbase.FrameTypeTicker { - tickerFrames++ - } - return nil - }) - require.True(t, errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) || err == nil) + err = coinbase.ReadLoop( + loopCtx, + conn, + seq, + func(_ context.Context, f coinbase.Frame) error { + mu.Lock() + defer mu.Unlock() + if f.Kind == coinbase.FrameTypeTicker { + tickerFrames++ + } + return nil + }, + ) + require.True( + t, + errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) || + err == nil, + ) mu.Lock() defer mu.Unlock() require.GreaterOrEqual(t, tickerFrames, 2) } func TestReadLoop_GapIsLoggedButLoopContinues(t *testing.T) { - fs := newFakeServer(t, + fs := newFakeServer( + t, loadFixture(t, "subscriptions.json"), - []byte(`{"channel":"ticker","sequence_num":100,"timestamp":"2026-05-01T22:30:00Z","events":[{"type":"update","tickers":[{"product_id":"BTC-USD","price":"42000.00","volume_24_h":"1.0","time":"2026-05-01T22:30:00Z"}]}]}`), - []byte(`{"channel":"ticker","sequence_num":250,"timestamp":"2026-05-01T22:30:01Z","events":[{"type":"update","tickers":[{"product_id":"BTC-USD","price":"42001.00","volume_24_h":"1.0","time":"2026-05-01T22:30:01Z"}]}]}`), + []byte( + `{"channel":"ticker","sequence_num":100,"timestamp":"2026-05-01T22:30:00Z","events":[{"type":"update","tickers":[{"product_id":"BTC-USD","price":"42000.00","volume_24_h":"1.0","time":"2026-05-01T22:30:00Z"}]}]}`, + ), + []byte( + `{"channel":"ticker","sequence_num":250,"timestamp":"2026-05-01T22:30:01Z","events":[{"type":"update","tickers":[{"product_id":"BTC-USD","price":"42001.00","volume_24_h":"1.0","time":"2026-05-01T22:30:01Z"}]}]}`, + ), ) - d := coinbase.NewWSDialer(coinbase.DialerConfig{URL: fs.URL(), ProductIDs: []string{"BTC-USD"}}) - ctx, cancel := context.WithTimeout(context.Background(), 600*time.Millisecond) + d := coinbase.NewWSDialer( + coinbase.DialerConfig{URL: fs.URL(), ProductIDs: []string{"BTC-USD"}}, + ) + ctx, cancel := context.WithTimeout( + context.Background(), + 600*time.Millisecond, + ) defer cancel() conn, err := d.Dial(ctx) require.NoError(t, err) @@ -66,24 +92,43 @@ func TestReadLoop_GapIsLoggedButLoopContinues(t *testing.T) { seq := coinbase.NewSequencer() delivered := 0 - loopErr := coinbase.ReadLoop(ctx, conn, seq, func(_ context.Context, f coinbase.Frame) error { - if f.Kind == coinbase.FrameTypeTicker { - delivered++ - } - return nil - }) - require.True(t, errors.Is(loopErr, context.DeadlineExceeded) || errors.Is(loopErr, context.Canceled) || loopErr == nil) - require.Equal(t, 2, delivered, "both ticker frames must be delivered despite the gap") + loopErr := coinbase.ReadLoop( + ctx, + conn, + seq, + func(_ context.Context, f coinbase.Frame) error { + if f.Kind == coinbase.FrameTypeTicker { + delivered++ + } + return nil + }, + ) + require.True( + t, + errors.Is(loopErr, context.DeadlineExceeded) || errors.Is(loopErr, context.Canceled) || + loopErr == nil, + ) + require.Equal( + t, + 2, + delivered, + "both ticker frames must be delivered despite the gap", + ) } func TestReadLoop_SnapshotResetsSequencer(t *testing.T) { - fs := newFakeServer(t, + fs := newFakeServer( + t, loadFixture(t, "subscriptions.json"), loadFixture(t, "snapshot.json"), - []byte(`{"channel":"ticker","sequence_num":2,"timestamp":"2026-05-01T22:30:02Z","events":[{"type":"update","tickers":[{"product_id":"BTC-USD","price":"42164.00","volume_24_h":"1.0","time":"2026-05-01T22:30:02Z"}]}]}`), + []byte( + `{"channel":"ticker","sequence_num":2,"timestamp":"2026-05-01T22:30:02Z","events":[{"type":"update","tickers":[{"product_id":"BTC-USD","price":"42164.00","volume_24_h":"1.0","time":"2026-05-01T22:30:02Z"}]}]}`, + ), ) - d := coinbase.NewWSDialer(coinbase.DialerConfig{URL: fs.URL(), ProductIDs: []string{"BTC-USD"}}) + d := coinbase.NewWSDialer( + coinbase.DialerConfig{URL: fs.URL(), ProductIDs: []string{"BTC-USD"}}, + ) ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() conn, err := d.Dial(ctx) @@ -96,13 +141,22 @@ func TestReadLoop_SnapshotResetsSequencer(t *testing.T) { loopCtx, loopCancel := context.WithTimeout(ctx, 800*time.Millisecond) defer loopCancel() - err = coinbase.ReadLoop(loopCtx, conn, seq, func(_ context.Context, f coinbase.Frame) error { - mu.Lock() - defer mu.Unlock() - kinds = append(kinds, f.Kind) - return nil - }) - require.True(t, errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) || err == nil) + err = coinbase.ReadLoop( + loopCtx, + conn, + seq, + func(_ context.Context, f coinbase.Frame) error { + mu.Lock() + defer mu.Unlock() + kinds = append(kinds, f.Kind) + return nil + }, + ) + require.True( + t, + errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) || + err == nil, + ) mu.Lock() defer mu.Unlock() diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/coinbase/reconnect.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/coinbase/reconnect.go index 30772b0f..4890e69d 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/coinbase/reconnect.go +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/coinbase/reconnect.go @@ -27,7 +27,12 @@ func PermanentReconnectError(err error) error { return backoff.Permanent(err) } -func Reconnect(ctx context.Context, d Dialer, cfg ReconnectConfig, handler ConnHandler) error { +func Reconnect( + ctx context.Context, + d Dialer, + cfg ReconnectConfig, + handler ConnHandler, +) error { if cfg.InitialInterval <= 0 { cfg.InitialInterval = defaultReconnectInitial } diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/coinbase/reconnect_test.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/coinbase/reconnect_test.go index dfa9738d..d86fff14 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/coinbase/reconnect_test.go +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/coinbase/reconnect_test.go @@ -31,7 +31,10 @@ func (d *stubDialer) Dial(_ context.Context) (*coinbase.Conn, error) { func TestReconnect_StopsOnContextCancel(t *testing.T) { d := &stubDialer{failsBeforeOK: 0} - ctx, cancel := context.WithTimeout(context.Background(), 80*time.Millisecond) + ctx, cancel := context.WithTimeout( + context.Background(), + 80*time.Millisecond, + ) defer cancel() calls := atomic.Int32{} @@ -42,13 +45,25 @@ func TestReconnect_StopsOnContextCancel(t *testing.T) { calls.Add(1) return errors.New("upstream closed") }) - require.True(t, errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled)) - require.Greater(t, calls.Load(), int32(1), "callback should fire multiple times before ctx expires") + require.True( + t, + errors.Is(err, context.DeadlineExceeded) || + errors.Is(err, context.Canceled), + ) + require.Greater( + t, + calls.Load(), + int32(1), + "callback should fire multiple times before ctx expires", + ) } func TestReconnect_TransientDialFailureBacksOffThenSucceeds(t *testing.T) { d := &stubDialer{failsBeforeOK: 3} - ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond) + ctx, cancel := context.WithTimeout( + context.Background(), + 500*time.Millisecond, + ) defer cancel() cbInvocations := atomic.Int32{} @@ -59,8 +74,17 @@ func TestReconnect_TransientDialFailureBacksOffThenSucceeds(t *testing.T) { cbInvocations.Add(1) return errors.New("force loop") }) - require.True(t, errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled)) - require.GreaterOrEqual(t, d.called.Load(), int32(4), "must dial at least 4 times (3 failures + 1 success)") + require.True( + t, + errors.Is(err, context.DeadlineExceeded) || + errors.Is(err, context.Canceled), + ) + require.GreaterOrEqual( + t, + d.called.Load(), + int32(4), + "must dial at least 4 times (3 failures + 1 success)", + ) require.GreaterOrEqual(t, cbInvocations.Load(), int32(1)) } @@ -68,7 +92,10 @@ func TestReconnect_PermanentErrorFromCallbackPropagates(t *testing.T) { d := &stubDialer{failsBeforeOK: 0} sentinel := errors.New("hard stop") - ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond) + ctx, cancel := context.WithTimeout( + context.Background(), + 200*time.Millisecond, + ) defer cancel() err := coinbase.Reconnect(ctx, d, coinbase.ReconnectConfig{ diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/coinbase/repo.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/coinbase/repo.go index 6c91fcf3..c664473d 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/coinbase/repo.go +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/coinbase/repo.go @@ -23,14 +23,18 @@ type Tick struct { Volume24h decimal.Decimal `db:"volume_24h"` } +// MinuteBar holds a per-minute OHLC for a symbol plus the rolling 24h +// volume sampled at the minute's close. The Coinbase ticker channel only +// publishes 24h volume, not per-trade size, so true per-minute volume +// would require the market_trades channel — out of scope here. type MinuteBar struct { - Symbol string `db:"symbol"` - Minute time.Time `db:"minute"` - Open decimal.Decimal `db:"open"` - High decimal.Decimal `db:"high"` - Low decimal.Decimal `db:"low"` - Close decimal.Decimal `db:"close"` - Volume decimal.Decimal `db:"volume"` + Symbol string `db:"symbol"` + Minute time.Time `db:"minute"` + Open decimal.Decimal `db:"open"` + High decimal.Decimal `db:"high"` + Low decimal.Decimal `db:"low"` + Close decimal.Decimal `db:"close"` + Volume24hAtClose decimal.Decimal `db:"volume_24h_at_close"` } type Repo struct { @@ -54,15 +58,15 @@ func (r *Repo) InsertTick(ctx context.Context, t Tick) error { func (r *Repo) UpsertMinute(ctx context.Context, b MinuteBar) error { _, err := r.db.ExecContext(ctx, ` - INSERT INTO btc_eth_minute (symbol, minute, open, high, low, close, volume) + INSERT INTO btc_eth_minute (symbol, minute, open, high, low, close, volume_24h_at_close) VALUES ($1, $2, $3, $4, $5, $6, $7) ON CONFLICT (symbol, minute) DO UPDATE SET - open = EXCLUDED.open, - high = EXCLUDED.high, - low = EXCLUDED.low, - close = EXCLUDED.close, - volume = EXCLUDED.volume`, - b.Symbol, b.Minute, b.Open, b.High, b.Low, b.Close, b.Volume, + open = EXCLUDED.open, + high = EXCLUDED.high, + low = EXCLUDED.low, + close = EXCLUDED.close, + volume_24h_at_close = EXCLUDED.volume_24h_at_close`, + b.Symbol, b.Minute, b.Open, b.High, b.Low, b.Close, b.Volume24hAtClose, ) if err != nil { return fmt.Errorf("upsert minute %s @ %s: %w", b.Symbol, b.Minute, err) @@ -83,10 +87,13 @@ func (r *Repo) LatestTick(ctx context.Context, symbol string) (Tick, error) { return t, nil } -func (r *Repo) History1h(ctx context.Context, symbol string) ([]MinuteBar, error) { +func (r *Repo) History1h( + ctx context.Context, + symbol string, +) ([]MinuteBar, error) { var rows []MinuteBar err := r.db.SelectContext(ctx, &rows, ` - SELECT symbol, minute, open, high, low, close, volume + SELECT symbol, minute, open, high, low, close, volume_24h_at_close FROM btc_eth_minute WHERE symbol = $1 ORDER BY minute DESC LIMIT $2`, symbol, history1hLimit, diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/coinbase/repo_test.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/coinbase/repo_test.go index b5fa427a..c8294bc1 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/coinbase/repo_test.go +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/coinbase/repo_test.go @@ -49,13 +49,13 @@ func setupDB(t *testing.T) *sqlx.DB { PRIMARY KEY (symbol, ts) ); CREATE TABLE btc_eth_minute ( - symbol text NOT NULL, - minute timestamptz NOT NULL, - open numeric(18,8) NOT NULL, - high numeric(18,8) NOT NULL, - low numeric(18,8) NOT NULL, - close numeric(18,8) NOT NULL, - volume numeric(20,8), + symbol text NOT NULL, + minute timestamptz NOT NULL, + open numeric(18,8) NOT NULL, + high numeric(18,8) NOT NULL, + low numeric(18,8) NOT NULL, + close numeric(18,8) NOT NULL, + volume_24h_at_close numeric(20,8), PRIMARY KEY (symbol, minute) );`) require.NoError(t, err) @@ -91,13 +91,13 @@ func TestRepo_UpsertMinuteUpdatesOHLC(t *testing.T) { minute := time.Now().UTC().Truncate(time.Minute) bar := coinbase.MinuteBar{ - Symbol: "ETH-USD", - Minute: minute, - Open: decimal.RequireFromString("2310.00"), - High: decimal.RequireFromString("2315.50"), - Low: decimal.RequireFromString("2308.10"), - Close: decimal.RequireFromString("2312.75"), - Volume: decimal.RequireFromString("88.12300000"), + Symbol: "ETH-USD", + Minute: minute, + Open: decimal.RequireFromString("2310.00"), + High: decimal.RequireFromString("2315.50"), + Low: decimal.RequireFromString("2308.10"), + Close: decimal.RequireFromString("2312.75"), + Volume24hAtClose: decimal.RequireFromString("88.12300000"), } require.NoError(t, repo.UpsertMinute(ctx, bar)) @@ -120,13 +120,13 @@ func TestRepo_History1hReturnsLast60MinutesOldestFirst(t *testing.T) { now := time.Now().UTC().Truncate(time.Minute) for i := 0; i < 75; i++ { bar := coinbase.MinuteBar{ - Symbol: "BTC-USD", - Minute: now.Add(-time.Duration(i) * time.Minute), - Open: decimal.NewFromInt(int64(40000 + i)), - High: decimal.NewFromInt(int64(40050 + i)), - Low: decimal.NewFromInt(int64(39950 + i)), - Close: decimal.NewFromInt(int64(40010 + i)), - Volume: decimal.NewFromInt(int64(i)), + Symbol: "BTC-USD", + Minute: now.Add(-time.Duration(i) * time.Minute), + Open: decimal.NewFromInt(int64(40000 + i)), + High: decimal.NewFromInt(int64(40050 + i)), + Low: decimal.NewFromInt(int64(39950 + i)), + Close: decimal.NewFromInt(int64(40010 + i)), + Volume24hAtClose: decimal.NewFromInt(int64(i)), } require.NoError(t, repo.UpsertMinute(ctx, bar)) } @@ -134,7 +134,11 @@ func TestRepo_History1hReturnsLast60MinutesOldestFirst(t *testing.T) { hist, err := repo.History1h(ctx, "BTC-USD") require.NoError(t, err) require.Len(t, hist, 60) - require.True(t, hist[0].Minute.Before(hist[len(hist)-1].Minute), "history must be oldest → newest") + require.True( + t, + hist[0].Minute.Before(hist[len(hist)-1].Minute), + "history must be oldest → newest", + ) } func TestRepo_LatestTickMissingReturnsErrNoRows(t *testing.T) { diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/cve/collector.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/cve/collector.go index 8baceb7f..dd725143 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/cve/collector.go +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/cve/collector.go @@ -23,12 +23,19 @@ type NVDFetcher interface { } type EPSSFetcher interface { - LookupBatch(ctx context.Context, cveIDs []string) (map[string]EPSSScore, error) + LookupBatch( + ctx context.Context, + cveIDs []string, + ) (map[string]EPSSScore, error) } type Repository interface { Upsert(ctx context.Context, row Row) error - UpdateEPSS(ctx context.Context, cveID string, score, percentile float64) error + UpdateEPSS( + ctx context.Context, + cveID string, + score, percentile float64, + ) error } type Emitter interface { @@ -36,8 +43,8 @@ type Emitter interface { } type StateRecorder interface { - RecordSuccess(ctx context.Context, name string, eventCount int64) error - RecordError(ctx context.Context, name, errMsg string) error + RecordSuccess(ctx context.Context, name string, eventCount int64) + RecordError(ctx context.Context, name, errMsg string) } type CollectorConfig struct { @@ -94,7 +101,7 @@ func (c *Collector) tick(ctx context.Context) { resp, err := c.cfg.NVD.Fetch(ctx, start, end) if err != nil { c.logger.Warn("nvd fetch", "err", err) - _ = c.cfg.State.RecordError(ctx, Name, err.Error()) + c.cfg.State.RecordError(ctx, Name, err.Error()) return } @@ -127,7 +134,12 @@ func (c *Collector) tick(ctx context.Context) { continue } if s, ok := scores[row.CveID]; ok { - if err := c.cfg.Repo.UpdateEPSS(ctx, row.CveID, s.Score, s.Percentile); err != nil { + if err := c.cfg.Repo.UpdateEPSS( + ctx, + row.CveID, + s.Score, + s.Percentile, + ); err != nil { c.logger.Warn("update epss", "id", row.CveID, "err", err) } row.EPSSScore = &s.Score @@ -142,5 +154,5 @@ func (c *Collector) tick(ctx context.Context) { }) emitted++ } - _ = c.cfg.State.RecordSuccess(ctx, Name, emitted) + c.cfg.State.RecordSuccess(ctx, Name, emitted) } diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/cve/collector_test.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/cve/collector_test.go index d7eea8e1..78e88042 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/cve/collector_test.go +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/cve/collector_test.go @@ -21,7 +21,11 @@ type stubNVD struct { err error } -func (s *stubNVD) Fetch(context.Context, time.Time, time.Time) (cve.NVDResponse, error) { +func (s *stubNVD) Fetch( + context.Context, + time.Time, + time.Time, +) (cve.NVDResponse, error) { return s.resp, s.err } @@ -30,7 +34,10 @@ type stubEPSS struct { err error } -func (s *stubEPSS) LookupBatch(context.Context, []string) (map[string]cve.EPSSScore, error) { +func (s *stubEPSS) LookupBatch( + context.Context, + []string, +) (map[string]cve.EPSSScore, error) { return s.scores, s.err } @@ -54,7 +61,11 @@ func (r *stubCVERepo) Upsert(_ context.Context, row cve.Row) error { return nil } -func (r *stubCVERepo) UpdateEPSS(_ context.Context, id string, score, pct float64) error { +func (r *stubCVERepo) UpdateEPSS( + _ context.Context, + id string, + score, pct float64, +) error { r.mu.Lock() defer r.mu.Unlock() r.patches[id] = [2]float64{score, pct} @@ -94,22 +105,29 @@ func (e *stubCVEEmitter) Events() []events.Event { type stubCVEState struct{} -func (stubCVEState) RecordSuccess(context.Context, string, int64) error { return nil } -func (stubCVEState) RecordError(context.Context, string, string) error { return nil } +func (stubCVEState) RecordSuccess(context.Context, string, int64) {} +func (stubCVEState) RecordError(context.Context, string, string) {} func TestCVECollector_FetchesEnrichesAndEmits(t *testing.T) { now := time.Now().UTC() nvd := &stubNVD{resp: cve.NVDResponse{ Vulnerabilities: []cve.NVDVulnRoot{ {CVE: cve.NVDCVE{ - ID: "CVE-2026-0001", Published: cve.NVDTime{Time: now.Add(-time.Hour)}, + ID: "CVE-2026-0001", + Published: cve.NVDTime{Time: now.Add(-time.Hour)}, LastModified: cve.NVDTime{Time: now}, Metrics: cve.NVDMetrics{CVSSv31: []cve.NVDMetricEntry{ - {CVSSData: cve.NVDCVSSData{BaseScore: 9.8, BaseSeverity: "CRITICAL"}}, + { + CVSSData: cve.NVDCVSSData{ + BaseScore: 9.8, + BaseSeverity: "CRITICAL", + }, + }, }}, }}, {CVE: cve.NVDCVE{ - ID: "CVE-2026-0002", Published: cve.NVDTime{Time: now.Add(-time.Hour)}, + ID: "CVE-2026-0002", + Published: cve.NVDTime{Time: now.Add(-time.Hour)}, LastModified: cve.NVDTime{Time: now}, }}, }, @@ -130,7 +148,10 @@ func TestCVECollector_FetchesEnrichesAndEmits(t *testing.T) { State: stubCVEState{}, }) - ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) + ctx, cancel := context.WithTimeout( + context.Background(), + 50*time.Millisecond, + ) defer cancel() _ = c.Run(ctx) @@ -162,11 +183,14 @@ func TestCVECollector_NVDFetchErrorRecordsState(t *testing.T) { State: stubCVEState{}, }) - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Millisecond) + ctx, cancel := context.WithTimeout( + context.Background(), + 30*time.Millisecond, + ) defer cancel() _ = c.Run(ctx) require.Zero(t, repo.Upserts()) require.Zero(t, repo.Patches()) - require.Zero(t, len(emt.Events())) + require.Empty(t, emt.Events()) } diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/cve/epss_client.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/cve/epss_client.go index 7dc5cf7f..132e5aec 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/cve/epss_client.go +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/cve/epss_client.go @@ -63,7 +63,10 @@ type EPSSResponse struct { Data []EPSSScore `json:"data"` } -func (c *EPSSClient) LookupBatch(ctx context.Context, cveIDs []string) (map[string]EPSSScore, error) { +func (c *EPSSClient) LookupBatch( + ctx context.Context, + cveIDs []string, +) (map[string]EPSSScore, error) { out := map[string]EPSSScore{} for i := 0; i < len(cveIDs); i += epssBatchSize { end := i + epssBatchSize diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/cve/epss_client_test.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/cve/epss_client_test.go index 638adf56..a87b60b4 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/cve/epss_client_test.go +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/cve/epss_client_test.go @@ -8,7 +8,6 @@ import ( "net/http" "net/http/httptest" "os" - "strings" "sync/atomic" "testing" @@ -18,17 +17,22 @@ import ( ) func TestEPSSClient_BatchLookupDecodesScores(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - ids := r.URL.Query().Get("cve") - require.True(t, strings.Contains(ids, "CVE-2024-3094")) - body, err := os.ReadFile("testdata/epss_batch.json") - require.NoError(t, err) - _, _ = w.Write(body) - })) + srv := httptest.NewServer( + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ids := r.URL.Query().Get("cve") + require.Contains(t, ids, "CVE-2024-3094") + body, err := os.ReadFile("testdata/epss_batch.json") + require.NoError(t, err) + _, _ = w.Write(body) + }), + ) defer srv.Close() c := cve.NewEPSSClient(cve.EPSSClientConfig{BaseURL: srv.URL}) - scores, err := c.LookupBatch(context.Background(), []string{"CVE-2024-3094", "CVE-2024-21413"}) + scores, err := c.LookupBatch( + context.Background(), + []string{"CVE-2024-3094", "CVE-2024-21413"}, + ) require.NoError(t, err) require.NotEmpty(t, scores) @@ -39,10 +43,12 @@ func TestEPSSClient_BatchLookupDecodesScores(t *testing.T) { func TestEPSSClient_ChunksOver100PerRequest(t *testing.T) { var hits atomic.Int32 - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - hits.Add(1) - _, _ = w.Write([]byte(`{"data":[]}`)) - })) + srv := httptest.NewServer( + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + hits.Add(1) + _, _ = w.Write([]byte(`{"data":[]}`)) + }), + ) defer srv.Close() c := cve.NewEPSSClient(cve.EPSSClientConfig{BaseURL: srv.URL}) diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/cve/nvd_client.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/cve/nvd_client.go index 8457d0cc..bb95451a 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/cve/nvd_client.go +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/cve/nvd_client.go @@ -7,6 +7,7 @@ import ( "context" "fmt" "net/url" + "strconv" "time" "golang.org/x/time/rate" @@ -23,6 +24,8 @@ const ( defaultNVDBreakerWin = 120 * time.Second nvdTimeFormat = "2006-01-02T15:04:05.000" nvdAPIKeyHeader = "apiKey" + nvdResultsPerPage = 2000 + nvdMaxPages = 50 ) type NVDClientConfig struct { @@ -110,16 +113,45 @@ type NVDCVSSData struct { BaseSeverity string `json:"baseSeverity"` } -func (c *NVDClient) Fetch(ctx context.Context, start, end time.Time) (NVDResponse, error) { - q := url.Values{ - "lastModStartDate": []string{start.UTC().Format(nvdTimeFormat)}, - "lastModEndDate": []string{end.UTC().Format(nvdTimeFormat)}, +// Fetch returns every NVD CVE updated in [start, end], paging through the +// API as needed. NVD caps a single response at 2000 vulns; without +// pagination, big-disclosure days (Patch Tuesday) silently drop everything +// past the first page. This is what the audit's Finding 4 was about. +func (c *NVDClient) Fetch( + ctx context.Context, + start, end time.Time, +) (NVDResponse, error) { + combined := NVDResponse{} + startIdx := 0 + + for page := 0; page < nvdMaxPages; page++ { + q := url.Values{ + "lastModStartDate": []string{start.UTC().Format(nvdTimeFormat)}, + "lastModEndDate": []string{end.UTC().Format(nvdTimeFormat)}, + "resultsPerPage": []string{strconv.Itoa(nvdResultsPerPage)}, + "startIndex": []string{strconv.Itoa(startIdx)}, + } + var pageResp NVDResponse + if err := c.hx.GetJSON(ctx, pathNVDCVE2, q, &pageResp); err != nil { + return NVDResponse{}, fmt.Errorf("nvd fetch page %d: %w", page, err) + } + combined.Vulnerabilities = append( + combined.Vulnerabilities, + pageResp.Vulnerabilities...) + combined.TotalResults = pageResp.TotalResults + + fetched := len(pageResp.Vulnerabilities) + if fetched == 0 { + break + } + startIdx += fetched + if startIdx >= pageResp.TotalResults { + break + } } - var resp NVDResponse - if err := c.hx.GetJSON(ctx, pathNVDCVE2, q, &resp); err != nil { - return NVDResponse{}, fmt.Errorf("nvd fetch: %w", err) - } - return resp, nil + combined.ResultsPerPage = len(combined.Vulnerabilities) + combined.StartIndex = 0 + return combined, nil } func (v NVDVulnRoot) PrimarySeverity() (float64, string) { diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/cve/nvd_client_test.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/cve/nvd_client_test.go index 7689e5a5..ff3c3310 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/cve/nvd_client_test.go +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/cve/nvd_client_test.go @@ -5,9 +5,13 @@ package cve_test import ( "context" + "encoding/json" + "fmt" "net/http" "net/http/httptest" "os" + "strconv" + "sync/atomic" "testing" "time" @@ -18,18 +22,22 @@ import ( func TestNVDClient_FetchSendsAPIKeyAndDecodes(t *testing.T) { var sawKey, sawStart, sawEnd string - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - sawKey = r.Header.Get("apiKey") - sawStart = r.URL.Query().Get("lastModStartDate") - sawEnd = r.URL.Query().Get("lastModEndDate") - body, err := os.ReadFile("testdata/nvd_2h_window.json") - require.NoError(t, err) - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write(body) - })) + srv := httptest.NewServer( + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + sawKey = r.Header.Get("apiKey") + sawStart = r.URL.Query().Get("lastModStartDate") + sawEnd = r.URL.Query().Get("lastModEndDate") + body, err := os.ReadFile("testdata/nvd_2h_window.json") + require.NoError(t, err) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write(body) + }), + ) defer srv.Close() - c := cve.NewNVDClient(cve.NVDClientConfig{BaseURL: srv.URL, APIKey: "test-key"}) + c := cve.NewNVDClient( + cve.NVDClientConfig{BaseURL: srv.URL, APIKey: "test-key"}, + ) end := time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC) start := end.Add(-2 * time.Hour) @@ -47,7 +55,12 @@ func TestNVDClient_PrimarySeverityFromV31(t *testing.T) { ID: "CVE-2026-X", Metrics: cve.NVDMetrics{ CVSSv31: []cve.NVDMetricEntry{ - {CVSSData: cve.NVDCVSSData{BaseScore: 9.8, BaseSeverity: "CRITICAL"}}, + { + CVSSData: cve.NVDCVSSData{ + BaseScore: 9.8, + BaseSeverity: "CRITICAL", + }, + }, }, }, }} @@ -61,7 +74,12 @@ func TestNVDClient_PrimarySeverityFallsBackToV30(t *testing.T) { ID: "CVE-2018-X", Metrics: cve.NVDMetrics{ CVSSv30: []cve.NVDMetricEntry{ - {CVSSData: cve.NVDCVSSData{BaseScore: 7.5, BaseSeverity: "HIGH"}}, + { + CVSSData: cve.NVDCVSSData{ + BaseScore: 7.5, + BaseSeverity: "HIGH", + }, + }, }, }, }} @@ -76,3 +94,57 @@ func TestNVDClient_PrimarySeverityZeroWhenMissing(t *testing.T) { require.Zero(t, score) require.Empty(t, sev) } + +// TestNVDClient_FetchPaginatesAcrossMultiplePages: NVD caps a single +// response at 2000 vulns. With a window like Patch Tuesday that exceeds +// that, we have to walk startIndex until startIndex >= totalResults. +// Pre-fix the collector silently dropped everything past page 1. +func TestNVDClient_FetchPaginatesAcrossMultiplePages(t *testing.T) { + const total = 4500 + var hits atomic.Int32 + + srv := httptest.NewServer( + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + hits.Add(1) + startIdx, _ := strconv.Atoi(r.URL.Query().Get("startIndex")) + perPage, _ := strconv.Atoi(r.URL.Query().Get("resultsPerPage")) + if perPage <= 0 { + perPage = 2000 + } + end := startIdx + perPage + if end > total { + end = total + } + vulns := make([]cve.NVDVulnRoot, 0, end-startIdx) + for i := startIdx; i < end; i++ { + vulns = append(vulns, cve.NVDVulnRoot{ + CVE: cve.NVDCVE{ID: fmt.Sprintf("CVE-2026-%05d", i)}, + }) + } + resp := cve.NVDResponse{ + ResultsPerPage: perPage, + StartIndex: startIdx, + TotalResults: total, + Vulnerabilities: vulns, + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(resp) + }), + ) + defer srv.Close() + + c := cve.NewNVDClient(cve.NVDClientConfig{BaseURL: srv.URL}) + end := time.Now().UTC() + start := end.Add(-2 * time.Hour) + + resp, err := c.Fetch(context.Background(), start, end) + require.NoError(t, err) + require.Len(t, resp.Vulnerabilities, total, "every page should be combined") + require.Equal(t, total, resp.TotalResults) + require.GreaterOrEqual( + t, + hits.Load(), + int32(3), + "4500/2000 needs >=3 page calls", + ) +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/cve/repo.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/cve/repo.go index 0e546357..4f7a9083 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/cve/repo.go +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/cve/repo.go @@ -31,7 +31,9 @@ type Repo struct { func NewRepo(db *sqlx.DB) *Repo { return &Repo{db: db} } func (r *Repo) Upsert(ctx context.Context, row Row) error { - _, err := r.db.ExecContext(ctx, ` + _, err := r.db.ExecContext( + ctx, + ` INSERT INTO cve_events (cve_id, published, last_modified, severity, cvss_score, payload) VALUES ($1, $2, $3, $4, $5, $6) ON CONFLICT (cve_id) DO UPDATE SET @@ -39,7 +41,12 @@ func (r *Repo) Upsert(ctx context.Context, row Row) error { severity = EXCLUDED.severity, cvss_score = EXCLUDED.cvss_score, payload = EXCLUDED.payload`, - row.CveID, row.Published, row.LastModified, row.Severity, row.CVSS, []byte(row.Payload), + row.CveID, + row.Published, + row.LastModified, + row.Severity, + row.CVSS, + []byte(row.Payload), ) if err != nil { return fmt.Errorf("upsert cve %s: %w", row.CveID, err) @@ -47,10 +54,17 @@ func (r *Repo) Upsert(ctx context.Context, row Row) error { return nil } -func (r *Repo) UpdateEPSS(ctx context.Context, cveID string, score, percentile float64) error { - _, err := r.db.ExecContext(ctx, +func (r *Repo) UpdateEPSS( + ctx context.Context, + cveID string, + score, percentile float64, +) error { + _, err := r.db.ExecContext( + ctx, `UPDATE cve_events SET epss_score = $2, epss_percentile = $3 WHERE cve_id = $1`, - cveID, score, percentile, + cveID, + score, + percentile, ) if err != nil { return fmt.Errorf("update epss %s: %w", cveID, err) @@ -70,7 +84,10 @@ func (r *Repo) Get(ctx context.Context, cveID string) (Row, error) { return row, nil } -func (r *Repo) RecentByLastModified(ctx context.Context, limit int) ([]Row, error) { +func (r *Repo) RecentByLastModified( + ctx context.Context, + limit int, +) ([]Row, error) { var rows []Row err := r.db.SelectContext(ctx, &rows, ` SELECT cve_id, published, last_modified, severity, cvss_score, diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/dshield/client.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/dshield/client.go index a68c17bc..d2c79d22 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/dshield/client.go +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/dshield/client.go @@ -68,7 +68,12 @@ func (c *Client) FetchAll(ctx context.Context) ([]SnapshotPayload, error) { Kind string Path string }{ - {KindTopPorts, "/api/topports/records/" + strconv.Itoa(c.topN) + "/" + yesterday + "?json"}, + { + KindTopPorts, + "/api/topports/records/" + strconv.Itoa( + c.topN, + ) + "/" + yesterday + "?json", + }, {KindTopIPs, "/api/topips/?json"}, {KindDailySummary, "/api/dailysummary/" + yesterday + "/?json"}, } diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/dshield/client_test.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/dshield/client_test.go index 74881918..326f764c 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/dshield/client_test.go +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/dshield/client_test.go @@ -21,26 +21,37 @@ import ( func newFixtureServer(t *testing.T) *httptest.Server { t.Helper() mux := http.NewServeMux() - mux.HandleFunc("/api/topports/", func(w http.ResponseWriter, r *http.Request) { - require.Contains(t, r.URL.Path, "/api/topports/records/") - body, err := os.ReadFile(filepath.Join("testdata", "topports.json")) - require.NoError(t, err) - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write(body) - }) - mux.HandleFunc("/api/topips/", func(w http.ResponseWriter, _ *http.Request) { - body, err := os.ReadFile(filepath.Join("testdata", "topips.json")) - require.NoError(t, err) - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write(body) - }) - mux.HandleFunc("/api/dailysummary/", func(w http.ResponseWriter, r *http.Request) { - require.True(t, strings.HasPrefix(r.URL.Path, "/api/dailysummary/")) - body, err := os.ReadFile(filepath.Join("testdata", "dailysummary.json")) - require.NoError(t, err) - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write(body) - }) + mux.HandleFunc( + "/api/topports/", + func(w http.ResponseWriter, r *http.Request) { + require.Contains(t, r.URL.Path, "/api/topports/records/") + body, err := os.ReadFile(filepath.Join("testdata", "topports.json")) + require.NoError(t, err) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write(body) + }, + ) + mux.HandleFunc( + "/api/topips/", + func(w http.ResponseWriter, _ *http.Request) { + body, err := os.ReadFile(filepath.Join("testdata", "topips.json")) + require.NoError(t, err) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write(body) + }, + ) + mux.HandleFunc( + "/api/dailysummary/", + func(w http.ResponseWriter, r *http.Request) { + require.True(t, strings.HasPrefix(r.URL.Path, "/api/dailysummary/")) + body, err := os.ReadFile( + filepath.Join("testdata", "dailysummary.json"), + ) + require.NoError(t, err) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write(body) + }, + ) return httptest.NewServer(mux) } @@ -70,17 +81,26 @@ func TestClient_FetchAllReturnsThreeKinds(t *testing.T) { func TestClient_FetchAllUsesYesterdayDate(t *testing.T) { var topPortsPath, dailyPath string mux := http.NewServeMux() - mux.HandleFunc("/api/topports/", func(w http.ResponseWriter, r *http.Request) { - topPortsPath = r.URL.Path - _, _ = w.Write([]byte(`{}`)) - }) - mux.HandleFunc("/api/topips/", func(w http.ResponseWriter, _ *http.Request) { - _, _ = w.Write([]byte(`[]`)) - }) - mux.HandleFunc("/api/dailysummary/", func(w http.ResponseWriter, r *http.Request) { - dailyPath = r.URL.Path - _, _ = w.Write([]byte(`[]`)) - }) + mux.HandleFunc( + "/api/topports/", + func(w http.ResponseWriter, r *http.Request) { + topPortsPath = r.URL.Path + _, _ = w.Write([]byte(`{}`)) + }, + ) + mux.HandleFunc( + "/api/topips/", + func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`[]`)) + }, + ) + mux.HandleFunc( + "/api/dailysummary/", + func(w http.ResponseWriter, r *http.Request) { + dailyPath = r.URL.Path + _, _ = w.Write([]byte(`[]`)) + }, + ) srv := httptest.NewServer(mux) defer srv.Close() diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/dshield/collector.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/dshield/collector.go index ff2c4827..8de10ec1 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/dshield/collector.go +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/dshield/collector.go @@ -21,8 +21,23 @@ type Fetcher interface { FetchAll(ctx context.Context) ([]SnapshotPayload, error) } +type DShieldEnrichment struct { + Country string + Classification string + Actor string +} + +type Enricher interface { + Lookup(ctx context.Context, ip string) (DShieldEnrichment, error) +} + type Persister interface { - PutSnapshot(ctx context.Context, ts time.Time, kind string, payload json.RawMessage) error + PutSnapshot( + ctx context.Context, + ts time.Time, + kind string, + payload json.RawMessage, + ) error } type Emitter interface { @@ -30,8 +45,8 @@ type Emitter interface { } type StateRecorder interface { - RecordSuccess(ctx context.Context, name string, eventCount int64) error - RecordError(ctx context.Context, name, errMsg string) error + RecordSuccess(ctx context.Context, name string, eventCount int64) + RecordError(ctx context.Context, name, errMsg string) } type CollectorConfig struct { @@ -40,6 +55,7 @@ type CollectorConfig struct { Persister Persister Emitter Emitter State StateRecorder + Enricher Enricher Logger *slog.Logger } @@ -80,7 +96,7 @@ func (c *Collector) tick(ctx context.Context) { snaps, err := c.cfg.Fetcher.FetchAll(ctx) if err != nil { c.logger.Warn("dshield fetch failed", "err", err) - _ = c.cfg.State.RecordError(ctx, Name, err.Error()) + c.cfg.State.RecordError(ctx, Name, err.Error()) return } @@ -88,22 +104,31 @@ func (c *Collector) tick(ctx context.Context) { tsRaw, err := json.Marshal(now.Format(time.RFC3339Nano)) if err != nil { c.logger.Error("dshield marshal ts", "err", err) - _ = c.cfg.State.RecordError(ctx, Name, err.Error()) + c.cfg.State.RecordError(ctx, Name, err.Error()) return } merged := map[string]json.RawMessage{"ts": tsRaw} for _, s := range snaps { - if perr := c.cfg.Persister.PutSnapshot(ctx, now, s.Kind, s.Payload); perr != nil { + payload := s.Payload + if s.Kind == KindTopIPs { + payload = c.enrichSources(ctx, payload) + } + if perr := c.cfg.Persister.PutSnapshot( + ctx, + now, + s.Kind, + payload, + ); perr != nil { c.logger.Warn("dshield persist failed", "kind", s.Kind, "err", perr) } - merged[s.Kind] = s.Payload + merged[s.Kind] = payload } body, err := json.Marshal(merged) if err != nil { c.logger.Error("dshield marshal merged", "err", err) - _ = c.cfg.State.RecordError(ctx, Name, err.Error()) + c.cfg.State.RecordError(ctx, Name, err.Error()) return } @@ -113,5 +138,38 @@ func (c *Collector) tick(ctx context.Context) { Source: Name, Payload: json.RawMessage(body), }) - _ = c.cfg.State.RecordSuccess(ctx, Name, 1) + c.cfg.State.RecordSuccess(ctx, Name, 1) +} + +func (c *Collector) enrichSources( + ctx context.Context, + raw json.RawMessage, +) json.RawMessage { + if c.cfg.Enricher == nil { + return raw + } + var srcs []RawSource + if err := json.Unmarshal(raw, &srcs); err != nil { + return raw + } + enriched := make([]EnrichedSource, 0, len(srcs)) + for _, s := range srcs { + e := EnrichedSource{ + Rank: s.Rank, + Source: s.Source, + Reports: s.Reports, + Targets: s.Targets, + } + if info, err := c.cfg.Enricher.Lookup(ctx, s.Source); err == nil { + e.Country = info.Country + e.Classification = info.Classification + e.Actor = info.Actor + } + enriched = append(enriched, e) + } + out, err := json.Marshal(enriched) + if err != nil { + return raw + } + return out } diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/dshield/collector_test.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/dshield/collector_test.go index 4e43288a..ae292e28 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/dshield/collector_test.go +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/dshield/collector_test.go @@ -23,7 +23,9 @@ type fakeFetcher struct { err error } -func (f *fakeFetcher) FetchAll(_ context.Context) ([]dshield.SnapshotPayload, error) { +func (f *fakeFetcher) FetchAll( + _ context.Context, +) ([]dshield.SnapshotPayload, error) { f.mu.Lock() defer f.mu.Unlock() f.calls++ @@ -32,8 +34,14 @@ func (f *fakeFetcher) FetchAll(_ context.Context) ([]dshield.SnapshotPayload, er } return []dshield.SnapshotPayload{ {Kind: dshield.KindTopPorts, Payload: json.RawMessage(`[{"port":22}]`)}, - {Kind: dshield.KindTopIPs, Payload: json.RawMessage(`[{"ip":"1.2.3.4"}]`)}, - {Kind: dshield.KindDailySummary, Payload: json.RawMessage(`[{"records":42}]`)}, + { + Kind: dshield.KindTopIPs, + Payload: json.RawMessage(`[{"ip":"1.2.3.4"}]`), + }, + { + Kind: dshield.KindDailySummary, + Payload: json.RawMessage(`[{"records":42}]`), + }, }, nil } @@ -53,7 +61,12 @@ type fakePersister struct { rows []persistedRow } -func (p *fakePersister) PutSnapshot(_ context.Context, _ time.Time, kind string, body json.RawMessage) error { +func (p *fakePersister) PutSnapshot( + _ context.Context, + _ time.Time, + kind string, + body json.RawMessage, +) error { p.mu.Lock() defer p.mu.Unlock() p.rows = append(p.rows, persistedRow{Kind: kind, Body: body}) @@ -95,20 +108,18 @@ type recordingState struct { lastErr string } -func (s *recordingState) RecordSuccess(_ context.Context, _ string, n int64) error { +func (s *recordingState) RecordSuccess(_ context.Context, _ string, n int64) { s.mu.Lock() defer s.mu.Unlock() s.successes++ s.successN += n - return nil } -func (s *recordingState) RecordError(_ context.Context, _, msg string) error { +func (s *recordingState) RecordError(_ context.Context, _, msg string) { s.mu.Lock() defer s.mu.Unlock() s.failures++ s.lastErr = msg - return nil } func TestCollector_TickPersistsAllAndEmitsScanFirehose(t *testing.T) { @@ -125,7 +136,10 @@ func TestCollector_TickPersistsAllAndEmitsScanFirehose(t *testing.T) { State: st, }) - ctx, cancel := context.WithTimeout(context.Background(), 70*time.Millisecond) + ctx, cancel := context.WithTimeout( + context.Background(), + 70*time.Millisecond, + ) defer cancel() _ = c.Run(ctx) @@ -148,7 +162,7 @@ func TestCollector_TickPersistsAllAndEmitsScanFirehose(t *testing.T) { require.Contains(t, merged, "ts") } - require.Greater(t, st.successes, 0) + require.Positive(t, st.successes) require.Equal(t, 0, st.failures) } @@ -166,12 +180,15 @@ func TestCollector_FetchErrorRecordsState(t *testing.T) { State: st, }) - ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) + ctx, cancel := context.WithTimeout( + context.Background(), + 50*time.Millisecond, + ) defer cancel() _ = c.Run(ctx) - require.Equal(t, 0, len(prst.Rows())) - require.Equal(t, 0, len(emt.Events())) - require.Greater(t, st.failures, 0) + require.Empty(t, prst.Rows()) + require.Empty(t, emt.Events()) + require.Positive(t, st.failures) require.Contains(t, st.lastErr, "upstream 503") } diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/dshield/repo.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/dshield/repo.go index 0137e973..a4f711af 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/dshield/repo.go +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/dshield/repo.go @@ -24,7 +24,12 @@ type Repo struct { func NewRepo(db *sqlx.DB) *Repo { return &Repo{db: db} } -func (r *Repo) PutSnapshot(ctx context.Context, ts time.Time, kind string, payload json.RawMessage) error { +func (r *Repo) PutSnapshot( + ctx context.Context, + ts time.Time, + kind string, + payload json.RawMessage, +) error { _, err := r.db.ExecContext(ctx, ` INSERT INTO dshield_snapshots (ts, kind, payload) VALUES ($1, $2, $3) @@ -37,7 +42,11 @@ func (r *Repo) PutSnapshot(ctx context.Context, ts time.Time, kind string, paylo return nil } -func (r *Repo) LatestByKind(ctx context.Context, kind string, limit int) ([]Snapshot, error) { +func (r *Repo) LatestByKind( + ctx context.Context, + kind string, + limit int, +) ([]Snapshot, error) { var rows []Snapshot err := r.db.SelectContext(ctx, &rows, ` SELECT ts, kind, payload FROM dshield_snapshots diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/dshield/repo_test.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/dshield/repo_test.go index 5595a0c3..4e87d9d3 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/dshield/repo_test.go +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/dshield/repo_test.go @@ -95,9 +95,18 @@ func TestRepo_LatestByKindIsOrderedDesc(t *testing.T) { t2 := time.Now().UTC().Add(-1 * time.Hour).Truncate(time.Second) t3 := time.Now().UTC().Truncate(time.Second) - require.NoError(t, repo.PutSnapshot(ctx, t1, "sources", json.RawMessage(`[{"i":1}]`))) - require.NoError(t, repo.PutSnapshot(ctx, t3, "sources", json.RawMessage(`[{"i":3}]`))) - require.NoError(t, repo.PutSnapshot(ctx, t2, "sources", json.RawMessage(`[{"i":2}]`))) + require.NoError( + t, + repo.PutSnapshot(ctx, t1, "sources", json.RawMessage(`[{"i":1}]`)), + ) + require.NoError( + t, + repo.PutSnapshot(ctx, t3, "sources", json.RawMessage(`[{"i":3}]`)), + ) + require.NoError( + t, + repo.PutSnapshot(ctx, t2, "sources", json.RawMessage(`[{"i":2}]`)), + ) rows, err := repo.LatestByKind(ctx, "sources", 3) require.NoError(t, err) diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/dshield/types.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/dshield/types.go new file mode 100644 index 00000000..5334b7ab --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/dshield/types.go @@ -0,0 +1,21 @@ +// ©AngelaMos | 2026 +// types.go + +package dshield + +type RawSource struct { + Rank int `json:"rank"` + Source string `json:"source"` + Reports int `json:"reports"` + Targets int `json:"targets"` +} + +type EnrichedSource struct { + Rank int `json:"rank"` + Source string `json:"source"` + Reports int `json:"reports"` + Targets int `json:"targets"` + Country string `json:"country,omitempty"` + Classification string `json:"classification,omitempty"` + Actor string `json:"actor,omitempty"` +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/gdelt/baseline_test.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/gdelt/baseline_test.go index cdd383b9..c8c33760 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/gdelt/baseline_test.go +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/gdelt/baseline_test.go @@ -22,7 +22,13 @@ func TestBaseline_StableThenSpikeReturnsHighZ(t *testing.T) { b := gdelt.NewThemeState(96) now := time.Now().UTC() for i := 0; i < 96; i++ { - b.Push(gdelt.Bucket{Score: now.Add(-time.Duration(i) * 15 * time.Minute).UnixMilli(), Count: 100 + (i % 10)}) + b.Push( + gdelt.Bucket{ + Score: now.Add(-time.Duration(i) * 15 * time.Minute). + UnixMilli(), + Count: 100 + (i % 10), + }, + ) } z := b.ZScore(500) require.Greater(t, z, 3.0) @@ -32,7 +38,13 @@ func TestBaseline_StableThenNormalReturnsLowZ(t *testing.T) { b := gdelt.NewThemeState(96) now := time.Now().UTC() for i := 0; i < 96; i++ { - b.Push(gdelt.Bucket{Score: now.Add(-time.Duration(i) * 15 * time.Minute).UnixMilli(), Count: 100 + (i % 10)}) + b.Push( + gdelt.Bucket{ + Score: now.Add(-time.Duration(i) * 15 * time.Minute). + UnixMilli(), + Count: 100 + (i % 10), + }, + ) } z := b.ZScore(108) require.Less(t, z, 3.0) @@ -50,7 +62,13 @@ func TestBaseline_TinyStdDevDoesNotDivByZero(t *testing.T) { b := gdelt.NewThemeState(96) now := time.Now().UTC() for i := 0; i < 96; i++ { - b.Push(gdelt.Bucket{Score: now.Add(-time.Duration(i) * 15 * time.Minute).UnixMilli(), Count: 100}) + b.Push( + gdelt.Bucket{ + Score: now.Add(-time.Duration(i) * 15 * time.Minute). + UnixMilli(), + Count: 100, + }, + ) } z := b.ZScore(101) require.False(t, math.IsInf(z, 0)) diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/gdelt/client.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/gdelt/client.go index 76d6d1e5..496bd12f 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/gdelt/client.go +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/gdelt/client.go @@ -7,8 +7,8 @@ import ( "context" "encoding/json" "fmt" + "io" "net/url" - "strings" "time" "golang.org/x/time/rate" @@ -74,7 +74,10 @@ type rawTimeline struct { } `json:"timeline"` } -func (c *Client) FetchTheme(ctx context.Context, theme string) ([]ThemeBucket, error) { +func (c *Client) FetchTheme( + ctx context.Context, + theme string, +) ([]ThemeBucket, error) { q := url.Values{} q.Set("query", "theme:"+theme) q.Set("mode", "timelinevol") @@ -88,20 +91,13 @@ func (c *Client) FetchTheme(ctx context.Context, theme string) ([]ThemeBucket, e } defer func() { _ = resp.Body.Close() }() - body := strings.Builder{} - buf := make([]byte, 4096) - for { - n, rerr := resp.Body.Read(buf) - if n > 0 { - body.Write(buf[:n]) - } - if rerr != nil { - break - } + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("read gdelt theme %s body: %w", theme, err) } var raw rawTimeline - if err := json.Unmarshal([]byte(body.String()), &raw); err != nil { + if err := json.Unmarshal(body, &raw); err != nil { return nil, fmt.Errorf("decode gdelt theme %s: %w", theme, err) } if len(raw.Timeline) == 0 { diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/gdelt/client_test.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/gdelt/client_test.go index b3f02d6e..6bcca56a 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/gdelt/client_test.go +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/gdelt/client_test.go @@ -20,13 +20,19 @@ func TestClient_FetchThemeDecodesBuckets(t *testing.T) { body, err := os.ReadFile("testdata/timelinevol.json") require.NoError(t, err) - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - require.Equal(t, "/api/v2/doc/doc", r.URL.Path) - require.Equal(t, "theme:NATURAL_DISASTER", r.URL.Query().Get("query")) - require.Equal(t, "timelinevol", r.URL.Query().Get("mode")) - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write(body) - })) + srv := httptest.NewServer( + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/api/v2/doc/doc", r.URL.Path) + require.Equal( + t, + "theme:NATURAL_DISASTER", + r.URL.Query().Get("query"), + ) + require.Equal(t, "timelinevol", r.URL.Query().Get("mode")) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write(body) + }), + ) defer srv.Close() c := gdelt.NewClient(gdelt.ClientConfig{BaseURL: srv.URL}) @@ -38,7 +44,7 @@ func TestClient_FetchThemeDecodesBuckets(t *testing.T) { require.GreaterOrEqual(t, len(buckets), 1) for _, b := range buckets { require.False(t, b.Time.IsZero()) - require.Greater(t, b.Count, 0) + require.Positive(t, b.Count) require.Equal(t, "NATURAL_DISASTER", b.Theme) } } diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/gdelt/collector.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/gdelt/collector.go index cf1d491b..d23c1baa 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/gdelt/collector.go +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/gdelt/collector.go @@ -28,7 +28,7 @@ type Fetcher interface { } type Repository interface { - Insert(ctx context.Context, row SpikeRow) error + Insert(ctx context.Context, row SpikeRow) (inserted bool, err error) } type Emitter interface { @@ -36,8 +36,8 @@ type Emitter interface { } type StateRecorder interface { - RecordSuccess(ctx context.Context, name string, eventCount int64) error - RecordError(ctx context.Context, name, errMsg string) error + RecordSuccess(ctx context.Context, name string, eventCount int64) + RecordError(ctx context.Context, name, errMsg string) } type CollectorConfig struct { @@ -55,7 +55,6 @@ type Collector struct { cfg CollectorConfig logger *slog.Logger baselines map[string]*ThemeState - emitted map[string]bool } func NewCollector(cfg CollectorConfig) *Collector { @@ -75,7 +74,6 @@ func NewCollector(cfg CollectorConfig) *Collector { cfg: cfg, logger: cfg.Logger, baselines: make(map[string]*ThemeState, len(cfg.Themes)), - emitted: make(map[string]bool), } for _, t := range cfg.Themes { c.baselines[t] = NewThemeState(cfg.BaselineCap) @@ -107,7 +105,7 @@ func (c *Collector) tick(ctx context.Context) { buckets, err := c.cfg.Fetcher.FetchTheme(ctx, theme) if err != nil { c.logger.Warn("gdelt fetch", "theme", theme, "err", err) - _ = c.cfg.State.RecordError(ctx, Name, err.Error()) + c.cfg.State.RecordError(ctx, Name, err.Error()) hadError = true continue } @@ -120,26 +118,41 @@ func (c *Collector) tick(ctx context.Context) { continue } id := spikeID(theme, b.Time) - if c.emitted[id] { - continue - } - c.emitted[id] = true - payload, _ := json.Marshal(map[string]any{ + payload, perr := json.Marshal(map[string]any{ "theme": theme, "time": b.Time, "count": b.Count, "zscore": z, }) + if perr != nil { + c.logger.Warn( + "gdelt marshal payload", + "theme", + theme, + "err", + perr, + ) + continue + } row := SpikeRow{ ID: id, Theme: theme, OccurredAt: b.Time, - Headline: fmt.Sprintf("Theme spike: %s (z=%.2f, count=%d)", theme, z, b.Count), - Payload: payload, + Headline: fmt.Sprintf( + "Theme spike: %s (z=%.2f, count=%d)", + theme, + z, + b.Count, + ), + Payload: payload, } - if err := c.cfg.Repo.Insert(ctx, row); err != nil { - c.logger.Warn("gdelt insert", "id", id, "err", err) + inserted, ierr := c.cfg.Repo.Insert(ctx, row) + if ierr != nil { + c.logger.Warn("gdelt insert", "id", id, "err", ierr) + continue + } + if !inserted { continue } c.cfg.Emitter.Emit(events.Event{ @@ -153,7 +166,7 @@ func (c *Collector) tick(ctx context.Context) { } if !hadError { - _ = c.cfg.State.RecordSuccess(ctx, Name, emitted) + c.cfg.State.RecordSuccess(ctx, Name, emitted) } } diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/gdelt/collector_test.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/gdelt/collector_test.go index a599adbf..79116887 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/gdelt/collector_test.go +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/gdelt/collector_test.go @@ -23,7 +23,10 @@ type fakeFetcher struct { err error } -func (f *fakeFetcher) FetchTheme(_ context.Context, theme string) ([]gdelt.ThemeBucket, error) { +func (f *fakeFetcher) FetchTheme( + _ context.Context, + theme string, +) ([]gdelt.ThemeBucket, error) { f.mu.Lock() defer f.mu.Unlock() f.calls++ @@ -36,13 +39,21 @@ func (f *fakeFetcher) FetchTheme(_ context.Context, theme string) ([]gdelt.Theme type fakeRepo struct { mu sync.Mutex inserts []gdelt.SpikeRow + seen map[string]struct{} } -func (r *fakeRepo) Insert(_ context.Context, row gdelt.SpikeRow) error { +func (r *fakeRepo) Insert(_ context.Context, row gdelt.SpikeRow) (bool, error) { r.mu.Lock() defer r.mu.Unlock() + if r.seen == nil { + r.seen = map[string]struct{}{} + } + if _, ok := r.seen[row.ID]; ok { + return false, nil + } + r.seen[row.ID] = struct{}{} r.inserts = append(r.inserts, row) - return nil + return true, nil } func (r *fakeRepo) Inserts() int { @@ -74,18 +85,16 @@ type recordingState struct { failures int } -func (s *recordingState) RecordSuccess(_ context.Context, _ string, _ int64) error { +func (s *recordingState) RecordSuccess(_ context.Context, _ string, _ int64) { s.mu.Lock() defer s.mu.Unlock() s.successes++ - return nil } -func (s *recordingState) RecordError(_ context.Context, _, _ string) error { +func (s *recordingState) RecordError(_ context.Context, _, _ string) { s.mu.Lock() defer s.mu.Unlock() s.failures++ - return nil } func TestCollector_NoBaselineNoSpike(t *testing.T) { @@ -108,13 +117,21 @@ func TestCollector_NoBaselineNoSpike(t *testing.T) { State: st, }) - ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) + ctx, cancel := context.WithTimeout( + context.Background(), + 50*time.Millisecond, + ) defer cancel() _ = c.Run(ctx) - require.Equal(t, 0, repo.Inserts(), "first observation has no baseline → cannot detect spike") + require.Equal( + t, + 0, + repo.Inserts(), + "first observation has no baseline → cannot detect spike", + ) require.Equal(t, 0, emt.Count()) - require.Greater(t, st.successes, 0) + require.Positive(t, st.successes) } func TestCollector_StableBaselinePlusSpikeEmitsOnce(t *testing.T) { @@ -127,7 +144,11 @@ func TestCollector_StableBaselinePlusSpikeEmitsOnce(t *testing.T) { Count: 100 + i, }) } - spikeBucket := gdelt.ThemeBucket{Theme: "X", Time: base.Add(15 * 15 * time.Minute), Count: 5000} + spikeBucket := gdelt.ThemeBucket{ + Theme: "X", + Time: base.Add(15 * 15 * time.Minute), + Count: 5000, + } buckets := append(stable, spikeBucket) ftch := &fakeFetcher{buckets: map[string][]gdelt.ThemeBucket{"X": buckets}} @@ -145,7 +166,10 @@ func TestCollector_StableBaselinePlusSpikeEmitsOnce(t *testing.T) { State: st, }) - ctx, cancel := context.WithTimeout(context.Background(), 80*time.Millisecond) + ctx, cancel := context.WithTimeout( + context.Background(), + 80*time.Millisecond, + ) defer cancel() _ = c.Run(ctx) @@ -172,10 +196,13 @@ func TestCollector_FetchErrorsRecordsState(t *testing.T) { State: st, }) - ctx, cancel := context.WithTimeout(context.Background(), 60*time.Millisecond) + ctx, cancel := context.WithTimeout( + context.Background(), + 60*time.Millisecond, + ) defer cancel() _ = c.Run(ctx) require.Equal(t, 0, repo.Inserts()) - require.Greater(t, st.failures, 0) + require.Positive(t, st.failures) } diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/gdelt/repo.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/gdelt/repo.go index 93a09c51..b7320184 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/gdelt/repo.go +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/gdelt/repo.go @@ -5,7 +5,9 @@ package gdelt import ( "context" + "database/sql" "encoding/json" + "errors" "fmt" "time" @@ -30,15 +32,27 @@ type Repo struct { func NewRepo(db *sqlx.DB) *Repo { return &Repo{db: db} } -func (r *Repo) Insert(ctx context.Context, row SpikeRow) error { - _, err := r.db.ExecContext(ctx, ` +func (r *Repo) Insert(ctx context.Context, row SpikeRow) (bool, error) { + var id string + err := r.db.GetContext( + ctx, + &id, + ` INSERT INTO world_events (id, source, occurred_at, headline, payload) VALUES ($1, $2, $3, $4, $5) - ON CONFLICT (id) DO NOTHING`, - row.ID, sourceGDELTSpike, row.OccurredAt, row.Headline, []byte(row.Payload), + ON CONFLICT (id) DO NOTHING + RETURNING id`, + row.ID, + sourceGDELTSpike, + row.OccurredAt, + row.Headline, + []byte(row.Payload), ) - if err != nil { - return fmt.Errorf("insert gdelt spike %s: %w", row.ID, err) + if errors.Is(err, sql.ErrNoRows) { + return false, nil } - return nil + if err != nil { + return false, fmt.Errorf("insert gdelt spike %s: %w", row.ID, err) + } + return true, nil } diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/heartbeat/collector_test.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/heartbeat/collector_test.go index 5be60a8b..1ecf6eba 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/heartbeat/collector_test.go +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/heartbeat/collector_test.go @@ -45,7 +45,10 @@ func TestCollector_EmitsAtInterval(t *testing.T) { Emitter: emitter, }) - ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond) + ctx, cancel := context.WithTimeout( + context.Background(), + 200*time.Millisecond, + ) defer cancel() err := c.Run(ctx) @@ -64,9 +67,17 @@ func TestCollector_EmitsImmediatelyOnStart(t *testing.T) { Emitter: emitter, }) - ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) + ctx, cancel := context.WithTimeout( + context.Background(), + 50*time.Millisecond, + ) defer cancel() _ = c.Run(ctx) - require.Equal(t, 1, emitter.Count(), "should emit one tick immediately on start") + require.Equal( + t, + 1, + emitter.Count(), + "should emit one tick immediately on start", + ) } diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/iss/client.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/iss/client.go index 364ca26e..24a7c998 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/iss/client.go +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/iss/client.go @@ -6,6 +6,7 @@ package iss import ( "context" "fmt" + "io" "strings" "time" @@ -97,21 +98,17 @@ func (c *Client) FetchTLE(ctx context.Context) (TLE, error) { } defer func() { _ = resp.Body.Close() }() - body := strings.Builder{} - buf := make([]byte, 1024) - for { - n, rerr := resp.Body.Read(buf) - if n > 0 { - body.Write(buf[:n]) - } - if rerr != nil { - break - } + body, err := io.ReadAll(resp.Body) + if err != nil { + return TLE{}, fmt.Errorf("read iss tle body: %w", err) } - lines := strings.Split(strings.TrimSpace(body.String()), "\n") + lines := strings.Split(strings.TrimSpace(string(body)), "\n") if len(lines) < 3 { - return TLE{}, fmt.Errorf("iss tle: expected 3 lines, got %d", len(lines)) + return TLE{}, fmt.Errorf( + "iss tle: expected 3 lines, got %d", + len(lines), + ) } return TLE{ Line1: strings.TrimRight(lines[1], " \r"), diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/iss/collector.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/iss/collector.go index 36b27b4c..3c52abed 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/iss/collector.go +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/iss/collector.go @@ -33,8 +33,8 @@ type Emitter interface { } type StateRecorder interface { - RecordSuccess(ctx context.Context, name string, eventCount int64) error - RecordError(ctx context.Context, name, errMsg string) error + RecordSuccess(ctx context.Context, name string, eventCount int64) + RecordError(ctx context.Context, name, errMsg string) } type CollectorConfig struct { @@ -92,7 +92,7 @@ func (c *Collector) tickPosition(ctx context.Context) { p, err := c.cfg.Fetcher.FetchPosition(ctx) if err != nil { c.logger.Warn("iss position fetch", "err", err) - _ = c.cfg.State.RecordError(ctx, Name, err.Error()) + c.cfg.State.RecordError(ctx, Name, err.Error()) return } body, _ := json.Marshal(p) @@ -102,18 +102,18 @@ func (c *Collector) tickPosition(ctx context.Context) { Source: Name, Payload: json.RawMessage(body), }) - _ = c.cfg.State.RecordSuccess(ctx, Name, 1) + c.cfg.State.RecordSuccess(ctx, Name, 1) } func (c *Collector) tickTLE(ctx context.Context) { tle, err := c.cfg.Fetcher.FetchTLE(ctx) if err != nil { c.logger.Warn("iss tle fetch", "err", err) - _ = c.cfg.State.RecordError(ctx, Name, err.Error()) + c.cfg.State.RecordError(ctx, Name, err.Error()) return } if err := c.cfg.TLEStore.Save(ctx, tle); err != nil { c.logger.Warn("iss tle save", "err", err) - _ = c.cfg.State.RecordError(ctx, Name, err.Error()) + c.cfg.State.RecordError(ctx, Name, err.Error()) } } diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/iss/collector_test.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/iss/collector_test.go index 07141ca3..c65a81f6 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/iss/collector_test.go +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/iss/collector_test.go @@ -86,23 +86,28 @@ type recordingState struct { failures int } -func (s *recordingState) RecordSuccess(_ context.Context, _ string, _ int64) error { +func (s *recordingState) RecordSuccess(_ context.Context, _ string, _ int64) { s.mu.Lock() defer s.mu.Unlock() s.successes++ - return nil } -func (s *recordingState) RecordError(_ context.Context, _, _ string) error { +func (s *recordingState) RecordError(_ context.Context, _, _ string) { s.mu.Lock() defer s.mu.Unlock() s.failures++ - return nil } func TestCollector_PositionLoopEmitsAndRecords(t *testing.T) { ftch := &fakeFetcher{ - pos: iss.Position{Latitude: 10, Longitude: 20, Altitude: 420, Velocity: 27500, Timestamp: 1234, FetchedAt: time.Now().UTC()}, + pos: iss.Position{ + Latitude: 10, + Longitude: 20, + Altitude: 420, + Velocity: 27500, + Timestamp: 1234, + FetchedAt: time.Now().UTC(), + }, tle: iss.TLE{Line1: "1 25544U ...", Line2: "2 25544 ..."}, } store := &fakeStore{} @@ -118,21 +123,34 @@ func TestCollector_PositionLoopEmitsAndRecords(t *testing.T) { State: st, }) - ctx, cancel := context.WithTimeout(context.Background(), 70*time.Millisecond) + ctx, cancel := context.WithTimeout( + context.Background(), + 70*time.Millisecond, + ) defer cancel() _ = c.Run(ctx) - require.GreaterOrEqual(t, ftch.posN, 2, "position should fire multiple times") + require.GreaterOrEqual( + t, + ftch.posN, + 2, + "position should fire multiple times", + ) require.GreaterOrEqual(t, emt.Count(), 2) for _, ev := range emt.events { require.Equal(t, events.TopicISSPosition, ev.Topic) } - require.Greater(t, st.successes, 0) + require.Positive(t, st.successes) } func TestCollector_TLELoopSavesToStore(t *testing.T) { ftch := &fakeFetcher{ - pos: iss.Position{Latitude: 10, Longitude: 20, Altitude: 420, FetchedAt: time.Now().UTC()}, + pos: iss.Position{ + Latitude: 10, + Longitude: 20, + Altitude: 420, + FetchedAt: time.Now().UTC(), + }, tle: iss.TLE{Line1: "1 25544U test", Line2: "2 25544 test"}, } store := &fakeStore{} @@ -148,11 +166,19 @@ func TestCollector_TLELoopSavesToStore(t *testing.T) { State: st, }) - ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) + ctx, cancel := context.WithTimeout( + context.Background(), + 100*time.Millisecond, + ) defer cancel() _ = c.Run(ctx) - require.GreaterOrEqual(t, store.saves, 2, "TLE should refresh multiple times in 100ms with 30ms interval") + require.GreaterOrEqual( + t, + store.saves, + 2, + "TLE should refresh multiple times in 100ms with 30ms interval", + ) require.Equal(t, "1 25544U test", store.saved.Line1) } @@ -174,10 +200,13 @@ func TestCollector_PositionFetchErrorRecordsState(t *testing.T) { State: st, }) - ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) + ctx, cancel := context.WithTimeout( + context.Background(), + 50*time.Millisecond, + ) defer cancel() _ = c.Run(ctx) require.Equal(t, 0, emt.Count()) - require.Greater(t, st.failures, 0) + require.Positive(t, st.failures) } diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/iss/propagator.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/iss/propagator.go index c446ab6f..55c53976 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/iss/propagator.go +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/iss/propagator.go @@ -18,28 +18,68 @@ type Sat struct { func LoadTLE(line1, line2 string) (Sat, error) { if !strings.HasPrefix(line1, "1 ") || !strings.HasPrefix(line2, "2 ") { - return Sat{}, fmt.Errorf("invalid TLE: lines must start with '1 ' and '2 '") + return Sat{}, fmt.Errorf( + "invalid TLE: lines must start with '1 ' and '2 '", + ) } s := satellite.TLEToSat(line1, line2, satellite.GravityWGS84) if s.Error != 0 || s.ErrorStr != "" { - return Sat{}, fmt.Errorf("parse TLE: error %d / %s", s.Error, s.ErrorStr) + return Sat{}, fmt.Errorf( + "parse TLE: error %d / %s", + s.Error, + s.ErrorStr, + ) } return Sat{inner: s}, nil } func Propagate(s Sat, t time.Time) (lat, lon, altKm float64) { t = t.UTC() - pos, _ := satellite.Propagate(s.inner, t.Year(), int(t.Month()), t.Day(), t.Hour(), t.Minute(), t.Second()) - gmst := satellite.GSTimeFromDate(t.Year(), int(t.Month()), t.Day(), t.Hour(), t.Minute(), t.Second()) + pos, _ := satellite.Propagate( + s.inner, + t.Year(), + int(t.Month()), + t.Day(), + t.Hour(), + t.Minute(), + t.Second(), + ) + gmst := satellite.GSTimeFromDate( + t.Year(), + int(t.Month()), + t.Day(), + t.Hour(), + t.Minute(), + t.Second(), + ) altitude, _, ll := satellite.ECIToLLA(pos, gmst) deg := satellite.LatLongDeg(ll) return deg.Latitude, deg.Longitude, altitude } -func LookAngles(s Sat, t time.Time, observerLatDeg, observerLonDeg, observerAltKm float64) (azimuthDeg, elevationDeg, rangeKm float64) { +func LookAngles( + s Sat, + t time.Time, + observerLatDeg, observerLonDeg, observerAltKm float64, +) (azimuthDeg, elevationDeg, rangeKm float64) { t = t.UTC() - pos, _ := satellite.Propagate(s.inner, t.Year(), int(t.Month()), t.Day(), t.Hour(), t.Minute(), t.Second()) - jday := satellite.JDay(t.Year(), int(t.Month()), t.Day(), t.Hour(), t.Minute(), t.Second()) + pos, _ := satellite.Propagate( + s.inner, + t.Year(), + int(t.Month()), + t.Day(), + t.Hour(), + t.Minute(), + t.Second(), + ) + jday := satellite.JDay( + t.Year(), + int(t.Month()), + t.Day(), + t.Hour(), + t.Minute(), + t.Second(), + ) obs := satellite.LatLong{ Latitude: observerLatDeg * math.Pi / 180, Longitude: observerLonDeg * math.Pi / 180, diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/iss/propagator_test.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/iss/propagator_test.go index 3317e467..ed4e981e 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/iss/propagator_test.go +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/iss/propagator_test.go @@ -19,8 +19,19 @@ func loadTLE(t *testing.T) (string, string) { body, err := os.ReadFile("testdata/tle.txt") require.NoError(t, err) lines := strings.Split(strings.TrimSpace(string(body)), "\n") - require.GreaterOrEqual(t, len(lines), 3, "TLE file must have name + 2 element lines") - return strings.TrimRight(lines[1], " \r"), strings.TrimRight(lines[2], " \r") + require.GreaterOrEqual( + t, + len(lines), + 3, + "TLE file must have name + 2 element lines", + ) + return strings.TrimRight( + lines[1], + " \r", + ), strings.TrimRight( + lines[2], + " \r", + ) } func TestPropagator_PositionWithinReasonableBounds(t *testing.T) { @@ -33,7 +44,12 @@ func TestPropagator_PositionWithinReasonableBounds(t *testing.T) { require.True(t, lat >= -90 && lat <= 90, "lat=%f", lat) require.True(t, lon >= -180 && lon <= 180, "lon=%f", lon) - require.True(t, alt >= 350 && alt <= 500, "ISS altitude is roughly 380-420 km, got %f", alt) + require.True( + t, + alt >= 350 && alt <= 500, + "ISS altitude is roughly 380-420 km, got %f", + alt, + ) } func TestPropagator_LookAnglesInValidRange(t *testing.T) { diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/kev/client_test.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/kev/client_test.go index fc32d4ea..34beac94 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/kev/client_test.go +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/kev/client_test.go @@ -16,12 +16,14 @@ import ( ) func TestKEVClient_FetchCatalogDecodes(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - body, err := os.ReadFile("testdata/kev_catalog.json") - require.NoError(t, err) - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write(body) - })) + srv := httptest.NewServer( + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + body, err := os.ReadFile("testdata/kev_catalog.json") + require.NoError(t, err) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write(body) + }), + ) defer srv.Close() c := kev.NewClient(kev.ClientConfig{URL: srv.URL}) @@ -32,9 +34,11 @@ func TestKEVClient_FetchCatalogDecodes(t *testing.T) { } func TestKEVClient_RejectsServerError(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusBadRequest) - })) + srv := httptest.NewServer( + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusBadRequest) + }), + ) defer srv.Close() c := kev.NewClient(kev.ClientConfig{URL: srv.URL}) diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/kev/collector.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/kev/collector.go index 167e3363..56914eeb 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/kev/collector.go +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/kev/collector.go @@ -32,8 +32,8 @@ type Emitter interface { } type StateRecorder interface { - RecordSuccess(ctx context.Context, name string, eventCount int64) error - RecordError(ctx context.Context, name, errMsg string) error + RecordSuccess(ctx context.Context, name string, eventCount int64) + RecordError(ctx context.Context, name, errMsg string) } type CollectorConfig struct { @@ -82,7 +82,7 @@ func (c *Collector) tick(ctx context.Context) { cat, err := c.cfg.Fetcher.FetchCatalog(ctx) if err != nil { c.logger.Warn("kev fetch", "err", err) - _ = c.cfg.State.RecordError(ctx, Name, err.Error()) + c.cfg.State.RecordError(ctx, Name, err.Error()) return } @@ -94,7 +94,7 @@ func (c *Collector) tick(ctx context.Context) { known, err := c.cfg.Repo.KnownIDs(ctx, ids) if err != nil { c.logger.Warn("kev known ids", "err", err) - _ = c.cfg.State.RecordError(ctx, Name, err.Error()) + c.cfg.State.RecordError(ctx, Name, err.Error()) return } @@ -136,5 +136,5 @@ func (c *Collector) tick(ctx context.Context) { }) emitted++ } - _ = c.cfg.State.RecordSuccess(ctx, Name, emitted) + c.cfg.State.RecordSuccess(ctx, Name, emitted) } diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/kev/collector_test.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/kev/collector_test.go index faafc732..bcbd001a 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/kev/collector_test.go +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/kev/collector_test.go @@ -41,7 +41,10 @@ func (r *stubKEVRepo) Insert(_ context.Context, row kev.Row) error { return nil } -func (r *stubKEVRepo) KnownIDs(_ context.Context, ids []string) (map[string]bool, error) { +func (r *stubKEVRepo) KnownIDs( + _ context.Context, + ids []string, +) (map[string]bool, error) { r.mu.Lock() defer r.mu.Unlock() out := make(map[string]bool) @@ -82,8 +85,8 @@ func (e *stubKEVEmitter) Events() []events.Event { type stubKEVState struct{} -func (stubKEVState) RecordSuccess(context.Context, string, int64) error { return nil } -func (stubKEVState) RecordError(context.Context, string, string) error { return nil } +func (stubKEVState) RecordSuccess(context.Context, string, int64) {} +func (stubKEVState) RecordError(context.Context, string, string) {} func TestCollector_OnlyEmitsNewKEVs(t *testing.T) { ftch := &stubFetcher{cat: kev.Catalog{Vulnerabilities: []kev.Vulnerability{ @@ -101,7 +104,10 @@ func TestCollector_OnlyEmitsNewKEVs(t *testing.T) { State: stubKEVState{}, }) - ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) + ctx, cancel := context.WithTimeout( + context.Background(), + 50*time.Millisecond, + ) defer cancel() _ = c.Run(ctx) @@ -137,7 +143,10 @@ func TestCollector_EmptyKnownInsertsAll(t *testing.T) { State: stubKEVState{}, }) - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Millisecond) + ctx, cancel := context.WithTimeout( + context.Background(), + 30*time.Millisecond, + ) defer cancel() _ = c.Run(ctx) diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/kev/repo.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/kev/repo.go index 75edabc3..796ec5f1 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/kev/repo.go +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/kev/repo.go @@ -52,13 +52,20 @@ func (r *Repo) Insert(ctx context.Context, row Row) error { return nil } -func (r *Repo) KnownIDs(ctx context.Context, ids []string) (map[string]bool, error) { +func (r *Repo) KnownIDs( + ctx context.Context, + ids []string, +) (map[string]bool, error) { if len(ids) == 0 { return map[string]bool{}, nil } var found []string - if err := r.db.SelectContext(ctx, &found, - `SELECT cve_id FROM kev_entries WHERE cve_id = ANY($1::text[])`, pq.Array(ids)); err != nil { + if err := r.db.SelectContext( + ctx, + &found, + `SELECT cve_id FROM kev_entries WHERE cve_id = ANY($1::text[])`, + pq.Array(ids), + ); err != nil { return nil, fmt.Errorf("kev known ids: %w", err) } out := make(map[string]bool, len(found)) @@ -70,8 +77,32 @@ func (r *Repo) KnownIDs(ctx context.Context, ids []string) (map[string]bool, err func (r *Repo) Count(ctx context.Context) (int64, error) { var n int64 - if err := r.db.GetContext(ctx, &n, `SELECT count(*) FROM kev_entries`); err != nil { + if err := r.db.GetContext( + ctx, + &n, + `SELECT count(*) FROM kev_entries`, + ); err != nil { return 0, fmt.Errorf("kev count: %w", err) } return n, nil } + +func (r *Repo) RecentByDateAdded( + ctx context.Context, + limit int, +) ([]Row, error) { + if limit <= 0 { + limit = 50 + } + var rows []Row + err := r.db.SelectContext(ctx, &rows, ` + SELECT cve_id, vendor, product, vulnerability_name, date_added, due_date, + ransomware_use, payload + FROM kev_entries + ORDER BY date_added DESC, cve_id DESC + LIMIT $1`, limit) + if err != nil { + return nil, fmt.Errorf("recent kev: %w", err) + } + return rows, nil +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/kev/repo_test.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/kev/repo_test.go index 2cda2b07..fe459437 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/kev/repo_test.go +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/kev/repo_test.go @@ -69,7 +69,10 @@ func TestRepo_InsertAndKnownIDs(t *testing.T) { Payload: json.RawMessage(`{"cveID":"CVE-2024-3094"}`), })) - known, err := repo.KnownIDs(ctx, []string{"CVE-2024-3094", "CVE-2024-MISSING"}) + known, err := repo.KnownIDs( + ctx, + []string{"CVE-2024-3094", "CVE-2024-MISSING"}, + ) require.NoError(t, err) require.True(t, known["CVE-2024-3094"]) require.False(t, known["CVE-2024-MISSING"]) diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/ransomware/client.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/ransomware/client.go index eec9d944..61205939 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/ransomware/client.go +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/ransomware/client.go @@ -59,7 +59,12 @@ type Victim struct { } func (v Victim) ID() string { - h := sha256.Sum256([]byte(v.PostTitle + "|" + v.GroupName + "|" + v.Discovered.UTC().Format(time.RFC3339))) + h := sha256.Sum256( + []byte( + v.PostTitle + "|" + v.GroupName + "|" + v.Discovered.UTC(). + Format(time.RFC3339), + ), + ) return hex.EncodeToString(h[:idHashBytes]) } diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/ransomware/client_test.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/ransomware/client_test.go index 063ce9bb..47bae130 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/ransomware/client_test.go +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/ransomware/client_test.go @@ -17,12 +17,14 @@ import ( ) func TestClient_FetchRecentVictimsDecodes(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - body, err := os.ReadFile("testdata/recentvictims.json") - require.NoError(t, err) - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write(body) - })) + srv := httptest.NewServer( + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + body, err := os.ReadFile("testdata/recentvictims.json") + require.NoError(t, err) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write(body) + }), + ) defer srv.Close() c := ransomware.NewClient(ransomware.ClientConfig{BaseURL: srv.URL}) diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/ransomware/collector.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/ransomware/collector.go index 684c0483..74e158ae 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/ransomware/collector.go +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/ransomware/collector.go @@ -31,8 +31,8 @@ type Emitter interface { } type StateRecorder interface { - RecordSuccess(ctx context.Context, name string, eventCount int64) error - RecordError(ctx context.Context, name, errMsg string) error + RecordSuccess(ctx context.Context, name string, eventCount int64) + RecordError(ctx context.Context, name, errMsg string) } type CollectorConfig struct { @@ -81,7 +81,7 @@ func (c *Collector) tick(ctx context.Context) { vs, err := c.cfg.Fetcher.FetchRecent(ctx) if err != nil { c.logger.Warn("ransomware fetch", "err", err) - _ = c.cfg.State.RecordError(ctx, Name, err.Error()) + c.cfg.State.RecordError(ctx, Name, err.Error()) return } @@ -93,7 +93,7 @@ func (c *Collector) tick(ctx context.Context) { known, err := c.cfg.Repo.KnownIDs(ctx, ids) if err != nil { c.logger.Warn("ransomware known ids", "err", err) - _ = c.cfg.State.RecordError(ctx, Name, err.Error()) + c.cfg.State.RecordError(ctx, Name, err.Error()) return } @@ -129,5 +129,5 @@ func (c *Collector) tick(ctx context.Context) { }) emitted++ } - _ = c.cfg.State.RecordSuccess(ctx, Name, emitted) + c.cfg.State.RecordSuccess(ctx, Name, emitted) } diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/ransomware/collector_test.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/ransomware/collector_test.go index 1651161f..6889b234 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/ransomware/collector_test.go +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/ransomware/collector_test.go @@ -19,7 +19,9 @@ type stubFetcher struct { victims []ransomware.Victim } -func (s *stubFetcher) FetchRecent(context.Context) ([]ransomware.Victim, error) { +func (s *stubFetcher) FetchRecent( + context.Context, +) ([]ransomware.Victim, error) { return s.victims, nil } @@ -40,7 +42,10 @@ func (r *stubRansomRepo) Insert(_ context.Context, row ransomware.Row) error { return nil } -func (r *stubRansomRepo) KnownIDs(_ context.Context, ids []string) (map[string]bool, error) { +func (r *stubRansomRepo) KnownIDs( + _ context.Context, + ids []string, +) (map[string]bool, error) { r.mu.Lock() defer r.mu.Unlock() out := make(map[string]bool) @@ -81,14 +86,26 @@ func (e *stubRansomEmitter) Events() []events.Event { type stubRansomState struct{} -func (stubRansomState) RecordSuccess(context.Context, string, int64) error { return nil } -func (stubRansomState) RecordError(context.Context, string, string) error { return nil } +func (stubRansomState) RecordSuccess(context.Context, string, int64) {} +func (stubRansomState) RecordError(context.Context, string, string) {} func TestCollector_OnlyEmitsNewVictims(t *testing.T) { now := time.Now().UTC() - known := ransomware.Victim{PostTitle: "Old", GroupName: "lockbit", Discovered: now.Add(-time.Hour)} - new1 := ransomware.Victim{PostTitle: "Acme", GroupName: "blackcat", Discovered: now} - new2 := ransomware.Victim{PostTitle: "Banco", GroupName: "play", Discovered: now} + known := ransomware.Victim{ + PostTitle: "Old", + GroupName: "lockbit", + Discovered: now.Add(-time.Hour), + } + new1 := ransomware.Victim{ + PostTitle: "Acme", + GroupName: "blackcat", + Discovered: now, + } + new2 := ransomware.Victim{ + PostTitle: "Banco", + GroupName: "play", + Discovered: now, + } ftch := &stubFetcher{victims: []ransomware.Victim{known, new1, new2}} repo := &stubRansomRepo{known: map[string]bool{known.ID(): true}} @@ -102,7 +119,10 @@ func TestCollector_OnlyEmitsNewVictims(t *testing.T) { State: stubRansomState{}, }) - ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) + ctx, cancel := context.WithTimeout( + context.Background(), + 50*time.Millisecond, + ) defer cancel() _ = c.Run(ctx) diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/ransomware/repo.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/ransomware/repo.go index 3c03d231..fd957f57 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/ransomware/repo.go +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/ransomware/repo.go @@ -30,12 +30,20 @@ type Repo struct { func NewRepo(db *sqlx.DB) *Repo { return &Repo{db: db} } func (r *Repo) Insert(ctx context.Context, row Row) error { - _, err := r.db.ExecContext(ctx, ` + _, err := r.db.ExecContext( + ctx, + ` INSERT INTO ransomware_victims (id, post_title, group_name, discovered_at, country, sector, payload) VALUES ($1, $2, $3, $4, $5, $6, $7) ON CONFLICT (id) DO NOTHING`, - row.ID, row.PostTitle, row.GroupName, row.DiscoveredAt, row.Country, row.Sector, []byte(row.Payload), + row.ID, + row.PostTitle, + row.GroupName, + row.DiscoveredAt, + row.Country, + row.Sector, + []byte(row.Payload), ) if err != nil { return fmt.Errorf("insert ransom %s: %w", row.ID, err) @@ -43,13 +51,20 @@ func (r *Repo) Insert(ctx context.Context, row Row) error { return nil } -func (r *Repo) KnownIDs(ctx context.Context, ids []string) (map[string]bool, error) { +func (r *Repo) KnownIDs( + ctx context.Context, + ids []string, +) (map[string]bool, error) { if len(ids) == 0 { return map[string]bool{}, nil } var found []string - if err := r.db.SelectContext(ctx, &found, - `SELECT id FROM ransomware_victims WHERE id = ANY($1::text[])`, pq.Array(ids)); err != nil { + if err := r.db.SelectContext( + ctx, + &found, + `SELECT id FROM ransomware_victims WHERE id = ANY($1::text[])`, + pq.Array(ids), + ); err != nil { return nil, fmt.Errorf("ransom known ids: %w", err) } out := make(map[string]bool, len(found)) @@ -58,3 +73,19 @@ func (r *Repo) KnownIDs(ctx context.Context, ids []string) (map[string]bool, err } return out, nil } + +func (r *Repo) Recent(ctx context.Context, limit int) ([]Row, error) { + if limit <= 0 { + limit = 50 + } + var rows []Row + err := r.db.SelectContext(ctx, &rows, ` + SELECT id, post_title, group_name, discovered_at, country, sector, payload + FROM ransomware_victims + ORDER BY discovered_at DESC + LIMIT $1`, limit) + if err != nil { + return nil, fmt.Errorf("recent ransomware: %w", err) + } + return rows, nil +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/state/repo.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/state/repo.go index f6852132..ef2a9963 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/state/repo.go +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/state/repo.go @@ -5,7 +5,7 @@ package state import ( "context" - "fmt" + "log/slog" "time" "github.com/jmoiron/sqlx" @@ -29,12 +29,26 @@ type Row struct { } type Repo struct { - db *sqlx.DB + db *sqlx.DB + logger *slog.Logger } -func NewRepo(db *sqlx.DB) *Repo { return &Repo{db: db} } +func NewRepo(db *sqlx.DB) *Repo { + return &Repo{db: db, logger: slog.Default()} +} -func (r *Repo) RecordSuccess(ctx context.Context, name string, eventCount int64) error { +func NewRepoWithLogger(db *sqlx.DB, logger *slog.Logger) *Repo { + if logger == nil { + logger = slog.Default() + } + return &Repo{db: db, logger: logger} +} + +func (r *Repo) RecordSuccess( + ctx context.Context, + name string, + eventCount int64, +) { _, err := r.db.ExecContext(ctx, ` INSERT INTO collector_state (name, state, last_success_at, last_event_count, updated_at) VALUES ($1, $2, now(), $3, now()) @@ -46,12 +60,12 @@ func (r *Repo) RecordSuccess(ctx context.Context, name string, eventCount int64) name, StateHealthy, eventCount, ) if err != nil { - return fmt.Errorf("upsert healthy %s: %w", name, err) + r.logger.Warn("collector_state record success failed", + "collector", name, "err", err) } - return nil } -func (r *Repo) RecordError(ctx context.Context, name, errMsg string) error { +func (r *Repo) RecordError(ctx context.Context, name, errMsg string) { _, err := r.db.ExecContext(ctx, ` INSERT INTO collector_state (name, state, last_error_at, last_error, updated_at) VALUES ($1, $2, now(), $3, now()) @@ -63,21 +77,21 @@ func (r *Repo) RecordError(ctx context.Context, name, errMsg string) error { name, StateDegraded, errMsg, ) if err != nil { - return fmt.Errorf("upsert degraded %s: %w", name, err) + r.logger.Warn("collector_state record error failed", + "collector", name, "err", err) } - return nil } -func (r *Repo) Get(ctx context.Context, name string) (Row, error) { +func (r *Repo) Get(ctx context.Context, name string) (Row, bool, error) { var row Row err := r.db.GetContext(ctx, &row, ` SELECT name, state, last_success_at, last_error_at, last_error, last_event_count, updated_at FROM collector_state WHERE name = $1`, name) if err != nil { - return Row{}, fmt.Errorf("get state %s: %w", name, err) + return Row{}, false, err } - return row, nil + return row, true, nil } func (r *Repo) All(ctx context.Context) ([]Row, error) { @@ -87,7 +101,7 @@ func (r *Repo) All(ctx context.Context) ([]Row, error) { last_event_count, updated_at FROM collector_state ORDER BY name`) if err != nil { - return nil, fmt.Errorf("select all collector_state: %w", err) + return nil, err } return rows, nil } diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/state/repo_test.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/state/repo_test.go index 0b186c40..96a68d89 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/state/repo_test.go +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/state/repo_test.go @@ -59,20 +59,22 @@ func TestRepo_RecordSuccessThenError(t *testing.T) { repo := state.NewRepo(db) ctx := context.Background() - require.NoError(t, repo.RecordSuccess(ctx, "dshield", 12)) + repo.RecordSuccess(ctx, "dshield", 12) - got, err := repo.Get(ctx, "dshield") + got, found, err := repo.Get(ctx, "dshield") require.NoError(t, err) + require.True(t, found) require.Equal(t, "dshield", got.Name) require.Equal(t, state.StateHealthy, got.State) require.EqualValues(t, 12, got.LastEventCount) require.NotNil(t, got.LastSuccessAt) require.WithinDuration(t, time.Now(), *got.LastSuccessAt, 5*time.Second) - require.NoError(t, repo.RecordError(ctx, "dshield", "upstream 503")) + repo.RecordError(ctx, "dshield", "upstream 503") - got, err = repo.Get(ctx, "dshield") + got, found, err = repo.Get(ctx, "dshield") require.NoError(t, err) + require.True(t, found) require.Equal(t, state.StateDegraded, got.State) require.Equal(t, "upstream 503", got.LastError) require.NotNil(t, got.LastErrorAt) @@ -84,11 +86,12 @@ func TestRepo_SuccessAccumulatesCount(t *testing.T) { repo := state.NewRepo(db) ctx := context.Background() - require.NoError(t, repo.RecordSuccess(ctx, "kev", 3)) - require.NoError(t, repo.RecordSuccess(ctx, "kev", 5)) + repo.RecordSuccess(ctx, "kev", 3) + repo.RecordSuccess(ctx, "kev", 5) - got, err := repo.Get(ctx, "kev") + got, found, err := repo.Get(ctx, "kev") require.NoError(t, err) + require.True(t, found) require.EqualValues(t, 8, got.LastEventCount) } @@ -97,9 +100,9 @@ func TestRepo_AllReturnsRowsSorted(t *testing.T) { repo := state.NewRepo(db) ctx := context.Background() - require.NoError(t, repo.RecordSuccess(ctx, "ransomware", 1)) - require.NoError(t, repo.RecordSuccess(ctx, "cve", 1)) - require.NoError(t, repo.RecordSuccess(ctx, "kev", 1)) + repo.RecordSuccess(ctx, "ransomware", 1) + repo.RecordSuccess(ctx, "cve", 1) + repo.RecordSuccess(ctx, "kev", 1) rows, err := repo.All(ctx) require.NoError(t, err) diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/swpc/client.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/swpc/client.go index 5a9104b1..4a293f94 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/swpc/client.go +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/swpc/client.go @@ -204,7 +204,10 @@ func (c *Client) FetchAlerts(ctx context.Context) ([]AlertItem, error) { return out, nil } -func (c *Client) fetchRowArray(ctx context.Context, path string) ([]map[string]string, error) { +func (c *Client) fetchRowArray( + ctx context.Context, + path string, +) ([]map[string]string, error) { var raw [][]any if err := c.hx.GetJSON(ctx, path, nil, &raw); err != nil { return nil, fmt.Errorf("fetch row-array %s: %w", path, err) diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/swpc/client_test.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/swpc/client_test.go index ccf699e8..3bb76f70 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/swpc/client_test.go +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/swpc/client_test.go @@ -17,18 +17,20 @@ import ( "github.com/carterperez-dev/monitor-the-situation/backend/internal/collectors/swpc" ) -func newFakeServer(t *testing.T, route string, fixture string) *httptest.Server { +func newFakeServer(t *testing.T, route, fixture string) *httptest.Server { t.Helper() body, err := os.ReadFile("testdata/" + fixture) require.NoError(t, err) - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if !strings.Contains(r.URL.Path, route) { - http.Error(w, "not found", http.StatusNotFound) - return - } - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write(body) - })) + srv := httptest.NewServer( + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !strings.Contains(r.URL.Path, route) { + http.Error(w, "not found", http.StatusNotFound) + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write(body) + }), + ) t.Cleanup(srv.Close) return srv } diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/swpc/collector.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/swpc/collector.go index b92177ac..f1062baf 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/swpc/collector.go +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/swpc/collector.go @@ -49,8 +49,8 @@ type Emitter interface { } type StateRecorder interface { - RecordSuccess(ctx context.Context, name string, eventCount int64) error - RecordError(ctx context.Context, name, errMsg string) error + RecordSuccess(ctx context.Context, name string, eventCount int64) + RecordError(ctx context.Context, name, errMsg string) } type CollectorConfig struct { @@ -115,28 +115,28 @@ func (c *Collector) tickFast(ctx context.Context) { if n, err := c.pushPlasma(ctx); err != nil { c.logger.Warn("swpc plasma", "err", err) - _ = c.cfg.State.RecordError(ctx, Name, err.Error()) + c.cfg.State.RecordError(ctx, Name, err.Error()) hadError = true } else { pushed += n } if n, err := c.pushMag(ctx); err != nil { c.logger.Warn("swpc mag", "err", err) - _ = c.cfg.State.RecordError(ctx, Name, err.Error()) + c.cfg.State.RecordError(ctx, Name, err.Error()) hadError = true } else { pushed += n } if n, err := c.pushXray(ctx); err != nil { c.logger.Warn("swpc xray", "err", err) - _ = c.cfg.State.RecordError(ctx, Name, err.Error()) + c.cfg.State.RecordError(ctx, Name, err.Error()) hadError = true } else { pushed += n } if n, err := c.pushAlerts(ctx); err != nil { c.logger.Warn("swpc alerts", "err", err) - _ = c.cfg.State.RecordError(ctx, Name, err.Error()) + c.cfg.State.RecordError(ctx, Name, err.Error()) hadError = true } else { pushed += n @@ -147,7 +147,7 @@ func (c *Collector) tickFast(ctx context.Context) { } if !hadError { - _ = c.cfg.State.RecordSuccess(ctx, Name, pushed) + c.cfg.State.RecordSuccess(ctx, Name, pushed) } } @@ -155,7 +155,7 @@ func (c *Collector) tickSlow(ctx context.Context) { n, err := c.pushKp(ctx) if err != nil { c.logger.Warn("swpc kp", "err", err) - _ = c.cfg.State.RecordError(ctx, Name, err.Error()) + c.cfg.State.RecordError(ctx, Name, err.Error()) return } if n > 0 { @@ -208,10 +208,19 @@ func (c *Collector) pushPlasma(ctx context.Context) (int64, error) { if err != nil { return 0, err } - if latest := lastNonZero(rows, func(r PlasmaTick) bool { return !r.TimeTag.IsZero() }); latest != nil { + if latest := lastNonZero( + rows, + func(r PlasmaTick) bool { return !r.TimeTag.IsZero() }, + ); latest != nil { c.latestPlasma = latest } - return pushAll(ctx, c.cfg.Ring, keyPlasma, rows, func(r PlasmaTick) int64 { return r.TimeTag.UnixMilli() }) + return pushAll( + ctx, + c.cfg.Ring, + keyPlasma, + rows, + func(r PlasmaTick) int64 { return r.TimeTag.UnixMilli() }, + ) } func (c *Collector) pushMag(ctx context.Context) (int64, error) { @@ -219,10 +228,19 @@ func (c *Collector) pushMag(ctx context.Context) (int64, error) { if err != nil { return 0, err } - if latest := lastNonZero(rows, func(r MagTick) bool { return !r.TimeTag.IsZero() }); latest != nil { + if latest := lastNonZero( + rows, + func(r MagTick) bool { return !r.TimeTag.IsZero() }, + ); latest != nil { c.latestMag = latest } - return pushAll(ctx, c.cfg.Ring, keyMag, rows, func(r MagTick) int64 { return r.TimeTag.UnixMilli() }) + return pushAll( + ctx, + c.cfg.Ring, + keyMag, + rows, + func(r MagTick) int64 { return r.TimeTag.UnixMilli() }, + ) } func (c *Collector) pushKp(ctx context.Context) (int64, error) { @@ -230,10 +248,19 @@ func (c *Collector) pushKp(ctx context.Context) (int64, error) { if err != nil { return 0, err } - if latest := lastNonZero(rows, func(r KpTick) bool { return !r.TimeTag.IsZero() }); latest != nil { + if latest := lastNonZero( + rows, + func(r KpTick) bool { return !r.TimeTag.IsZero() }, + ); latest != nil { c.latestKp = latest } - return pushAll(ctx, c.cfg.Ring, keyKp, rows, func(r KpTick) int64 { return r.TimeTag.UnixMilli() }) + return pushAll( + ctx, + c.cfg.Ring, + keyKp, + rows, + func(r KpTick) int64 { return r.TimeTag.UnixMilli() }, + ) } func (c *Collector) pushXray(ctx context.Context) (int64, error) { @@ -241,10 +268,19 @@ func (c *Collector) pushXray(ctx context.Context) (int64, error) { if err != nil { return 0, err } - if latest := lastNonZero(rows, func(r XrayTick) bool { return !r.TimeTag.IsZero() }); latest != nil { + if latest := lastNonZero( + rows, + func(r XrayTick) bool { return !r.TimeTag.IsZero() }, + ); latest != nil { c.latestXray = latest } - return pushAll(ctx, c.cfg.Ring, keyXray, rows, func(r XrayTick) int64 { return r.TimeTag.UnixMilli() }) + return pushAll( + ctx, + c.cfg.Ring, + keyXray, + rows, + func(r XrayTick) int64 { return r.TimeTag.UnixMilli() }, + ) } func (c *Collector) pushAlerts(ctx context.Context) (int64, error) { @@ -252,10 +288,22 @@ func (c *Collector) pushAlerts(ctx context.Context) (int64, error) { if err != nil { return 0, err } - return pushAll(ctx, c.cfg.Ring, keyAlerts, rows, func(r AlertItem) int64 { return r.IssueDatetime.UnixMilli() }) + return pushAll( + ctx, + c.cfg.Ring, + keyAlerts, + rows, + func(r AlertItem) int64 { return r.IssueDatetime.UnixMilli() }, + ) } -func pushAll[T any](ctx context.Context, ring Ring, key string, rows []T, score func(T) int64) (int64, error) { +func pushAll[T any]( + ctx context.Context, + ring Ring, + key string, + rows []T, + score func(T) int64, +) (int64, error) { pushed := int64(0) for _, r := range rows { s := score(r) diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/swpc/collector_test.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/swpc/collector_test.go index 1a950a13..dbfbec1b 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/swpc/collector_test.go +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/swpc/collector_test.go @@ -26,14 +26,28 @@ type fakeFetcher struct { err error } -func (f *fakeFetcher) FetchPlasma(_ context.Context) ([]swpc.PlasmaTick, error) { +func (f *fakeFetcher) FetchPlasma( + _ context.Context, +) ([]swpc.PlasmaTick, error) { return f.plasma, f.err } -func (f *fakeFetcher) FetchMag(_ context.Context) ([]swpc.MagTick, error) { return f.mag, f.err } -func (f *fakeFetcher) FetchKp(_ context.Context) ([]swpc.KpTick, error) { return f.kp, f.err } + +func (f *fakeFetcher) FetchMag( + _ context.Context, +) ([]swpc.MagTick, error) { + return f.mag, f.err +} + +func (f *fakeFetcher) FetchKp( + _ context.Context, +) ([]swpc.KpTick, error) { + return f.kp, f.err +} + func (f *fakeFetcher) FetchXray(_ context.Context) ([]swpc.XrayTick, error) { return f.xray, f.err } + func (f *fakeFetcher) FetchAlerts(_ context.Context) ([]swpc.AlertItem, error) { return f.alerts, f.err } @@ -43,7 +57,12 @@ type fakeRing struct { pushes map[string]int } -func (r *fakeRing) Push(_ context.Context, key string, _ int64, _ []byte) error { +func (r *fakeRing) Push( + _ context.Context, + key string, + _ int64, + _ []byte, +) error { r.mu.Lock() defer r.mu.Unlock() if r.pushes == nil { @@ -82,28 +101,32 @@ type recordingState struct { failures int } -func (s *recordingState) RecordSuccess(_ context.Context, _ string, _ int64) error { +func (s *recordingState) RecordSuccess(_ context.Context, _ string, _ int64) { s.mu.Lock() defer s.mu.Unlock() s.successes++ - return nil } -func (s *recordingState) RecordError(_ context.Context, _, _ string) error { +func (s *recordingState) RecordError(_ context.Context, _, _ string) { s.mu.Lock() defer s.mu.Unlock() s.failures++ - return nil } func TestCollector_FastTickPushesToRingsAndEmits(t *testing.T) { now := time.Now().UTC() ftch := &fakeFetcher{ - plasma: []swpc.PlasmaTick{{TimeTag: now, Density: "2.94", Speed: "450", Temperature: "93030"}}, - mag: []swpc.MagTick{{TimeTag: now, Bt: "5.6"}}, - xray: []swpc.XrayTick{{TimeTag: now, Flux: 1e-7, Energy: "0.1-0.8nm"}}, - alerts: []swpc.AlertItem{{ProductID: "TIIA", IssueDatetime: now, Message: "test alert"}}, - kp: []swpc.KpTick{{TimeTag: now, Kp: 3.0}}, + plasma: []swpc.PlasmaTick{ + {TimeTag: now, Density: "2.94", Speed: "450", Temperature: "93030"}, + }, + mag: []swpc.MagTick{{TimeTag: now, Bt: "5.6"}}, + xray: []swpc.XrayTick{ + {TimeTag: now, Flux: 1e-7, Energy: "0.1-0.8nm"}, + }, + alerts: []swpc.AlertItem{ + {ProductID: "TIIA", IssueDatetime: now, Message: "test alert"}, + }, + kp: []swpc.KpTick{{TimeTag: now, Kp: 3.0}}, } ring := &fakeRing{} emt := &fakeEmitter{} @@ -118,7 +141,10 @@ func TestCollector_FastTickPushesToRingsAndEmits(t *testing.T) { State: st, }) - ctx, cancel := context.WithTimeout(context.Background(), 80*time.Millisecond) + ctx, cancel := context.WithTimeout( + context.Background(), + 80*time.Millisecond, + ) defer cancel() _ = c.Run(ctx) @@ -132,7 +158,7 @@ func TestCollector_FastTickPushesToRingsAndEmits(t *testing.T) { for _, ev := range emt.events { require.Equal(t, events.TopicSpaceWeather, ev.Topic) } - require.Greater(t, st.successes, 0) + require.Positive(t, st.successes) require.Equal(t, 0, st.failures) } @@ -167,7 +193,10 @@ func TestCollector_EmitsRichPayloadWithLatestReadings(t *testing.T) { State: st, }) - ctx, cancel := context.WithTimeout(context.Background(), 60*time.Millisecond) + ctx, cancel := context.WithTimeout( + context.Background(), + 60*time.Millisecond, + ) defer cancel() _ = c.Run(ctx) @@ -205,10 +234,13 @@ func TestCollector_FetchErrorsRecordsState(t *testing.T) { State: st, }) - ctx, cancel := context.WithTimeout(context.Background(), 60*time.Millisecond) + ctx, cancel := context.WithTimeout( + context.Background(), + 60*time.Millisecond, + ) defer cancel() _ = c.Run(ctx) require.Equal(t, 0, ring.PushCount("swpc:plasma")) - require.Greater(t, st.failures, 0) + require.Positive(t, st.failures) } diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/usgs/client_test.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/usgs/client_test.go index 40d9d621..796bb6b1 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/usgs/client_test.go +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/usgs/client_test.go @@ -20,10 +20,12 @@ func TestClient_FetchDecodesFeatures(t *testing.T) { body, err := os.ReadFile("testdata/2_5_day.geojson") require.NoError(t, err) - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/geo+json") - _, _ = w.Write(body) - })) + srv := httptest.NewServer( + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/geo+json") + _, _ = w.Write(body) + }), + ) defer srv.Close() c := usgs.NewClient(usgs.ClientConfig{BaseURL: srv.URL}) diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/usgs/collector.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/usgs/collector.go index 68cdb5a5..7ce3734f 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/usgs/collector.go +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/usgs/collector.go @@ -32,8 +32,8 @@ type Emitter interface { } type StateRecorder interface { - RecordSuccess(ctx context.Context, name string, eventCount int64) error - RecordError(ctx context.Context, name, errMsg string) error + RecordSuccess(ctx context.Context, name string, eventCount int64) + RecordError(ctx context.Context, name, errMsg string) } type CollectorConfig struct { @@ -80,7 +80,7 @@ func (c *Collector) tick(ctx context.Context) { feed, err := c.cfg.Fetcher.Fetch(ctx) if err != nil { c.logger.Warn("usgs fetch", "err", err) - _ = c.cfg.State.RecordError(ctx, Name, err.Error()) + c.cfg.State.RecordError(ctx, Name, err.Error()) return } @@ -91,7 +91,7 @@ func (c *Collector) tick(ctx context.Context) { known, err := c.cfg.Repo.KnownIDs(ctx, ids) if err != nil { c.logger.Warn("usgs known ids", "err", err) - _ = c.cfg.State.RecordError(ctx, Name, err.Error()) + c.cfg.State.RecordError(ctx, Name, err.Error()) return } @@ -124,7 +124,7 @@ func (c *Collector) tick(ctx context.Context) { }) emitted++ } - _ = c.cfg.State.RecordSuccess(ctx, Name, emitted) + c.cfg.State.RecordSuccess(ctx, Name, emitted) } func coord(c []float64, i int) float64 { diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/usgs/collector_test.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/usgs/collector_test.go index d1711de0..9d9ee1c3 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/usgs/collector_test.go +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/usgs/collector_test.go @@ -50,7 +50,10 @@ func (r *fakeRepo) Upsert(_ context.Context, row usgs.Row) error { return nil } -func (r *fakeRepo) KnownIDs(_ context.Context, ids []string) (map[string]bool, error) { +func (r *fakeRepo) KnownIDs( + _ context.Context, + ids []string, +) (map[string]bool, error) { r.mu.Lock() defer r.mu.Unlock() out := make(map[string]bool, len(ids)) @@ -92,28 +95,50 @@ type recordingState struct { lastErr string } -func (s *recordingState) RecordSuccess(_ context.Context, _ string, _ int64) error { +func (s *recordingState) RecordSuccess(_ context.Context, _ string, _ int64) { s.mu.Lock() defer s.mu.Unlock() s.successes++ - return nil } -func (s *recordingState) RecordError(_ context.Context, _, msg string) error { +func (s *recordingState) RecordError(_ context.Context, _, msg string) { s.mu.Lock() defer s.mu.Unlock() s.failures++ s.lastErr = msg - return nil } func TestCollector_TickPersistsAndEmitsNewQuakes(t *testing.T) { feed := usgs.Feed{ Type: "FeatureCollection", Features: []usgs.Feature{ - {ID: "q1", Properties: usgs.Properties{Mag: 4.5, Place: "test 1", Time: time.Now().UnixMilli()}, Geometry: usgs.Geometry{Coordinates: []float64{-120, 49, 5}}}, - {ID: "q2", Properties: usgs.Properties{Mag: 6.5, Place: "test 2", Time: time.Now().UnixMilli()}, Geometry: usgs.Geometry{Coordinates: []float64{140, -30, 10}}}, - {ID: "q3", Properties: usgs.Properties{Mag: 3.0, Place: "test 3", Time: time.Now().UnixMilli()}, Geometry: usgs.Geometry{Coordinates: []float64{0, 0, 1}}}, + { + ID: "q1", + Properties: usgs.Properties{ + Mag: 4.5, + Place: "test 1", + Time: time.Now().UnixMilli(), + }, + Geometry: usgs.Geometry{Coordinates: []float64{-120, 49, 5}}, + }, + { + ID: "q2", + Properties: usgs.Properties{ + Mag: 6.5, + Place: "test 2", + Time: time.Now().UnixMilli(), + }, + Geometry: usgs.Geometry{Coordinates: []float64{140, -30, 10}}, + }, + { + ID: "q3", + Properties: usgs.Properties{ + Mag: 3.0, + Place: "test 3", + Time: time.Now().UnixMilli(), + }, + Geometry: usgs.Geometry{Coordinates: []float64{0, 0, 1}}, + }, }, } ftch := &fakeFetcher{feed: feed} @@ -129,17 +154,25 @@ func TestCollector_TickPersistsAndEmitsNewQuakes(t *testing.T) { State: st, }) - ctx, cancel := context.WithTimeout(context.Background(), 70*time.Millisecond) + ctx, cancel := context.WithTimeout( + context.Background(), + 70*time.Millisecond, + ) defer cancel() _ = c.Run(ctx) - require.GreaterOrEqual(t, repo.Upserts(), 3, "should upsert all 3 features at least once") + require.GreaterOrEqual( + t, + repo.Upserts(), + 3, + "should upsert all 3 features at least once", + ) require.GreaterOrEqual(t, emt.Count(), 3, "should emit 3 new-event events") for _, ev := range emt.events { require.Equal(t, events.TopicEarthquake, ev.Topic) require.Equal(t, usgs.Name, ev.Source) } - require.Greater(t, st.successes, 0) + require.Positive(t, st.successes) require.Equal(t, 0, st.failures) } @@ -157,20 +190,30 @@ func TestCollector_FetchErrorRecordsState(t *testing.T) { State: st, }) - ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) + ctx, cancel := context.WithTimeout( + context.Background(), + 50*time.Millisecond, + ) defer cancel() _ = c.Run(ctx) require.Equal(t, 0, repo.Upserts()) require.Equal(t, 0, emt.Count()) - require.Greater(t, st.failures, 0) + require.Positive(t, st.failures) require.Contains(t, st.lastErr, "upstream 503") } func TestCollector_KnownQuakesNotReEmitted(t *testing.T) { feed := usgs.Feed{ Features: []usgs.Feature{ - {ID: "qx", Properties: usgs.Properties{Mag: 4.5, Time: time.Now().UnixMilli()}, Geometry: usgs.Geometry{Coordinates: []float64{0, 0, 1}}}, + { + ID: "qx", + Properties: usgs.Properties{ + Mag: 4.5, + Time: time.Now().UnixMilli(), + }, + Geometry: usgs.Geometry{Coordinates: []float64{0, 0, 1}}, + }, }, } ftch := &fakeFetcher{feed: feed} @@ -186,7 +229,10 @@ func TestCollector_KnownQuakesNotReEmitted(t *testing.T) { State: st, }) - ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) + ctx, cancel := context.WithTimeout( + context.Background(), + 50*time.Millisecond, + ) defer cancel() _ = c.Run(ctx) diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/usgs/repo.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/usgs/repo.go index a1d585e7..ce53a573 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/usgs/repo.go +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/usgs/repo.go @@ -51,7 +51,10 @@ func (r *Repo) Upsert(ctx context.Context, row Row) error { return nil } -func (r *Repo) KnownIDs(ctx context.Context, ids []string) (map[string]bool, error) { +func (r *Repo) KnownIDs( + ctx context.Context, + ids []string, +) (map[string]bool, error) { out := make(map[string]bool, len(ids)) if len(ids) == 0 { return out, nil @@ -82,3 +85,19 @@ func (r *Repo) RecentByMag(ctx context.Context, limit int) ([]Row, error) { } return rows, nil } + +func (r *Repo) RecentByTime(ctx context.Context, limit int) ([]Row, error) { + if limit <= 0 { + limit = 50 + } + var rows []Row + err := r.db.SelectContext(ctx, &rows, ` + SELECT id, occurred_at, mag, place, geom_lon, geom_lat, depth_km, payload + FROM earthquakes + ORDER BY occurred_at DESC + LIMIT $1`, limit) + if err != nil { + return nil, fmt.Errorf("recent earthquakes by time: %w", err) + } + return rows, nil +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/usgs/repo_test.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/usgs/repo_test.go index 84c115e4..e17aa447 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/usgs/repo_test.go +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/usgs/repo_test.go @@ -107,9 +107,30 @@ func TestRepo_RecentByMagOrdered(t *testing.T) { now := time.Now().UTC() rows := []usgs.Row{ - {ID: "small", OccurredAt: now, Mag: 4.5, GeomLon: 0, GeomLat: 0, Payload: json.RawMessage(`{}`)}, - {ID: "huge", OccurredAt: now, Mag: 7.8, GeomLon: 0, GeomLat: 0, Payload: json.RawMessage(`{}`)}, - {ID: "mid", OccurredAt: now, Mag: 6.0, GeomLon: 0, GeomLat: 0, Payload: json.RawMessage(`{}`)}, + { + ID: "small", + OccurredAt: now, + Mag: 4.5, + GeomLon: 0, + GeomLat: 0, + Payload: json.RawMessage(`{}`), + }, + { + ID: "huge", + OccurredAt: now, + Mag: 7.8, + GeomLon: 0, + GeomLat: 0, + Payload: json.RawMessage(`{}`), + }, + { + ID: "mid", + OccurredAt: now, + Mag: 6.0, + GeomLon: 0, + GeomLat: 0, + Payload: json.RawMessage(`{}`), + }, } for _, r := range rows { require.NoError(t, repo.Upsert(ctx, r)) diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/wikipedia/client.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/wikipedia/client.go index 5ba0c445..955194e1 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/wikipedia/client.go +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/wikipedia/client.go @@ -6,6 +6,7 @@ package wikipedia import ( "context" "fmt" + "io" "net/url" "time" @@ -60,16 +61,9 @@ func (c *Client) Fetch(ctx context.Context) (Response, error) { } defer func() { _ = resp.Body.Close() }() - body := make([]byte, 0, 32*1024) - buf := make([]byte, 4*1024) - for { - n, rerr := resp.Body.Read(buf) - if n > 0 { - body = append(body, buf[:n]...) - } - if rerr != nil { - break - } + body, err := io.ReadAll(resp.Body) + if err != nil { + return Response{}, fmt.Errorf("read wikipedia ITN body: %w", err) } return DecodeResponse(body) } diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/wikipedia/client_test.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/wikipedia/client_test.go index 6732c28f..b6524f23 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/wikipedia/client_test.go +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/wikipedia/client_test.go @@ -20,13 +20,15 @@ func TestClient_FetchDecodesITNResponse(t *testing.T) { body, err := os.ReadFile("testdata/itn_response.json") require.NoError(t, err) - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - require.Equal(t, "/w/api.php", r.URL.Path) - require.Equal(t, "parse", r.URL.Query().Get("action")) - require.Equal(t, "Template:In_the_news", r.URL.Query().Get("page")) - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write(body) - })) + srv := httptest.NewServer( + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/w/api.php", r.URL.Path) + require.Equal(t, "parse", r.URL.Query().Get("action")) + require.Equal(t, "Template:In_the_news", r.URL.Query().Get("page")) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write(body) + }), + ) defer srv.Close() c := wikipedia.NewClient(wikipedia.ClientConfig{BaseURL: srv.URL}) diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/wikipedia/collector.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/wikipedia/collector.go index 2262ed88..a6c4cc85 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/wikipedia/collector.go +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/wikipedia/collector.go @@ -35,8 +35,8 @@ type Emitter interface { } type StateRecorder interface { - RecordSuccess(ctx context.Context, name string, eventCount int64) error - RecordError(ctx context.Context, name, errMsg string) error + RecordSuccess(ctx context.Context, name string, eventCount int64) + RecordError(ctx context.Context, name, errMsg string) } type CollectorConfig struct { @@ -83,18 +83,18 @@ func (c *Collector) tick(ctx context.Context) { resp, err := c.cfg.Fetcher.Fetch(ctx) if err != nil { c.logger.Warn("wikipedia fetch", "err", err) - _ = c.cfg.State.RecordError(ctx, Name, err.Error()) + c.cfg.State.RecordError(ctx, Name, err.Error()) return } last, found, err := c.cfg.Repo.LastRevID(ctx) if err != nil { c.logger.Warn("wikipedia revid lookup", "err", err) - _ = c.cfg.State.RecordError(ctx, Name, err.Error()) + c.cfg.State.RecordError(ctx, Name, err.Error()) return } if found && last == resp.RevID { - _ = c.cfg.State.RecordSuccess(ctx, Name, 0) + c.cfg.State.RecordSuccess(ctx, Name, 0) return } @@ -129,7 +129,7 @@ func (c *Collector) tick(ctx context.Context) { if err := c.cfg.Repo.RememberRevID(ctx, resp.RevID); err != nil { c.logger.Warn("wikipedia remember revid", "err", err) } - _ = c.cfg.State.RecordSuccess(ctx, Name, emitted) + c.cfg.State.RecordSuccess(ctx, Name, emitted) } func entryID(e ITNEntry) string { diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/wikipedia/collector_test.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/wikipedia/collector_test.go index 628fd9a8..15451c66 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/wikipedia/collector_test.go +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/wikipedia/collector_test.go @@ -84,24 +84,25 @@ type recordingState struct { failures int } -func (s *recordingState) RecordSuccess(_ context.Context, _ string, _ int64) error { +func (s *recordingState) RecordSuccess(_ context.Context, _ string, _ int64) { s.mu.Lock() defer s.mu.Unlock() s.successes++ - return nil } -func (s *recordingState) RecordError(_ context.Context, _, _ string) error { +func (s *recordingState) RecordError(_ context.Context, _, _ string) { s.mu.Lock() defer s.mu.Unlock() s.failures++ - return nil } func TestCollector_NewRevidInsertsAndEmits(t *testing.T) { resp := wikipedia.Response{ RevID: 999, - HTML: ``, + HTML: `
    ` + + `
  • A long enough ITN headline mentioning subject A for context.
  • ` + + `
  • Another long enough ITN headline referencing subject B in the news.
  • ` + + `
`, } ftch := &fakeFetcher{resp: resp} repo := &fakeRepo{} @@ -116,7 +117,10 @@ func TestCollector_NewRevidInsertsAndEmits(t *testing.T) { State: st, }) - ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) + ctx, cancel := context.WithTimeout( + context.Background(), + 50*time.Millisecond, + ) defer cancel() _ = c.Run(ctx) @@ -125,7 +129,7 @@ func TestCollector_NewRevidInsertsAndEmits(t *testing.T) { for _, ev := range emt.events { require.Equal(t, events.TopicWikipediaITN, ev.Topic) } - require.Greater(t, st.successes, 0) + require.Positive(t, st.successes) } func TestCollector_RevIDUnchangedSkipsInsert(t *testing.T) { @@ -146,13 +150,16 @@ func TestCollector_RevIDUnchangedSkipsInsert(t *testing.T) { State: st, }) - ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) + ctx, cancel := context.WithTimeout( + context.Background(), + 50*time.Millisecond, + ) defer cancel() _ = c.Run(ctx) require.Equal(t, 0, repo.Inserts()) require.Equal(t, 0, emt.Count()) - require.Greater(t, st.successes, 0) + require.Positive(t, st.successes) } func TestCollector_FetchErrorRecordsState(t *testing.T) { @@ -169,10 +176,13 @@ func TestCollector_FetchErrorRecordsState(t *testing.T) { State: st, }) - ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) + ctx, cancel := context.WithTimeout( + context.Background(), + 50*time.Millisecond, + ) defer cancel() _ = c.Run(ctx) require.Equal(t, 0, repo.Inserts()) - require.Greater(t, st.failures, 0) + require.Positive(t, st.failures) } diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/wikipedia/parser.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/wikipedia/parser.go index 8c65ffce..6cde9e6a 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/wikipedia/parser.go +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/wikipedia/parser.go @@ -39,6 +39,8 @@ func DecodeResponse(body []byte) (Response, error) { return Response{RevID: r.Parse.RevID, HTML: r.Parse.Text.Star}, nil } +const minEntryLen = 50 + func ParseEntries(html string) []ITNEntry { if strings.TrimSpace(html) == "" { return nil @@ -50,16 +52,19 @@ func ParseEntries(html string) []ITNEntry { var out []ITNEntry doc.Find("ul li").Each(func(_ int, s *goquery.Selection) { text := strings.TrimSpace(s.Text()) - if text == "" { + if text == "" || len(text) < minEntryLen || + !strings.Contains(text, " ") { return } - entry := ITNEntry{Text: text} - if href, ok := s.Find("a").First().Attr("href"); ok { - if slug := slugFromHref(href); slug != "" { - entry.ArticleSlug = slug - } + href, ok := s.Find("a").First().Attr("href") + if !ok { + return } - out = append(out, entry) + slug := slugFromHref(href) + if slug == "" { + return + } + out = append(out, ITNEntry{Text: text, ArticleSlug: slug}) }) return out } diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/wikipedia/parser_test.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/wikipedia/parser_test.go index ea0d46d7..901ec9bd 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/wikipedia/parser_test.go +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/wikipedia/parser_test.go @@ -41,18 +41,24 @@ func TestParser_HandlesEmptyHTML(t *testing.T) { } func TestParser_StripsHTMLTagsFromText(t *testing.T) { - entries := wikipedia.ParseEntries(`
  • Plain bold headline with link.
`) + entries := wikipedia.ParseEntries( + `
  • A reasonably long ITN-style sentence with bold and an inline linked phrase for context.
`, + ) require.Len(t, entries, 1) - require.Contains(t, entries[0].Text, "Plain") - require.Contains(t, entries[0].Text, "bold") - require.Contains(t, entries[0].Text, "link") + require.Contains(t, entries[0].Text, "linked phrase") require.NotContains(t, entries[0].Text, "") require.Equal(t, "Topic", entries[0].ArticleSlug) } func TestParser_SkipsListItemsWithoutLinks(t *testing.T) { - entries := wikipedia.ParseEntries(`
  • Has link
  • No link here
`) - require.Len(t, entries, 2) + entries := wikipedia.ParseEntries( + `
  • A long enough sentence with an actual linked article reference embedded.
  • Another long sentence that contains no link element at all in the body text.
`, + ) + require.Len( + t, + entries, + 1, + "items without an article slug should be filtered out", + ) require.Equal(t, "Foo", entries[0].ArticleSlug) - require.Empty(t, entries[1].ArticleSlug) } diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/wikipedia/repo.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/wikipedia/repo.go index 7a710728..913ad301 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/wikipedia/repo.go +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/wikipedia/repo.go @@ -37,7 +37,8 @@ func NewRepo(db *sqlx.DB, rdb *redis.Client) *Repo { } func (r *Repo) RememberRevID(ctx context.Context, revID int64) error { - if err := r.rdb.Set(ctx, keyRevID, strconv.FormatInt(revID, 10), 0).Err(); err != nil { + if err := r.rdb.Set(ctx, keyRevID, strconv.FormatInt(revID, 10), 0). + Err(); err != nil { return fmt.Errorf("save wiki revid: %w", err) } return nil diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/config/config.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/config/config.go index 5e69a76d..2d0d114a 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/config/config.go +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/config/config.go @@ -15,16 +15,17 @@ import ( ) type Config struct { - App AppConfig `koanf:"app"` - Server ServerConfig `koanf:"server"` - Database DatabaseConfig `koanf:"database"` - Redis RedisConfig `koanf:"redis"` - JWT JWTConfig `koanf:"jwt"` - RateLimit RateLimitConfig `koanf:"rate_limit"` - CORS CORSConfig `koanf:"cors"` - Log LogConfig `koanf:"log"` - Otel OtelConfig `koanf:"otel"` - Collectors CollectorsConfig `koanf:"collectors"` + App AppConfig `koanf:"app"` + Server ServerConfig `koanf:"server"` + Database DatabaseConfig `koanf:"database"` + Redis RedisConfig `koanf:"redis"` + JWT JWTConfig `koanf:"jwt"` + RateLimit RateLimitConfig `koanf:"rate_limit"` + CORS CORSConfig `koanf:"cors"` + Log LogConfig `koanf:"log"` + Otel OtelConfig `koanf:"otel"` + Collectors CollectorsConfig `koanf:"collectors"` + Notifications NotificationsConfig `koanf:"notifications"` } type CollectorsConfig struct { @@ -95,15 +96,24 @@ type AppConfig struct { Name string `koanf:"name"` Version string `koanf:"version"` Environment string `koanf:"environment"` + PublicURL string `koanf:"public_url"` + AdminEmail string `koanf:"admin_email"` +} + +type NotificationsConfig struct { + EncryptionKey string `koanf:"encryption_key"` } type ServerConfig struct { - Host string `koanf:"host"` - Port int `koanf:"port"` - ReadTimeout time.Duration `koanf:"read_timeout"` - WriteTimeout time.Duration `koanf:"write_timeout"` - IdleTimeout time.Duration `koanf:"idle_timeout"` - ShutdownTimeout time.Duration `koanf:"shutdown_timeout"` + Host string `koanf:"host"` + Port int `koanf:"port"` + ReadTimeout time.Duration `koanf:"read_timeout"` + WriteTimeout time.Duration `koanf:"write_timeout"` + IdleTimeout time.Duration `koanf:"idle_timeout"` + ShutdownTimeout time.Duration `koanf:"shutdown_timeout"` + TrustedProxyHops int `koanf:"trusted_proxy_hops"` + WSMaxConnsPerIP int `koanf:"ws_max_conns_per_ip"` + WSMaxSubscribers int `koanf:"ws_max_subscribers"` } type DatabaseConfig struct { @@ -173,13 +183,19 @@ func Load(configPath string) (*Config, error) { } if configPath != "" { - if err := k.Load(file.Provider(configPath), yaml.Parser()); err != nil { + if err := k.Load( + file.Provider(configPath), + yaml.Parser(), + ); err != nil { loadErr = fmt.Errorf("load config file: %w", err) return } } - if err := k.Load(env.Provider("", ".", envKeyReplacer), nil); err != nil { + if err := k.Load( + env.Provider("", ".", envKeyReplacer), + nil, + ); err != nil { loadErr = fmt.Errorf("load env vars: %w", err) return } @@ -216,12 +232,15 @@ func loadDefaults(k *koanf.Koanf) error { "app.version": "1.0.0", "app.environment": "development", - "server.host": "0.0.0.0", - "server.port": 8080, - "server.read_timeout": "30s", - "server.write_timeout": "30s", - "server.idle_timeout": "120s", - "server.shutdown_timeout": "15s", + "server.host": "0.0.0.0", + "server.port": 8080, + "server.read_timeout": "30s", + "server.write_timeout": "30s", + "server.idle_timeout": "120s", + "server.shutdown_timeout": "15s", + "server.trusted_proxy_hops": 0, + "server.ws_max_conns_per_ip": 25, + "server.ws_max_subscribers": 5000, "database.max_open_conns": 25, "database.max_idle_conns": 5, @@ -274,8 +293,8 @@ func loadDefaults(k *koanf.Koanf) error { "collectors.cfradar.interval": "5m", "collectors.cfradar.min_confidence": 7, "collectors.cve.enabled": true, - "collectors.cve.interval": "2h", - "collectors.cve.window": "2h", + "collectors.cve.interval": "5m", + "collectors.cve.window": "6m", "collectors.kev.enabled": true, "collectors.kev.interval": "1h", "collectors.ransomware.enabled": true, @@ -315,6 +334,9 @@ var envKeyMap = map[string]string{ "ENVIRONMENT": "app.environment", "HOST": "server.host", "PORT": "server.port", + "TRUSTED_PROXY_HOPS": "server.trusted_proxy_hops", + "WS_MAX_CONNS_PER_IP": "server.ws_max_conns_per_ip", + "WS_MAX_SUBSCRIBERS": "server.ws_max_subscribers", "LOG_LEVEL": "log.level", "LOG_FORMAT": "log.format", "JWT_PRIVATE_KEY_PATH": "jwt.private_key_path", @@ -336,6 +358,9 @@ var envKeyMap = map[string]string{ "CF_RADAR_TOKEN": "collectors.cfradar.bearer_token", "GREYNOISE_API_KEY": "collectors.greynoise.api_key", "ABUSEIPDB_API_KEY": "collectors.abuseipdb.api_key", + "NOTIFICATION_ENCRYPTION_KEY": "notifications.encryption_key", + "PUBLIC_URL": "app.public_url", + "ADMIN_EMAIL": "app.admin_email", } func envKeyReplacer(s string) string { diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/core/telemetry.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/core/telemetry.go index a03220b1..737ca1d5 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/core/telemetry.go +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/core/telemetry.go @@ -51,7 +51,12 @@ func NewTelemetry( otlptracegrpc.WithTLSCredentials(insecure.NewCredentials()), ) } else { - opts = append(opts, otlptracegrpc.WithTLSCredentials(credentials.NewClientTLSFromCert(nil, ""))) + opts = append( + opts, + otlptracegrpc.WithTLSCredentials( + credentials.NewClientTLSFromCert(nil, ""), + ), + ) } exporter, err := otlptracegrpc.New(ctx, opts...) diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/enrich/abuseipdb/client_test.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/enrich/abuseipdb/client_test.go index c3b7332d..8128b0d1 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/enrich/abuseipdb/client_test.go +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/enrich/abuseipdb/client_test.go @@ -16,19 +16,23 @@ import ( ) func TestClient_LookupDecodesVerdict(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - require.Equal(t, "/api/v2/check", r.URL.Path) - require.Equal(t, "118.25.6.39", r.URL.Query().Get("ipAddress")) - require.Equal(t, "90", r.URL.Query().Get("maxAgeInDays")) - require.Equal(t, "test-key", r.Header.Get("Key")) - body, err := os.ReadFile("testdata/check_lookup.json") - require.NoError(t, err) - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write(body) - })) + srv := httptest.NewServer( + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/api/v2/check", r.URL.Path) + require.Equal(t, "118.25.6.39", r.URL.Query().Get("ipAddress")) + require.Equal(t, "90", r.URL.Query().Get("maxAgeInDays")) + require.Equal(t, "test-key", r.Header.Get("Key")) + body, err := os.ReadFile("testdata/check_lookup.json") + require.NoError(t, err) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write(body) + }), + ) defer srv.Close() - c := abuseipdb.NewClient(abuseipdb.ClientConfig{BaseURL: srv.URL, APIKey: "test-key"}) + c := abuseipdb.NewClient( + abuseipdb.ClientConfig{BaseURL: srv.URL, APIKey: "test-key"}, + ) v, err := c.Lookup(context.Background(), "118.25.6.39") require.NoError(t, err) require.Equal(t, "118.25.6.39", v.IPAddress) @@ -38,24 +42,32 @@ func TestClient_LookupDecodesVerdict(t *testing.T) { } func TestClient_LookupReturnsErrUnknownIPOn404(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusNotFound) - })) + srv := httptest.NewServer( + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + }), + ) defer srv.Close() - c := abuseipdb.NewClient(abuseipdb.ClientConfig{BaseURL: srv.URL, APIKey: "k"}) + c := abuseipdb.NewClient( + abuseipdb.ClientConfig{BaseURL: srv.URL, APIKey: "k"}, + ) _, err := c.Lookup(context.Background(), "10.0.0.1") require.ErrorIs(t, err, abuseipdb.ErrUnknownIP) } func TestClient_LookupReturnsErrUnknownIPOnEmptyData(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"data":{}}`)) - })) + srv := httptest.NewServer( + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"data":{}}`)) + }), + ) defer srv.Close() - c := abuseipdb.NewClient(abuseipdb.ClientConfig{BaseURL: srv.URL, APIKey: "k"}) + c := abuseipdb.NewClient( + abuseipdb.ClientConfig{BaseURL: srv.URL, APIKey: "k"}, + ) _, err := c.Lookup(context.Background(), "10.0.0.1") require.ErrorIs(t, err, abuseipdb.ErrUnknownIP) } diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/enrich/greynoise/cached.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/enrich/greynoise/cached.go new file mode 100644 index 00000000..92b3a93f --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/enrich/greynoise/cached.go @@ -0,0 +1,66 @@ +// ©AngelaMos | 2026 +// cached.go + +package greynoise + +import ( + "context" + "encoding/json" + "errors" + "time" + + "github.com/redis/go-redis/v9" +) + +const ( + cacheKeyPrefix = "gn:lookup:" + cacheHitTTL = 6 * time.Hour + cacheMissTTL = 30 * time.Minute + cacheMissValue = "__miss__" +) + +type Cached struct { + inner *Client + rdb *redis.Client +} + +func NewCached(inner *Client, rdb *redis.Client) *Cached { + return &Cached{inner: inner, rdb: rdb} +} + +func (c *Cached) Lookup(ctx context.Context, ip string) (Verdict, error) { + key := cacheKeyPrefix + ip + + if c.rdb != nil { + raw, err := c.rdb.Get(ctx, key).Result() + if err == nil { + if raw == cacheMissValue { + return Verdict{}, ErrUnknownIP + } + var v Verdict + if jerr := json.Unmarshal([]byte(raw), &v); jerr == nil { + return v, nil + } + } else if !errors.Is(err, redis.Nil) { + // Redis is degraded — fall through to live lookup. Don't cache + // the result either since the cache layer is itself unhealthy. + v, lerr := c.inner.Lookup(ctx, ip) + return v, lerr + } + } + + v, err := c.inner.Lookup(ctx, ip) + if err != nil { + if errors.Is(err, ErrUnknownIP) && c.rdb != nil { + _ = c.rdb.Set(ctx, key, cacheMissValue, cacheMissTTL).Err() + } + return Verdict{}, err + } + + if c.rdb != nil { + if payload, merr := json.Marshal(v); merr == nil { + _ = c.rdb.Set(ctx, key, string(payload), cacheHitTTL).Err() + } + } + return v, nil +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/enrich/greynoise/client_test.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/enrich/greynoise/client_test.go index 289fb446..59238534 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/enrich/greynoise/client_test.go +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/enrich/greynoise/client_test.go @@ -16,17 +16,21 @@ import ( ) func TestClient_LookupDecodesVerdict(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - require.Equal(t, "/v3/community/8.8.8.8", r.URL.Path) - require.Equal(t, "test-key", r.Header.Get("key")) - body, err := os.ReadFile("testdata/ip_lookup.json") - require.NoError(t, err) - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write(body) - })) + srv := httptest.NewServer( + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/v3/community/8.8.8.8", r.URL.Path) + require.Equal(t, "test-key", r.Header.Get("key")) + body, err := os.ReadFile("testdata/ip_lookup.json") + require.NoError(t, err) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write(body) + }), + ) defer srv.Close() - c := greynoise.NewClient(greynoise.ClientConfig{BaseURL: srv.URL, APIKey: "test-key"}) + c := greynoise.NewClient( + greynoise.ClientConfig{BaseURL: srv.URL, APIKey: "test-key"}, + ) v, err := c.Lookup(context.Background(), "8.8.8.8") require.NoError(t, err) require.Equal(t, "8.8.8.8", v.IP) @@ -35,12 +39,16 @@ func TestClient_LookupDecodesVerdict(t *testing.T) { } func TestClient_LookupReturnsErrUnknownIPOn404(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusNotFound) - })) + srv := httptest.NewServer( + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + }), + ) defer srv.Close() - c := greynoise.NewClient(greynoise.ClientConfig{BaseURL: srv.URL, APIKey: "k"}) + c := greynoise.NewClient( + greynoise.ClientConfig{BaseURL: srv.URL, APIKey: "k"}, + ) _, err := c.Lookup(context.Background(), "10.0.0.1") require.ErrorIs(t, err, greynoise.ErrUnknownIP) } diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/httpx/client.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/httpx/client.go index ba1bf59a..0a176755 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/httpx/client.go +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/httpx/client.go @@ -85,7 +85,12 @@ func New(cfg Config) *Client { } } -func (c *Client) GetJSON(ctx context.Context, path string, query url.Values, out any) error { +func (c *Client) GetJSON( + ctx context.Context, + path string, + query url.Values, + out any, +) error { resp, err := c.Get(ctx, path, query) if err != nil { return err @@ -94,7 +99,11 @@ func (c *Client) GetJSON(ctx context.Context, path string, query url.Values, out return json.NewDecoder(resp.Body).Decode(out) } -func (c *Client) Get(ctx context.Context, path string, query url.Values) (*http.Response, error) { +func (c *Client) Get( + ctx context.Context, + path string, + query url.Values, +) (*http.Response, error) { target, err := c.resolveURL(path, query) if err != nil { return nil, err @@ -107,9 +116,12 @@ func (c *Client) Get(ctx context.Context, path string, query url.Values) (*http. var resp *http.Response op := func() error { - r, opErr := c.limiter.Do(ctx, func(rctx context.Context) (*http.Response, error) { - return c.do(rctx, target) - }) + r, opErr := c.limiter.Do( + ctx, + func(rctx context.Context) (*http.Response, error) { + return c.do(rctx, target) + }, + ) if opErr != nil { return opErr } @@ -131,7 +143,9 @@ func (c *Client) Get(ctx context.Context, path string, query url.Values) (*http. case r.StatusCode >= 400: body, _ := io.ReadAll(io.LimitReader(r.Body, clientErrorBodyLimit)) drainAndClose(r) - return backoff.Permanent(&StatusError{Code: r.StatusCode, Body: string(body)}) + return backoff.Permanent( + &StatusError{Code: r.StatusCode, Body: string(body)}, + ) } resp = r return nil @@ -142,7 +156,10 @@ func (c *Client) Get(ctx context.Context, path string, query url.Values) (*http. return resp, nil } -func (c *Client) do(ctx context.Context, target string) (*http.Response, error) { +func (c *Client) do( + ctx context.Context, + target string, +) (*http.Response, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, target, nil) if err != nil { return nil, err diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/httpx/client_test.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/httpx/client_test.go index 837ba359..ce592283 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/httpx/client_test.go +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/httpx/client_test.go @@ -19,10 +19,12 @@ import ( func TestClient_GetJSONHonorsRateLimit(t *testing.T) { var hits atomic.Int32 - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - hits.Add(1) - _, _ = w.Write([]byte(`{"ok":true}`)) - })) + srv := httptest.NewServer( + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + hits.Add(1) + _, _ = w.Write([]byte(`{"ok":true}`)) + }), + ) defer srv.Close() c := httpx.New(httpx.Config{ @@ -51,14 +53,16 @@ func TestClient_GetJSONHonorsRateLimit(t *testing.T) { func TestClient_RetriesOn429WithRetryAfter(t *testing.T) { var hits atomic.Int32 - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - if hits.Add(1) == 1 { - w.Header().Set("Retry-After", "1") - w.WriteHeader(http.StatusTooManyRequests) - return - } - _, _ = w.Write([]byte(`{"ok":true}`)) - })) + srv := httptest.NewServer( + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + if hits.Add(1) == 1 { + w.Header().Set("Retry-After", "1") + w.WriteHeader(http.StatusTooManyRequests) + return + } + _, _ = w.Write([]byte(`{"ok":true}`)) + }), + ) defer srv.Close() c := httpx.New(httpx.Config{ @@ -84,13 +88,15 @@ func TestClient_RetriesOn429WithRetryAfter(t *testing.T) { func TestClient_RetriesOn5xx(t *testing.T) { var hits atomic.Int32 - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - if hits.Add(1) < 3 { - w.WriteHeader(http.StatusBadGateway) - return - } - _, _ = w.Write([]byte(`{"ok":true}`)) - })) + srv := httptest.NewServer( + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + if hits.Add(1) < 3 { + w.WriteHeader(http.StatusBadGateway) + return + } + _, _ = w.Write([]byte(`{"ok":true}`)) + }), + ) defer srv.Close() c := httpx.New(httpx.Config{ @@ -115,11 +121,13 @@ func TestClient_RetriesOn5xx(t *testing.T) { func TestClient_PermanentErrorOn4xx(t *testing.T) { var hits atomic.Int32 - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - hits.Add(1) - w.WriteHeader(http.StatusBadRequest) - _, _ = w.Write([]byte(`bad`)) - })) + srv := httptest.NewServer( + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + hits.Add(1) + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`bad`)) + }), + ) defer srv.Close() c := httpx.New(httpx.Config{ @@ -143,12 +151,14 @@ func TestClient_PermanentErrorOn4xx(t *testing.T) { func TestClient_AddsBearerAndAPIKeyHeaders(t *testing.T) { var bearer, apiKey, ua string - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - bearer = r.Header.Get("Authorization") - apiKey = r.Header.Get("X-Custom-Key") - ua = r.Header.Get("User-Agent") - _, _ = w.Write([]byte(`{}`)) - })) + srv := httptest.NewServer( + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + bearer = r.Header.Get("Authorization") + apiKey = r.Header.Get("X-Custom-Key") + ua = r.Header.Get("User-Agent") + _, _ = w.Write([]byte(`{}`)) + }), + ) defer srv.Close() c := httpx.New(httpx.Config{ diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/intel/handler.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/intel/handler.go new file mode 100644 index 00000000..9f9dc8af --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/intel/handler.go @@ -0,0 +1,303 @@ +// ©AngelaMos | 2026 +// handler.go + +package intel + +import ( + "context" + "encoding/json" + "net/http" + "strconv" + "time" + + "github.com/go-chi/chi/v5" + + "github.com/carterperez-dev/monitor-the-situation/backend/internal/collectors/cfradar" + "github.com/carterperez-dev/monitor-the-situation/backend/internal/collectors/cve" + "github.com/carterperez-dev/monitor-the-situation/backend/internal/collectors/kev" + "github.com/carterperez-dev/monitor-the-situation/backend/internal/collectors/ransomware" + "github.com/carterperez-dev/monitor-the-situation/backend/internal/collectors/usgs" + "github.com/carterperez-dev/monitor-the-situation/backend/internal/core" +) + +// Backfill endpoints. The dashboard's snapshot store keeps only the latest +// SINGLE event per topic, so on cold load the panels are nearly empty until +// the next collector tick fires (up to 2h for CVE). These endpoints read +// directly from the persistent stores so the panels populate immediately, +// and the WS path layers deltas on top. +// +// All routes are GET, public (same posture as /snapshot), with a small +// public cache to absorb the cold-load thundering herd if the dashboard +// happens to be popular. + +const ( + defaultLimit = 50 + maxLimit = 500 + cacheMaxAgeS = 30 + cacheControl = "public, max-age=30" + contentType = "Content-Type" + contentJSON = "application/json" +) + +type CVERepo interface { + RecentByLastModified(ctx context.Context, limit int) ([]cve.Row, error) +} + +type KEVRepo interface { + RecentByDateAdded(ctx context.Context, limit int) ([]kev.Row, error) +} + +type CFRadarRepo interface { + RecentHijacks(ctx context.Context, limit int) ([]cfradar.HijackRow, error) + RecentOutages(ctx context.Context, limit int) ([]cfradar.OutageRow, error) +} + +type RansomwareRepo interface { + Recent(ctx context.Context, limit int) ([]ransomware.Row, error) +} + +type USGSRepo interface { + RecentByTime(ctx context.Context, limit int) ([]usgs.Row, error) +} + +type Handler struct { + cveRepo CVERepo + kevRepo KEVRepo + cfradarRepo CFRadarRepo + ransomwareRepo RansomwareRepo + usgsRepo USGSRepo +} + +type HandlerConfig struct { + CVE CVERepo + KEV KEVRepo + CFRadar CFRadarRepo + Ransomware RansomwareRepo + USGS USGSRepo +} + +func NewHandler(cfg HandlerConfig) *Handler { + return &Handler{ + cveRepo: cfg.CVE, + kevRepo: cfg.KEV, + cfradarRepo: cfg.CFRadar, + ransomwareRepo: cfg.Ransomware, + usgsRepo: cfg.USGS, + } +} + +func (h *Handler) RegisterRoutes(r chi.Router) { + r.Route("/intel", func(r chi.Router) { + r.Get("/cves", h.recentCVEs) + r.Get("/kev", h.recentKEV) + r.Get("/hijacks", h.recentHijacks) + r.Get("/outages", h.recentOutages) + r.Get("/ransomware", h.recentRansomware) + r.Get("/quakes", h.recentQuakes) + }) +} + +func parseLimit(r *http.Request) int { + raw := r.URL.Query().Get("limit") + if raw == "" { + return defaultLimit + } + n, err := strconv.Atoi(raw) + if err != nil || n <= 0 { + return defaultLimit + } + if n > maxLimit { + return maxLimit + } + return n +} + +func (h *Handler) recentCVEs(w http.ResponseWriter, r *http.Request) { + if h.cveRepo == nil { + core.OK(w, []any{}) + return + } + rows, err := h.cveRepo.RecentByLastModified(r.Context(), parseLimit(r)) + if err != nil { + core.InternalServerError(w, err) + return + } + w.Header().Set("Cache-Control", cacheControl) + core.OK(w, intelCVERows(rows)) +} + +func (h *Handler) recentKEV(w http.ResponseWriter, r *http.Request) { + if h.kevRepo == nil { + core.OK(w, []any{}) + return + } + rows, err := h.kevRepo.RecentByDateAdded(r.Context(), parseLimit(r)) + if err != nil { + core.InternalServerError(w, err) + return + } + w.Header().Set("Cache-Control", cacheControl) + core.OK(w, intelKEVRows(rows)) +} + +// recentHijacks returns the raw payload column (the same EnrichedHijack +// JSON the WS path emits at collector time) so the dashboard panel sees +// `prefixes` and `enrichment` alongside the row metadata. Building a +// flat DTO loses fields the panel renders → empty rows. +func (h *Handler) recentHijacks(w http.ResponseWriter, r *http.Request) { + if h.cfradarRepo == nil { + core.OK(w, []any{}) + return + } + rows, err := h.cfradarRepo.RecentHijacks(r.Context(), parseLimit(r)) + if err != nil { + core.InternalServerError(w, err) + return + } + out := make([]json.RawMessage, 0, len(rows)) + for _, r := range rows { + if len(r.Payload) > 0 { + out = append(out, r.Payload) + } + } + w.Header().Set("Cache-Control", cacheControl) + core.OK(w, out) +} + +// recentOutages returns the raw OutageAnnotation payload the WS path +// emits, for the same reason as recentHijacks. +func (h *Handler) recentOutages(w http.ResponseWriter, r *http.Request) { + if h.cfradarRepo == nil { + core.OK(w, []any{}) + return + } + rows, err := h.cfradarRepo.RecentOutages(r.Context(), parseLimit(r)) + if err != nil { + core.InternalServerError(w, err) + return + } + out := make([]json.RawMessage, 0, len(rows)) + for _, r := range rows { + if len(r.Payload) > 0 { + out = append(out, r.Payload) + } + } + w.Header().Set("Cache-Control", cacheControl) + core.OK(w, out) +} + +// recentRansomware returns the raw Victim payload the WS path emits, so +// dedupe by victimKey() works identically across cold-load and live. +func (h *Handler) recentRansomware(w http.ResponseWriter, r *http.Request) { + if h.ransomwareRepo == nil { + core.OK(w, []any{}) + return + } + rows, err := h.ransomwareRepo.Recent(r.Context(), parseLimit(r)) + if err != nil { + core.InternalServerError(w, err) + return + } + out := make([]json.RawMessage, 0, len(rows)) + for _, r := range rows { + if len(r.Payload) > 0 { + out = append(out, r.Payload) + } + } + w.Header().Set("Cache-Control", cacheControl) + core.OK(w, out) +} + +func (h *Handler) recentQuakes(w http.ResponseWriter, r *http.Request) { + if h.usgsRepo == nil { + core.OK(w, []any{}) + return + } + rows, err := h.usgsRepo.RecentByTime(r.Context(), parseLimit(r)) + if err != nil { + core.InternalServerError(w, err) + return + } + // Return the raw GeoJSON Feature shape the frontend (and the live WS + // path) already speak. Row.Payload IS the upstream feature. + out := make([]json.RawMessage, 0, len(rows)) + for _, r := range rows { + if len(r.Payload) > 0 { + out = append(out, r.Payload) + } + } + w.Header().Set("Cache-Control", cacheControl) + core.OK(w, out) +} + +// The collectors' Row types embed the raw upstream payload as +// json.RawMessage. The dashboard panels consume the upstream shape (CveID, +// Severity, etc.), so we forward the payloads — they're already the right +// shape for the existing zod schemas on the frontend. For panels that +// derive fields server-side (severity/cvss numeric vs the NVD nested +// structure) we expand back to the same flat shape the live WS event +// emits, so the frontend stores can dedupe by ID across cold-load and live +// streams without remembering two formats. + +type cveDTO struct { + CveID string `json:"CveID"` + Published time.Time `json:"Published"` + LastModified time.Time `json:"LastModified"` + Severity string `json:"Severity"` + CVSS float64 `json:"CVSS"` + EPSSScore *float64 `json:"EPSSScore"` + EPSSPercentile *float64 `json:"EPSSPercentile"` + InKEV bool `json:"InKEV"` + Payload json.RawMessage `json:"-"` +} + +func intelCVERows(rows []cve.Row) []cveDTO { + out := make([]cveDTO, 0, len(rows)) + for _, r := range rows { + out = append(out, cveDTO{ + CveID: r.CveID, + Published: r.Published, + LastModified: r.LastModified, + Severity: r.Severity, + CVSS: r.CVSS, + EPSSScore: r.EPSSScore, + EPSSPercentile: r.EPSSPercentile, + InKEV: r.InKEV, + }) + } + return out +} + +type kevDTO struct { + CveID string `json:"cveID"` + VendorProject string `json:"vendorProject"` + Product string `json:"product"` + VulnerabilityName string `json:"vulnerabilityName"` + DateAdded string `json:"dateAdded"` + DueDate string `json:"dueDate,omitempty"` + KnownRansomwareCampaignUse string `json:"knownRansomwareCampaignUse,omitempty"` +} + +func intelKEVRows(rows []kev.Row) []kevDTO { + out := make([]kevDTO, 0, len(rows)) + for _, r := range rows { + dto := kevDTO{ + CveID: r.CveID, + VendorProject: r.Vendor, + Product: r.Product, + VulnerabilityName: r.VulnerabilityName, + DateAdded: r.DateAdded.Format(time.DateOnly), + KnownRansomwareCampaignUse: r.RansomwareUse, + } + if r.DueDate != nil { + dto.DueDate = r.DueDate.Format(time.DateOnly) + } + out = append(out, dto) + } + return out +} + +// Hijack/outage/ransomware endpoints all return the raw collector +// payload (the JSON the WS path emits), so the dashboard's panel-render +// fields like prefixes/enrichment survive the cold-load → live-stream +// transition without shape divergence. diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/middleware/auth.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/middleware/auth.go index 1ca066c3..0c4455b4 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/middleware/auth.go +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/middleware/auth.go @@ -8,6 +8,7 @@ import ( "errors" "net/http" "strings" + "time" "github.com/carterperez-dev/monitor-the-situation/backend/internal/core" ) @@ -31,6 +32,8 @@ type AccessTokenClaims struct { Role string Tier string TokenVersion int + JTI string + ExpiresAt time.Time } func Authenticator(verifier TokenVerifier) func(http.Handler) http.Handler { diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/middleware/clientip.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/middleware/clientip.go new file mode 100644 index 00000000..e0170846 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/middleware/clientip.go @@ -0,0 +1,43 @@ +// ©AngelaMos | 2026 +// clientip.go + +package middleware + +import ( + "net" + "net/http" + "strings" +) + +// ClientIP returns the request's source IP. If trustedHops > 0, it walks back +// that many hops from the right side of X-Forwarded-For (the closest hops are +// the most trusted, since each proxy appends to the right). With trustedHops +// == 0, X-Forwarded-For is ignored entirely and only RemoteAddr is honored — +// the safe default when the binary is reachable directly without a known +// proxy chain. Set trusted_proxy_hops in config to match your deployment +// (e.g., 1 behind nginx, 2 behind nginx+Cloudflare). +func ClientIP(r *http.Request, trustedHops int) string { + if trustedHops > 0 { + if xff := r.Header.Get("X-Forwarded-For"); xff != "" { + ips := strings.Split(xff, ",") + for i := range ips { + ips[i] = strings.TrimSpace(ips[i]) + } + idx := len(ips) - trustedHops + if idx < 0 { + idx = 0 + } + if idx < len(ips) && ips[idx] != "" { + return ips[idx] + } + } + if xri := strings.TrimSpace(r.Header.Get("X-Real-IP")); xri != "" { + return xri + } + } + host, _, err := net.SplitHostPort(r.RemoteAddr) + if err != nil { + return r.RemoteAddr + } + return host +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/middleware/clientip_test.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/middleware/clientip_test.go new file mode 100644 index 00000000..4f46f834 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/middleware/clientip_test.go @@ -0,0 +1,61 @@ +// ©AngelaMos | 2026 +// clientip_test.go + +package middleware_test + +import ( + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/carterperez-dev/monitor-the-situation/backend/internal/middleware" +) + +func TestClientIP_TrustedHopsZero_IgnoresXFF(t *testing.T) { + r := httptest.NewRequest("GET", "/", nil) + r.RemoteAddr = "10.0.0.5:54321" + r.Header.Set("X-Forwarded-For", "1.2.3.4") + r.Header.Set("X-Real-IP", "1.2.3.4") + + require.Equal(t, "10.0.0.5", middleware.ClientIP(r, 0)) +} + +func TestClientIP_TrustedHopsOne_PeelsRightmost(t *testing.T) { + r := httptest.NewRequest("GET", "/", nil) + r.RemoteAddr = "10.0.0.5:54321" + r.Header.Set("X-Forwarded-For", "203.0.113.1, 10.0.0.5") + + require.Equal(t, "10.0.0.5", middleware.ClientIP(r, 1)) +} + +func TestClientIP_TrustedHopsTwo_PeelsTwoFromRight(t *testing.T) { + r := httptest.NewRequest("GET", "/", nil) + r.RemoteAddr = "10.0.0.5:54321" + r.Header.Set("X-Forwarded-For", "203.0.113.1, 198.51.100.7, 10.0.0.5") + + require.Equal(t, "198.51.100.7", middleware.ClientIP(r, 2)) +} + +func TestClientIP_TrustedHopsExceedsList_ReturnsLeftmost(t *testing.T) { + r := httptest.NewRequest("GET", "/", nil) + r.RemoteAddr = "10.0.0.5:54321" + r.Header.Set("X-Forwarded-For", "203.0.113.1, 10.0.0.5") + + require.Equal(t, "203.0.113.1", middleware.ClientIP(r, 5)) +} + +func TestClientIP_TrustedHopsOne_NoXFF_FallsBackToRemoteAddr(t *testing.T) { + r := httptest.NewRequest("GET", "/", nil) + r.RemoteAddr = "10.0.0.5:54321" + + require.Equal(t, "10.0.0.5", middleware.ClientIP(r, 1)) +} + +func TestClientIP_TrustedHopsOne_HonorsXRealIP(t *testing.T) { + r := httptest.NewRequest("GET", "/", nil) + r.RemoteAddr = "10.0.0.5:54321" + r.Header.Set("X-Real-IP", "203.0.113.42") + + require.Equal(t, "203.0.113.42", middleware.ClientIP(r, 1)) +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/middleware/ratelimit.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/middleware/ratelimit.go index c85519a1..12afa67b 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/middleware/ratelimit.go +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/middleware/ratelimit.go @@ -8,7 +8,6 @@ import ( "encoding/json" "fmt" "log/slog" - "net" "net/http" "strconv" "strings" @@ -94,23 +93,18 @@ func (rl *RateLimiter) allow( return res, nil } +// KeyByIP keys requests by their RemoteAddr only — does NOT trust +// X-Forwarded-For. Use KeyByClientIP(trustedHops) when you have a known +// proxy chain to peel back. func KeyByIP(r *http.Request) string { - if xff := r.Header.Get("X-Forwarded-For"); xff != "" { - ips := strings.Split(xff, ",") - ip := strings.TrimSpace(ips[len(ips)-1]) - return "ratelimit:ip:" + ip - } + return "ratelimit:ip:" + ClientIP(r, 0) +} - if xri := r.Header.Get("X-Real-IP"); xri != "" { - return "ratelimit:ip:" + xri +// KeyByClientIP returns a KeyFunc that honors trustedHops of X-Forwarded-For. +func KeyByClientIP(trustedHops int) func(*http.Request) string { + return func(r *http.Request) string { + return "ratelimit:ip:" + ClientIP(r, trustedHops) } - - ip, _, err := net.SplitHostPort(r.RemoteAddr) - if err != nil { - ip = r.RemoteAddr - } - - return "ratelimit:ip:" + ip } func KeyByUser(r *http.Request) string { @@ -351,25 +345,24 @@ func TieredRateLimiter( } func PerMinute(rate, burst int) redis_rate.Limit { - return redis_rate.Limit{ - Rate: rate, - Burst: burst, - Period: time.Minute, - } + return PerWindow(rate, burst, time.Minute) } func PerSecond(rate, burst int) redis_rate.Limit { - return redis_rate.Limit{ - Rate: rate, - Burst: burst, - Period: time.Second, - } + return PerWindow(rate, burst, time.Second) } func PerHour(rate, burst int) redis_rate.Limit { + return PerWindow(rate, burst, time.Hour) +} + +func PerWindow(rate, burst int, window time.Duration) redis_rate.Limit { + if window <= 0 { + window = time.Minute + } return redis_rate.Limit{ Rate: rate, Burst: burst, - Period: time.Hour, + Period: window, } } diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/notifications/bridge.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/notifications/bridge.go new file mode 100644 index 00000000..147bfafb --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/notifications/bridge.go @@ -0,0 +1,113 @@ +// ©AngelaMos | 2026 +// bridge.go + +package notifications + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + + "github.com/carterperez-dev/monitor-the-situation/backend/internal/alerts" +) + +// Bridge wires the notifications module into the alerts engine. It loads +// a user's configured channels (decrypting webhook URLs / Telegram bot +// tokens on demand) and routes outbound alerts to the right transport. +// +// Without this seam, the alerts engine would have to know about +// encryption/repo specifics; with it, alerts.Engine treats every +// destination as the same interface (alerts.Channel + Notifier). +type Bridge struct { + repo Repository + enc *Encryptor + sender *Sender + logger *slog.Logger +} + +func NewBridge( + repo Repository, + enc *Encryptor, + sender *Sender, + logger *slog.Logger, +) *Bridge { + if logger == nil { + logger = slog.Default() + } + return &Bridge{repo: repo, enc: enc, sender: sender, logger: logger} +} + +// LoadChannels implements alerts.ChannelLoader. Returns every linked +// destination for the user — Slack/Discord webhook channels plus the +// Telegram bot if it's been linked. Decryption errors mark the channel +// invalid and skip it. +func (b *Bridge) LoadChannels( + ctx context.Context, + userID string, +) ([]alerts.Channel, error) { + out := []alerts.Channel{} + + channels, err := b.repo.ListChannels(ctx, userID) + if err != nil { + return nil, fmt.Errorf("load webhook channels: %w", err) + } + for _, ch := range channels { + if ch.Invalid { + continue + } + raw, decErr := b.enc.Decrypt(ch.ConfigEnc, ch.Nonce) + if decErr != nil { + b.logger.Warn("alerts: channel decrypt failed", + "channel_id", ch.ID, "type", ch.Type, "err", decErr) + continue + } + var cfg webhookChannelConfig + if jErr := json.Unmarshal(raw, &cfg); jErr != nil { + b.logger.Warn("alerts: channel config unmarshal failed", + "channel_id", ch.ID, "type", ch.Type, "err", jErr) + continue + } + out = append(out, alerts.Channel{ + ID: ch.ID, + Type: ch.Type, + Label: ch.Label, + WebhookURL: cfg.WebhookURL, + }) + } + + tw, err := b.repo.GetTelegramWebhook(ctx, userID) + if err == nil && tw != nil && tw.IsLinked() { + botToken, derr := b.enc.Decrypt(tw.BotTokenEnc, tw.BotTokenNonce) + if derr == nil && tw.ChatID != nil { + out = append(out, alerts.Channel{ + ID: "telegram:" + userID, + Type: ChannelTypeTelegram, + Label: "Telegram", + BotToken: string(botToken), + ChatID: *tw.ChatID, + }) + } + } + + return out, nil +} + +// SendAlert implements alerts.Notifier. Routes by channel type to the +// matching transport on the Sender. +func (b *Bridge) SendAlert( + ctx context.Context, + ch alerts.Channel, + message string, +) error { + switch ch.Type { + case ChannelTypeSlack: + return b.sender.SendSlack(ctx, ch.WebhookURL, message) + case ChannelTypeDiscord: + return b.sender.SendDiscord(ctx, ch.WebhookURL, message) + case ChannelTypeTelegram: + return b.sender.SendTelegram(ctx, ch.BotToken, ch.ChatID, message) + default: + return fmt.Errorf("unsupported channel type: %s", ch.Type) + } +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/notifications/crypto.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/notifications/crypto.go new file mode 100644 index 00000000..b9d31340 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/notifications/crypto.go @@ -0,0 +1,72 @@ +// ©AngelaMos | 2026 +// crypto.go + +package notifications + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "encoding/base64" + "fmt" + "io" +) + +type Encryptor struct { + key []byte +} + +func NewEncryptor(b64Key string) (*Encryptor, error) { + key, err := base64.StdEncoding.DecodeString(b64Key) + if err != nil { + return nil, fmt.Errorf("decode encryption key: %w", err) + } + if len(key) != 32 { + return nil, fmt.Errorf( + "encryption key must be 32 bytes, got %d", + len(key), + ) + } + return &Encryptor{key: key}, nil +} + +func (e *Encryptor) Encrypt( + plaintext []byte, +) (ciphertext, nonce []byte, err error) { + block, err := aes.NewCipher(e.key) + if err != nil { + return nil, nil, fmt.Errorf("create cipher: %w", err) + } + + gcm, err := cipher.NewGCM(block) + if err != nil { + return nil, nil, fmt.Errorf("create GCM: %w", err) + } + + nonce = make([]byte, gcm.NonceSize()) + if _, err := io.ReadFull(rand.Reader, nonce); err != nil { + return nil, nil, fmt.Errorf("generate nonce: %w", err) + } + + ciphertext = gcm.Seal(nil, nonce, plaintext, nil) + return ciphertext, nonce, nil +} + +func (e *Encryptor) Decrypt(ciphertext, nonce []byte) ([]byte, error) { + block, err := aes.NewCipher(e.key) + if err != nil { + return nil, fmt.Errorf("create cipher: %w", err) + } + + gcm, err := cipher.NewGCM(block) + if err != nil { + return nil, fmt.Errorf("create GCM: %w", err) + } + + plaintext, err := gcm.Open(nil, nonce, ciphertext, nil) + if err != nil { + return nil, fmt.Errorf("decrypt: %w", err) + } + + return plaintext, nil +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/notifications/dto.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/notifications/dto.go new file mode 100644 index 00000000..0dac3340 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/notifications/dto.go @@ -0,0 +1,56 @@ +// ©AngelaMos | 2026 +// dto.go + +package notifications + +import "time" + +type ChannelResponse struct { + ID string `json:"id"` + Type string `json:"type"` + Label string `json:"label"` + Invalid bool `json:"invalid"` + CreatedAt time.Time `json:"created_at"` +} + +type TelegramStatusResponse struct { + Configured bool `json:"configured"` + Linked bool `json:"linked"` + PendingLink bool `json:"pending_link"` + WebhookURL string `json:"webhook_url,omitempty"` + WebhookRegistered bool `json:"webhook_registered"` + CreatedAt time.Time `json:"created_at,omitempty"` +} + +type RegisterTelegramResponse struct { + WebhookURL string `json:"webhook_url"` + WebhookRegistered bool `json:"webhook_registered"` +} + +type ChannelListResponse struct { + Channels []ChannelResponse `json:"channels"` + Telegram TelegramStatusResponse `json:"telegram"` +} + +type CreateChannelRequest struct { + Type string `json:"type" validate:"required,oneof=slack discord"` + Label string `json:"label" validate:"required,min=1,max=100"` + WebhookURL string `json:"webhook_url" validate:"required,url,max=2048"` +} + +type RegisterTelegramRequest struct { + BotToken string `json:"bot_token" validate:"required,min=10,max=200"` +} + +type webhookChannelConfig struct { + WebhookURL string `json:"webhook_url"` +} + +type telegramUpdate struct { + UpdateID int64 `json:"update_id"` + Message *struct { + Chat struct { + ID int64 `json:"id"` + } `json:"chat"` + } `json:"message"` +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/notifications/entity.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/notifications/entity.go new file mode 100644 index 00000000..b6dc6678 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/notifications/entity.go @@ -0,0 +1,39 @@ +// ©AngelaMos | 2026 +// entity.go + +package notifications + +import "time" + +const ( + ChannelTypeSlack = "slack" + ChannelTypeDiscord = "discord" + ChannelTypeTelegram = "telegram" +) + +type Channel struct { + ID string `db:"id"` + UserID string `db:"user_id"` + Type string `db:"type"` + Label string `db:"label"` + ConfigEnc []byte `db:"config_enc"` + Nonce []byte `db:"nonce"` + Invalid bool `db:"invalid"` + CreatedAt time.Time `db:"created_at"` + UpdatedAt time.Time `db:"updated_at"` +} + +type TelegramWebhook struct { + UserID string `db:"user_id"` + WebhookUUID string `db:"webhook_uuid"` + SecretToken string `db:"secret_token"` + BotTokenEnc []byte `db:"bot_token_enc"` + BotTokenNonce []byte `db:"bot_token_nonce"` + ChatID *int64 `db:"chat_id"` + PendingLink bool `db:"pending_link"` + CreatedAt time.Time `db:"created_at"` +} + +func (t *TelegramWebhook) IsLinked() bool { + return !t.PendingLink && t.ChatID != nil +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/notifications/handler.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/notifications/handler.go new file mode 100644 index 00000000..2880a9a8 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/notifications/handler.go @@ -0,0 +1,226 @@ +// ©AngelaMos | 2026 +// handler.go + +package notifications + +import ( + "encoding/json" + "errors" + "net/http" + + "github.com/go-chi/chi/v5" + "github.com/go-playground/validator/v10" + + "github.com/carterperez-dev/monitor-the-situation/backend/internal/core" + "github.com/carterperez-dev/monitor-the-situation/backend/internal/middleware" +) + +const headerTelegramSecret = "X-Telegram-Bot-Api-Secret-Token" + +type Handler struct { + service *Service + validator *validator.Validate +} + +func NewHandler(service *Service) *Handler { + return &Handler{ + service: service, + validator: validator.New(validator.WithRequiredStructEnabled()), + } +} + +func (h *Handler) RegisterRoutes( + r chi.Router, + authenticator func(http.Handler) http.Handler, +) { + r.Route("/notifications", func(r chi.Router) { + r.Post( + "/telegram/webhook/{uuid}", + h.HandleTelegramUpdate, + ) + + r.Group(func(r chi.Router) { + r.Use(authenticator) + + r.Get("/channels", h.ListChannels) + r.Post("/channels", h.CreateChannel) + r.Delete("/channels/{id}", h.DeleteChannel) + r.Post("/channels/{id}/test", h.TestChannel) + + r.Post("/telegram", h.RegisterTelegram) + r.Delete("/telegram", h.UnlinkTelegram) + r.Post("/telegram/test", h.TestTelegram) + r.Get("/telegram/status", h.GetTelegramStatus) + }) + }) +} + +func (h *Handler) ListChannels(w http.ResponseWriter, r *http.Request) { + userID := middleware.GetUserID(r.Context()) + + resp, err := h.service.ListChannels(r.Context(), userID) + if err != nil { + core.InternalServerError(w, err) + return + } + + core.OK(w, resp) +} + +func (h *Handler) CreateChannel(w http.ResponseWriter, r *http.Request) { + userID := middleware.GetUserID(r.Context()) + + var req CreateChannelRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + core.BadRequest(w, "invalid request body") + return + } + + if err := h.validator.Struct(req); err != nil { + core.BadRequest(w, core.FormatValidationError(err)) + return + } + + ch, err := h.service.CreateChannel(r.Context(), userID, req) + if err != nil { + core.InternalServerError(w, err) + return + } + + core.Created(w, ch) +} + +func (h *Handler) DeleteChannel(w http.ResponseWriter, r *http.Request) { + userID := middleware.GetUserID(r.Context()) + id := chi.URLParam(r, "id") + + if err := h.service.DeleteChannel(r.Context(), id, userID); err != nil { + if errors.Is(err, core.ErrNotFound) { + core.NotFound(w, "channel") + return + } + core.InternalServerError(w, err) + return + } + + core.NoContent(w) +} + +func (h *Handler) TestChannel(w http.ResponseWriter, r *http.Request) { + userID := middleware.GetUserID(r.Context()) + id := chi.URLParam(r, "id") + + if err := h.service.TestChannel(r.Context(), id, userID); err != nil { + if errors.Is(err, core.ErrNotFound) { + core.NotFound(w, "channel") + return + } + core.JSONError(w, core.NewAppError( + err, + "test notification failed: "+err.Error(), + http.StatusBadGateway, + "NOTIFICATION_FAILED", + )) + return + } + + core.NoContent(w) +} + +func (h *Handler) RegisterTelegram(w http.ResponseWriter, r *http.Request) { + userID := middleware.GetUserID(r.Context()) + + var req RegisterTelegramRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + core.BadRequest(w, "invalid request body") + return + } + + if err := h.validator.Struct(req); err != nil { + core.BadRequest(w, core.FormatValidationError(err)) + return + } + + resp, err := h.service.RegisterTelegram(r.Context(), userID, req) + if err != nil { + core.InternalServerError(w, err) + return + } + + core.Created(w, resp) +} + +func (h *Handler) UnlinkTelegram(w http.ResponseWriter, r *http.Request) { + userID := middleware.GetUserID(r.Context()) + + if err := h.service.UnlinkTelegram(r.Context(), userID); err != nil { + if errors.Is(err, core.ErrNotFound) { + core.NotFound(w, "telegram channel") + return + } + core.InternalServerError(w, err) + return + } + + core.NoContent(w) +} + +func (h *Handler) TestTelegram(w http.ResponseWriter, r *http.Request) { + userID := middleware.GetUserID(r.Context()) + + if err := h.service.TestTelegram(r.Context(), userID); err != nil { + if errors.Is(err, core.ErrNotFound) { + core.NotFound(w, "telegram channel") + return + } + core.JSONError(w, core.NewAppError( + err, + err.Error(), + http.StatusBadGateway, + "NOTIFICATION_FAILED", + )) + return + } + + core.NoContent(w) +} + +func (h *Handler) GetTelegramStatus(w http.ResponseWriter, r *http.Request) { + userID := middleware.GetUserID(r.Context()) + + status, err := h.service.GetTelegramStatus(r.Context(), userID) + if err != nil { + core.InternalServerError(w, err) + return + } + + core.OK(w, status) +} + +func (h *Handler) HandleTelegramUpdate( + w http.ResponseWriter, + r *http.Request, +) { + webhookUUID := chi.URLParam(r, "uuid") + secretToken := r.Header.Get(headerTelegramSecret) + + var update telegramUpdate + if err := json.NewDecoder(r.Body).Decode(&update); err != nil { + w.WriteHeader(http.StatusBadRequest) + return + } + + if err := h.service.HandleTelegramUpdate( + r.Context(), webhookUUID, secretToken, update, + ); err != nil { + if errors.Is(err, core.ErrNotFound) || + errors.Is(err, core.ErrForbidden) { + w.WriteHeader(http.StatusUnauthorized) + return + } + w.WriteHeader(http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusOK) +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/notifications/repository.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/notifications/repository.go new file mode 100644 index 00000000..c3215e14 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/notifications/repository.go @@ -0,0 +1,259 @@ +// ©AngelaMos | 2026 +// repository.go + +package notifications + +import ( + "context" + "database/sql" + "errors" + "fmt" + + "github.com/carterperez-dev/monitor-the-situation/backend/internal/core" +) + +type Repository interface { + ListChannels(ctx context.Context, userID string) ([]Channel, error) + GetChannel(ctx context.Context, id, userID string) (*Channel, error) + CreateChannel(ctx context.Context, ch *Channel) error + DeleteChannel(ctx context.Context, id, userID string) error + MarkChannelInvalid(ctx context.Context, id string) error + + GetTelegramWebhook( + ctx context.Context, + userID string, + ) (*TelegramWebhook, error) + GetTelegramWebhookByUUID( + ctx context.Context, + uuid string, + ) (*TelegramWebhook, error) + UpsertTelegramWebhook(ctx context.Context, tw *TelegramWebhook) error + UpdateTelegramChatID(ctx context.Context, uuid string, chatID int64) error + DeleteTelegramWebhook(ctx context.Context, userID string) error +} + +type repository struct { + db core.DBTX +} + +func NewRepository(db core.DBTX) Repository { + return &repository{db: db} +} + +func (r *repository) ListChannels( + ctx context.Context, + userID string, +) ([]Channel, error) { + query := ` + SELECT id, user_id, type, label, config_enc, nonce, + invalid, created_at, updated_at + FROM alert_channels + WHERE user_id = $1 + ORDER BY created_at DESC` + + var channels []Channel + if err := r.db.SelectContext(ctx, &channels, query, userID); err != nil { + return nil, fmt.Errorf("list channels: %w", err) + } + return channels, nil +} + +func (r *repository) GetChannel( + ctx context.Context, + id, userID string, +) (*Channel, error) { + query := ` + SELECT id, user_id, type, label, config_enc, nonce, + invalid, created_at, updated_at + FROM alert_channels + WHERE id = $1 AND user_id = $2` + + var ch Channel + err := r.db.GetContext(ctx, &ch, query, id, userID) + if errors.Is(err, sql.ErrNoRows) { + return nil, fmt.Errorf("get channel: %w", core.ErrNotFound) + } + if err != nil { + return nil, fmt.Errorf("get channel: %w", err) + } + return &ch, nil +} + +func (r *repository) CreateChannel(ctx context.Context, ch *Channel) error { + query := ` + INSERT INTO alert_channels + (id, user_id, type, label, config_enc, nonce) + VALUES ($1, $2, $3, $4, $5, $6) + RETURNING created_at, updated_at` + + err := r.db.GetContext(ctx, ch, query, + ch.ID, + ch.UserID, + ch.Type, + ch.Label, + ch.ConfigEnc, + ch.Nonce, + ) + if err != nil { + return fmt.Errorf("create channel: %w", err) + } + return nil +} + +func (r *repository) DeleteChannel( + ctx context.Context, + id, userID string, +) error { + query := `DELETE FROM alert_channels WHERE id = $1 AND user_id = $2` + + result, err := r.db.ExecContext(ctx, query, id, userID) + if err != nil { + return fmt.Errorf("delete channel: %w", err) + } + + rows, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("delete channel: %w", err) + } + if rows == 0 { + return fmt.Errorf("delete channel: %w", core.ErrNotFound) + } + return nil +} + +func (r *repository) MarkChannelInvalid(ctx context.Context, id string) error { + query := ` + UPDATE alert_channels + SET invalid = true, updated_at = NOW() + WHERE id = $1` + + _, err := r.db.ExecContext(ctx, query, id) + if err != nil { + return fmt.Errorf("mark channel invalid: %w", err) + } + return nil +} + +func (r *repository) GetTelegramWebhook( + ctx context.Context, + userID string, +) (*TelegramWebhook, error) { + query := ` + SELECT user_id, webhook_uuid, secret_token, bot_token_enc, + bot_token_nonce, chat_id, pending_link, created_at + FROM telegram_webhooks + WHERE user_id = $1` + + var tw TelegramWebhook + err := r.db.GetContext(ctx, &tw, query, userID) + if errors.Is(err, sql.ErrNoRows) { + return nil, fmt.Errorf("get telegram webhook: %w", core.ErrNotFound) + } + if err != nil { + return nil, fmt.Errorf("get telegram webhook: %w", err) + } + return &tw, nil +} + +func (r *repository) GetTelegramWebhookByUUID( + ctx context.Context, + uuid string, +) (*TelegramWebhook, error) { + query := ` + SELECT user_id, webhook_uuid, secret_token, bot_token_enc, + bot_token_nonce, chat_id, pending_link, created_at + FROM telegram_webhooks + WHERE webhook_uuid = $1` + + var tw TelegramWebhook + err := r.db.GetContext(ctx, &tw, query, uuid) + if errors.Is(err, sql.ErrNoRows) { + return nil, fmt.Errorf( + "get telegram webhook by uuid: %w", + core.ErrNotFound, + ) + } + if err != nil { + return nil, fmt.Errorf("get telegram webhook by uuid: %w", err) + } + return &tw, nil +} + +func (r *repository) UpsertTelegramWebhook( + ctx context.Context, + tw *TelegramWebhook, +) error { + query := ` + INSERT INTO telegram_webhooks + (user_id, webhook_uuid, secret_token, + bot_token_enc, bot_token_nonce, pending_link) + VALUES ($1, $2, $3, $4, $5, $6) + ON CONFLICT (user_id) DO UPDATE + SET webhook_uuid = EXCLUDED.webhook_uuid, + secret_token = EXCLUDED.secret_token, + bot_token_enc = EXCLUDED.bot_token_enc, + bot_token_nonce = EXCLUDED.bot_token_nonce, + chat_id = NULL, + pending_link = true, + created_at = now() + RETURNING created_at` + + err := r.db.GetContext(ctx, &tw.CreatedAt, query, + tw.UserID, + tw.WebhookUUID, + tw.SecretToken, + tw.BotTokenEnc, + tw.BotTokenNonce, + tw.PendingLink, + ) + if err != nil { + return fmt.Errorf("upsert telegram webhook: %w", err) + } + return nil +} + +func (r *repository) UpdateTelegramChatID( + ctx context.Context, + uuid string, + chatID int64, +) error { + query := ` + UPDATE telegram_webhooks + SET chat_id = $2, pending_link = false + WHERE webhook_uuid = $1` + + result, err := r.db.ExecContext(ctx, query, uuid, chatID) + if err != nil { + return fmt.Errorf("update telegram chat_id: %w", err) + } + + rows, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("update telegram chat_id: %w", err) + } + if rows == 0 { + return fmt.Errorf("update telegram chat_id: %w", core.ErrNotFound) + } + return nil +} + +func (r *repository) DeleteTelegramWebhook( + ctx context.Context, + userID string, +) error { + query := `DELETE FROM telegram_webhooks WHERE user_id = $1` + + result, err := r.db.ExecContext(ctx, query, userID) + if err != nil { + return fmt.Errorf("delete telegram webhook: %w", err) + } + + rows, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("delete telegram webhook: %w", err) + } + if rows == 0 { + return fmt.Errorf("delete telegram webhook: %w", core.ErrNotFound) + } + return nil +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/notifications/sender.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/notifications/sender.go new file mode 100644 index 00000000..70a8f617 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/notifications/sender.go @@ -0,0 +1,223 @@ +// ©AngelaMos | 2026 +// sender.go + +package notifications + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "time" +) + +const ( + telegramAPIBase = "https://api.telegram.org" + senderTimeout = 10 * time.Second + testMessage = "Monitor the Situation — test notification. Channel configured successfully." +) + +type Sender struct { + client *http.Client +} + +func NewSender() *Sender { + return &Sender{ + client: &http.Client{Timeout: senderTimeout}, + } +} + +func (s *Sender) TestTelegram( + ctx context.Context, + botToken string, + chatID int64, +) error { + return s.sendTelegram(ctx, botToken, chatID, testMessage) +} + +// SendTelegram is the exported version used by the alerts engine. +func (s *Sender) SendTelegram( + ctx context.Context, + botToken string, + chatID int64, + text string, +) error { + return s.sendTelegram(ctx, botToken, chatID, text) +} + +// SendSlack posts a plain-text message to a Slack incoming-webhook URL. +func (s *Sender) SendSlack(ctx context.Context, webhookURL, text string) error { + payload, err := json.Marshal(map[string]string{"text": text}) + if err != nil { + return fmt.Errorf("marshal slack payload: %w", err) + } + return s.postWebhook(ctx, webhookURL, payload) +} + +// SendDiscord posts a plain-text message to a Discord webhook URL. +func (s *Sender) SendDiscord( + ctx context.Context, + webhookURL, text string, +) error { + payload, err := json.Marshal(map[string]string{"content": text}) + if err != nil { + return fmt.Errorf("marshal discord payload: %w", err) + } + return s.postWebhook(ctx, webhookURL, payload) +} + +func (s *Sender) sendTelegram( + ctx context.Context, + botToken string, + chatID int64, + text string, +) error { + url := fmt.Sprintf("%s/bot%s/sendMessage", telegramAPIBase, botToken) + + payload, err := json.Marshal(map[string]any{ + "chat_id": chatID, + "text": text, + }) + if err != nil { + return fmt.Errorf("marshal telegram payload: %w", err) + } + + req, err := http.NewRequestWithContext( + ctx, http.MethodPost, url, bytes.NewReader(payload), + ) + if err != nil { + return fmt.Errorf("create telegram request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + + resp, err := s.client.Do(req) + if err != nil { + return fmt.Errorf("send telegram: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf( + "telegram sendMessage: unexpected status %d", + resp.StatusCode, + ) + } + return nil +} + +func (s *Sender) SetTelegramWebhook( + ctx context.Context, + botToken, webhookURL, secretToken string, +) error { + url := fmt.Sprintf("%s/bot%s/setWebhook", telegramAPIBase, botToken) + + payload, err := json.Marshal(map[string]any{ + "url": webhookURL, + "secret_token": secretToken, + "allowed_updates": []string{"message"}, + "drop_pending_updates": true, + }) + if err != nil { + return fmt.Errorf("marshal setWebhook payload: %w", err) + } + + req, err := http.NewRequestWithContext( + ctx, http.MethodPost, url, bytes.NewReader(payload), + ) + if err != nil { + return fmt.Errorf("create setWebhook request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + + resp, err := s.client.Do(req) + if err != nil { + return fmt.Errorf("setWebhook: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf( + "setWebhook: unexpected status %d", + resp.StatusCode, + ) + } + return nil +} + +func (s *Sender) DeleteTelegramWebhook( + ctx context.Context, + botToken string, +) error { + url := fmt.Sprintf( + "%s/bot%s/deleteWebhook", + telegramAPIBase, + botToken, + ) + + req, err := http.NewRequestWithContext( + ctx, http.MethodPost, url, http.NoBody, + ) + if err != nil { + return fmt.Errorf("create deleteWebhook request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + + resp, err := s.client.Do(req) + if err != nil { + return fmt.Errorf("deleteWebhook: %w", err) + } + defer resp.Body.Close() + + return nil +} + +func (s *Sender) TestSlack(ctx context.Context, webhookURL string) error { + payload, err := json.Marshal(map[string]string{ + "text": testMessage, + }) + if err != nil { + return fmt.Errorf("marshal slack payload: %w", err) + } + + return s.postWebhook(ctx, webhookURL, payload) +} + +func (s *Sender) TestDiscord(ctx context.Context, webhookURL string) error { + payload, err := json.Marshal(map[string]string{ + "content": testMessage, + }) + if err != nil { + return fmt.Errorf("marshal discord payload: %w", err) + } + + return s.postWebhook(ctx, webhookURL, payload) +} + +func (s *Sender) postWebhook( + ctx context.Context, + webhookURL string, + payload []byte, +) error { + req, err := http.NewRequestWithContext( + ctx, http.MethodPost, webhookURL, bytes.NewReader(payload), + ) + if err != nil { + return fmt.Errorf("create webhook request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + + resp, err := s.client.Do(req) + if err != nil { + return fmt.Errorf("send webhook: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf( + "webhook: unexpected status %d", + resp.StatusCode, + ) + } + return nil +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/notifications/service.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/notifications/service.go new file mode 100644 index 00000000..29a8d066 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/notifications/service.go @@ -0,0 +1,327 @@ +// ©AngelaMos | 2026 +// service.go + +package notifications + +import ( + "context" + "crypto/rand" + "crypto/subtle" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "log/slog" + + "github.com/google/uuid" + + "github.com/carterperez-dev/monitor-the-situation/backend/internal/core" +) + +type Service struct { + repo Repository + enc *Encryptor + sender *Sender + publicURL string + logger *slog.Logger +} + +func NewService( + repo Repository, + enc *Encryptor, + sender *Sender, + publicURL string, + logger *slog.Logger, +) *Service { + return &Service{ + repo: repo, + enc: enc, + sender: sender, + publicURL: publicURL, + logger: logger, + } +} + +func (s *Service) ListChannels( + ctx context.Context, + userID string, +) (*ChannelListResponse, error) { + channels, err := s.repo.ListChannels(ctx, userID) + if err != nil { + return nil, err + } + + resp := &ChannelListResponse{ + Channels: make([]ChannelResponse, 0, len(channels)), + } + + for _, ch := range channels { + resp.Channels = append(resp.Channels, ChannelResponse{ + ID: ch.ID, + Type: ch.Type, + Label: ch.Label, + Invalid: ch.Invalid, + CreatedAt: ch.CreatedAt, + }) + } + + tw, err := s.repo.GetTelegramWebhook(ctx, userID) + if err != nil && !errors.Is(err, core.ErrNotFound) { + return nil, err + } + + if tw != nil { + resp.Telegram = TelegramStatusResponse{ + Configured: true, + Linked: tw.IsLinked(), + PendingLink: tw.PendingLink, + WebhookURL: s.webhookURL(tw.WebhookUUID), + CreatedAt: tw.CreatedAt, + } + } + + return resp, nil +} + +func (s *Service) CreateChannel( + ctx context.Context, + userID string, + req CreateChannelRequest, +) (*ChannelResponse, error) { + cfg := webhookChannelConfig{WebhookURL: req.WebhookURL} + + raw, err := json.Marshal(cfg) + if err != nil { + return nil, fmt.Errorf("marshal channel config: %w", err) + } + + enc, nonce, err := s.enc.Encrypt(raw) + if err != nil { + return nil, fmt.Errorf("encrypt channel config: %w", err) + } + + ch := &Channel{ + ID: uuid.New().String(), + UserID: userID, + Type: req.Type, + Label: req.Label, + ConfigEnc: enc, + Nonce: nonce, + } + + if err := s.repo.CreateChannel(ctx, ch); err != nil { + return nil, err + } + + return &ChannelResponse{ + ID: ch.ID, + Type: ch.Type, + Label: ch.Label, + Invalid: ch.Invalid, + CreatedAt: ch.CreatedAt, + }, nil +} + +func (s *Service) DeleteChannel( + ctx context.Context, + id, userID string, +) error { + return s.repo.DeleteChannel(ctx, id, userID) +} + +func (s *Service) TestChannel( + ctx context.Context, + id, userID string, +) error { + ch, err := s.repo.GetChannel(ctx, id, userID) + if err != nil { + return err + } + + raw, err := s.enc.Decrypt(ch.ConfigEnc, ch.Nonce) + if err != nil { + return fmt.Errorf("decrypt channel config: %w", err) + } + + var cfg webhookChannelConfig + if err := json.Unmarshal(raw, &cfg); err != nil { + return fmt.Errorf("unmarshal channel config: %w", err) + } + + switch ch.Type { + case ChannelTypeSlack: + return s.sender.TestSlack(ctx, cfg.WebhookURL) + case ChannelTypeDiscord: + return s.sender.TestDiscord(ctx, cfg.WebhookURL) + default: + return fmt.Errorf("unsupported channel type: %s", ch.Type) + } +} + +func (s *Service) RegisterTelegram( + ctx context.Context, + userID string, + req RegisterTelegramRequest, +) (*RegisterTelegramResponse, error) { + encToken, nonce, err := s.enc.Encrypt([]byte(req.BotToken)) + if err != nil { + return nil, fmt.Errorf("encrypt bot token: %w", err) + } + + webhookUUID := uuid.New().String() + secretToken, err := randomHex(32) + if err != nil { + return nil, fmt.Errorf("generate secret token: %w", err) + } + + tw := &TelegramWebhook{ + UserID: userID, + WebhookUUID: webhookUUID, + SecretToken: secretToken, + BotTokenEnc: encToken, + BotTokenNonce: nonce, + PendingLink: true, + } + + if err := s.repo.UpsertTelegramWebhook(ctx, tw); err != nil { + return nil, err + } + + wURL := s.webhookURL(webhookUUID) + + webhookRegistered := true + if err := s.sender.SetTelegramWebhook( + ctx, req.BotToken, wURL, secretToken, + ); err != nil { + webhookRegistered = false + s.logger.Warn( + "failed to auto-register telegram webhook", + "error", err, + "webhook_url", wURL, + ) + } + + return &RegisterTelegramResponse{ + WebhookURL: wURL, + WebhookRegistered: webhookRegistered, + }, nil +} + +func (s *Service) GetTelegramStatus( + ctx context.Context, + userID string, +) (*TelegramStatusResponse, error) { + tw, err := s.repo.GetTelegramWebhook(ctx, userID) + if errors.Is(err, core.ErrNotFound) { + return &TelegramStatusResponse{Configured: false}, nil + } + if err != nil { + return nil, err + } + + return &TelegramStatusResponse{ + Configured: true, + Linked: tw.IsLinked(), + PendingLink: tw.PendingLink, + WebhookURL: s.webhookURL(tw.WebhookUUID), + CreatedAt: tw.CreatedAt, + }, nil +} + +func (s *Service) UnlinkTelegram(ctx context.Context, userID string) error { + tw, err := s.repo.GetTelegramWebhook(ctx, userID) + if errors.Is(err, core.ErrNotFound) { + return core.ErrNotFound + } + if err != nil { + return err + } + + botToken, err := s.enc.Decrypt(tw.BotTokenEnc, tw.BotTokenNonce) + if err == nil { + if delErr := s.sender.DeleteTelegramWebhook( + ctx, string(botToken), + ); delErr != nil { + s.logger.Warn( + "failed to delete telegram webhook on unlink", + "error", delErr, + ) + } + } + + return s.repo.DeleteTelegramWebhook(ctx, userID) +} + +func (s *Service) TestTelegram(ctx context.Context, userID string) error { + tw, err := s.repo.GetTelegramWebhook(ctx, userID) + if errors.Is(err, core.ErrNotFound) { + return fmt.Errorf( + "no telegram channel configured: %w", + core.ErrNotFound, + ) + } + if err != nil { + return err + } + + if !tw.IsLinked() { + return fmt.Errorf( + "telegram not linked yet: send any message to your bot first", + ) + } + + botToken, err := s.enc.Decrypt(tw.BotTokenEnc, tw.BotTokenNonce) + if err != nil { + return fmt.Errorf("decrypt bot token: %w", err) + } + + return s.sender.TestTelegram(ctx, string(botToken), *tw.ChatID) +} + +func (s *Service) HandleTelegramUpdate( + ctx context.Context, + webhookUUID, secretToken string, + update telegramUpdate, +) error { + tw, err := s.repo.GetTelegramWebhookByUUID(ctx, webhookUUID) + if errors.Is(err, core.ErrNotFound) { + return core.ErrNotFound + } + if err != nil { + return err + } + + if subtle.ConstantTimeCompare( + []byte(tw.SecretToken), + []byte(secretToken), + ) != 1 { + return core.ErrForbidden + } + + if update.Message == nil { + return nil + } + + chatID := update.Message.Chat.ID + if chatID == 0 { + return nil + } + + return s.repo.UpdateTelegramChatID(ctx, webhookUUID, chatID) +} + +func (s *Service) webhookURL(webhookUUID string) string { + return fmt.Sprintf( + "%s/api/v1/notifications/telegram/webhook/%s", + s.publicURL, + webhookUUID, + ) +} + +func randomHex(n int) (string, error) { + b := make([]byte, n) + if _, err := rand.Read(b); err != nil { + return "", err + } + return hex.EncodeToString(b), nil +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/ratelimit/limiter.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/ratelimit/limiter.go index 5e842475..4e969bbb 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/ratelimit/limiter.go +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/ratelimit/limiter.go @@ -55,7 +55,10 @@ func New[T any](cfg Config) *Limiter[T] { } } -func (l *Limiter[T]) Do(ctx context.Context, fn func(context.Context) (T, error)) (T, error) { +func (l *Limiter[T]) Do( + ctx context.Context, + fn func(context.Context) (T, error), +) (T, error) { var zero T if err := l.rl.Wait(ctx); err != nil { return zero, err diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/ratelimit/limiter_test.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/ratelimit/limiter_test.go index 8299b139..6002e1f4 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/ratelimit/limiter_test.go +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/ratelimit/limiter_test.go @@ -52,7 +52,10 @@ func TestLimiter_OpensBreakerOnRepeatedFailures(t *testing.T) { failure := errors.New("upstream broken") for i := 0; i < 3; i++ { - _, _ = lim.Do(ctx, func(_ context.Context) (string, error) { return "", failure }) + _, _ = lim.Do( + ctx, + func(_ context.Context) (string, error) { return "", failure }, + ) } called := false @@ -60,6 +63,10 @@ func TestLimiter_OpensBreakerOnRepeatedFailures(t *testing.T) { called = true return "", nil }) - require.False(t, called, "should not have called function — breaker should be open") + require.False( + t, + called, + "should not have called function — breaker should be open", + ) require.ErrorIs(t, err, gobreaker.ErrOpenState) } diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/redisring/ring.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/redisring/ring.go index cf6be854..9d6ed444 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/redisring/ring.go +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/redisring/ring.go @@ -32,7 +32,12 @@ func New(rc *redis.Client, cfg Config) *Client { return &Client{rc: rc, retention: cfg.Retention} } -func (c *Client) Push(ctx context.Context, key string, score int64, payload []byte) error { +func (c *Client) Push( + ctx context.Context, + key string, + score int64, + payload []byte, +) error { cutoff := time.UnixMilli(score).Add(-c.retention).UnixMilli() pipe := c.rc.Pipeline() pipe.ZAdd(ctx, key, redis.Z{Score: float64(score), Member: payload}) @@ -43,7 +48,11 @@ func (c *Client) Push(ctx context.Context, key string, score int64, payload []by return nil } -func (c *Client) Recent(ctx context.Context, key string, n int) ([][]byte, error) { +func (c *Client) Recent( + ctx context.Context, + key string, + n int, +) ([][]byte, error) { if n <= 0 { return nil, nil } @@ -63,7 +72,11 @@ func (c *Client) Recent(ctx context.Context, key string, n int) ([][]byte, error return out, nil } -func (c *Client) Range(ctx context.Context, key string, fromScore, toScore int64) ([][]byte, error) { +func (c *Client) Range( + ctx context.Context, + key string, + fromScore, toScore int64, +) ([][]byte, error) { res, err := c.rc.ZRangeByScore(ctx, key, &redis.ZRangeBy{ Min: strconv.FormatInt(fromScore, 10), Max: strconv.FormatInt(toScore, 10), diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/redisring/ring_test.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/redisring/ring_test.go index 6ec06567..7955222e 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/redisring/ring_test.go +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/redisring/ring_test.go @@ -42,7 +42,15 @@ func TestRing_PushAndRecentRoundtrips(t *testing.T) { now := time.Now().UTC() for i := 0; i < 5; i++ { ts := now.Add(-time.Duration(i) * time.Minute) - require.NoError(t, r.Push(ctx, "test:key", ts.UnixMilli(), []byte(fmt.Sprintf(`{"i":%d}`, i)))) + require.NoError( + t, + r.Push( + ctx, + "test:key", + ts.UnixMilli(), + []byte(fmt.Sprintf(`{"i":%d}`, i)), + ), + ) } got, err := r.Recent(ctx, "test:key", 3) @@ -58,7 +66,15 @@ func TestRing_RetentionPrunesOldEntries(t *testing.T) { now := time.Now().UTC() for i := 0; i < 5; i++ { old := now.Add(-2 * time.Hour).Add(-time.Duration(i) * time.Minute) - require.NoError(t, r.Push(ctx, "test:key", old.UnixMilli(), []byte(fmt.Sprintf(`old-%d`, i)))) + require.NoError( + t, + r.Push( + ctx, + "test:key", + old.UnixMilli(), + []byte(fmt.Sprintf(`old-%d`, i)), + ), + ) } require.NoError(t, r.Push(ctx, "test:key", now.UnixMilli(), []byte("now"))) @@ -76,7 +92,15 @@ func TestRing_RangeReturnsItemsWithinScoreWindow(t *testing.T) { now := time.Now().UTC() for i := 0; i < 10; i++ { ts := now.Add(-time.Duration(i) * time.Minute) - require.NoError(t, r.Push(ctx, "test:key", ts.UnixMilli(), []byte(fmt.Sprintf("i=%d", i)))) + require.NoError( + t, + r.Push( + ctx, + "test:key", + ts.UnixMilli(), + []byte(fmt.Sprintf("i=%d", i)), + ), + ) } from := now.Add(-3 * time.Minute).UnixMilli() diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/snapshot/handler.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/snapshot/handler.go index 9e43a403..1393f2d5 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/snapshot/handler.go +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/snapshot/handler.go @@ -4,8 +4,9 @@ package snapshot import ( - "encoding/json" "net/http" + + "github.com/carterperez-dev/monitor-the-situation/backend/internal/core" ) type Handler struct { @@ -17,13 +18,9 @@ func NewHandler(store *Store) *Handler { return &Handler{store: store} } func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { all, err := h.store.GetAll(r.Context()) if err != nil { - http.Error(w, "snapshot read failed", http.StatusInternalServerError) + core.InternalServerError(w, err) return } - w.Header().Set("Content-Type", "application/json") w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate") - if err := json.NewEncoder(w).Encode(all); err != nil { - http.Error(w, "encode failed", http.StatusInternalServerError) - return - } + core.OK(w, all) } diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/snapshot/persister.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/snapshot/persister.go index bd694aef..5c899c42 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/snapshot/persister.go +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/snapshot/persister.go @@ -20,5 +20,8 @@ func (s StorePersister) Save(ctx context.Context, ev events.Event) error { if err != nil { return fmt.Errorf("marshal payload: %w", err) } + if ev.Topic == events.TopicCoinbasePrice { + return s.Store.MergeSymbolMap(ctx, ev.Topic, raw) + } return s.Store.PutLatest(ctx, ev.Topic, raw) } diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/snapshot/snapshot_test.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/snapshot/snapshot_test.go index c70e9f74..25fc629f 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/snapshot/snapshot_test.go +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/snapshot/snapshot_test.go @@ -42,8 +42,22 @@ func TestStore_PutLatestAndGetAll(t *testing.T) { store := snapshot.NewStore(rdb) ctx := context.Background() - require.NoError(t, store.PutLatest(ctx, events.TopicHeartbeat, json.RawMessage(`{"ts":"2026-05-01T00:00:00Z"}`))) - require.NoError(t, store.PutLatest(ctx, events.TopicCVENew, json.RawMessage(`{"id":"CVE-2026-9999"}`))) + require.NoError( + t, + store.PutLatest( + ctx, + events.TopicHeartbeat, + json.RawMessage(`{"ts":"2026-05-01T00:00:00Z"}`), + ), + ) + require.NoError( + t, + store.PutLatest( + ctx, + events.TopicCVENew, + json.RawMessage(`{"id":"CVE-2026-9999"}`), + ), + ) all, err := store.GetAll(ctx) require.NoError(t, err) diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/snapshot/store.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/snapshot/store.go index b4968f3c..541c95aa 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/snapshot/store.go +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/snapshot/store.go @@ -7,13 +7,19 @@ import ( "context" "encoding/json" "fmt" + "strings" "github.com/redis/go-redis/v9" "github.com/carterperez-dev/monitor-the-situation/backend/internal/events" ) -const keyPrefix = "state:" +const ( + keyPrefix = "state:" + keyHashPrice = "state:coinbase_price" + scanCount = 100 + wrongTypeMarker = "WRONGTYPE" +) type Store struct { rdb *redis.Client @@ -21,26 +27,109 @@ type Store struct { func NewStore(rdb *redis.Client) *Store { return &Store{rdb: rdb} } -func (s *Store) PutLatest(ctx context.Context, topic events.Topic, payload json.RawMessage) error { - if err := s.rdb.Set(ctx, keyPrefix+string(topic), []byte(payload), 0).Err(); err != nil { +func (s *Store) PutLatest( + ctx context.Context, + topic events.Topic, + payload json.RawMessage, +) error { + if err := s.rdb.Set(ctx, keyPrefix+string(topic), []byte(payload), 0). + Err(); err != nil { return fmt.Errorf("redis set %s: %w", topic, err) } return nil } -func (s *Store) GetAll(ctx context.Context) (map[string]json.RawMessage, error) { - keys, err := s.rdb.Keys(ctx, keyPrefix+"*").Result() - if err != nil { - return nil, err +func (s *Store) MergeSymbolMap( + ctx context.Context, + topic events.Topic, + payload json.RawMessage, +) error { + var tick struct { + Symbol string `json:"symbol"` } - out := make(map[string]json.RawMessage, len(keys)) - for _, k := range keys { - v, err := s.rdb.Get(ctx, k).Bytes() + if err := json.Unmarshal(payload, &tick); err != nil || tick.Symbol == "" { + return fmt.Errorf("coinbase merge: no symbol in payload") + } + + key := keyPrefix + string(topic) + err := s.rdb.HSet(ctx, key, tick.Symbol, []byte(payload)).Err() + if err == nil { + return nil + } + if !isWrongType(err) { + return fmt.Errorf("redis hset %s/%s: %w", topic, tick.Symbol, err) + } + if delErr := s.rdb.Del(ctx, key).Err(); delErr != nil { + return fmt.Errorf("redis del legacy %s: %w", key, delErr) + } + if err := s.rdb.HSet(ctx, key, tick.Symbol, []byte(payload)). + Err(); err != nil { + return fmt.Errorf( + "redis hset %s/%s after migration: %w", + topic, + tick.Symbol, + err, + ) + } + return nil +} + +func isWrongType(err error) bool { + return err != nil && strings.Contains(err.Error(), wrongTypeMarker) +} + +func (s *Store) GetAll( + ctx context.Context, +) (map[string]json.RawMessage, error) { + out := map[string]json.RawMessage{} + var cursor uint64 + for { + keys, next, err := s.rdb.Scan(ctx, cursor, keyPrefix+"*", scanCount). + Result() if err != nil { - continue + return nil, fmt.Errorf("scan state: %w", err) } - topic := k[len(keyPrefix):] - out[topic] = json.RawMessage(v) + for _, k := range keys { + topic := k[len(keyPrefix):] + val, err := s.readKey(ctx, k) + if err != nil { + continue + } + out[topic] = val + } + if next == 0 { + break + } + cursor = next } return out, nil } + +func (s *Store) readKey( + ctx context.Context, + key string, +) (json.RawMessage, error) { + t, err := s.rdb.Type(ctx, key).Result() + if err != nil { + return nil, err + } + switch t { + case "string": + v, err := s.rdb.Get(ctx, key).Bytes() + if err != nil { + return nil, err + } + return json.RawMessage(v), nil + case "hash": + fields, err := s.rdb.HGetAll(ctx, key).Result() + if err != nil { + return nil, err + } + m := make(map[string]json.RawMessage, len(fields)) + for k, v := range fields { + m[k] = json.RawMessage(v) + } + return json.Marshal(m) + } + return nil, fmt.Errorf("unsupported redis type %q for key %s", t, key) +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/user/dto.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/user/dto.go index ba2b0686..011142ca 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/user/dto.go +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/user/dto.go @@ -17,12 +17,23 @@ type UpdateUserRequest struct { Name *string `json:"name,omitempty" validate:"omitempty,min=1,max=100"` } -type UpdateUserRoleRequest struct { - Role string `json:"role" validate:"required,oneof=user admin"` +type UpdateEmailRequest struct { + CurrentPassword string `json:"current_password" validate:"required"` + NewEmail string `json:"new_email" validate:"required,email,max=255"` } -type UpdateUserTierRequest struct { - Tier string `json:"tier" validate:"required,oneof=free pro enterprise"` +type AdminUpdateUserRequest struct { + Name *string `json:"name,omitempty" validate:"omitempty,min=1,max=100"` + Role *string `json:"role,omitempty" validate:"omitempty,oneof=user admin"` + Tier *string `json:"tier,omitempty" validate:"omitempty,oneof=free pro enterprise"` +} + +type AdminCreateUserRequest struct { + Email string `json:"email" validate:"required,email,max=255"` + Password string `json:"password" validate:"required,min=8,max=128"` + Name string `json:"name" validate:"required,min=1,max=100"` + Role *string `json:"role,omitempty" validate:"omitempty,oneof=user admin"` + Tier *string `json:"tier,omitempty" validate:"omitempty,oneof=free pro enterprise"` } type UserResponse struct { diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/user/handler.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/user/handler.go index c4b5f73c..76915993 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/user/handler.go +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/user/handler.go @@ -37,6 +37,7 @@ func (h *Handler) RegisterRoutes( r.Get("/me", h.GetMe) r.Put("/me", h.UpdateMe) + r.Put("/me/email", h.UpdateMyEmail) r.Delete("/me", h.DeleteMe) }) } @@ -84,6 +85,54 @@ func (h *Handler) UpdateMe(w http.ResponseWriter, r *http.Request) { core.OK(w, ToUserResponse(user)) } +// UpdateMyEmail changes the authenticated user's email after re-verifying +// their password. +func (h *Handler) UpdateMyEmail(w http.ResponseWriter, r *http.Request) { + userID := middleware.GetUserID(r.Context()) + + var req UpdateEmailRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + core.BadRequest(w, "invalid request body") + return + } + if err := h.validator.Struct(req); err != nil { + core.BadRequest(w, core.FormatValidationError(err)) + return + } + + user, err := h.service.UpdateEmail( + r.Context(), + userID, + req.CurrentPassword, + req.NewEmail, + ) + if err != nil { + if errors.Is(err, core.ErrUnauthorized) { + core.JSONError( + w, + core.UnauthorizedError("current password is incorrect"), + ) + return + } + if errors.Is(err, core.ErrDuplicateKey) { + core.JSONError(w, core.DuplicateError("email")) + return + } + if errors.Is(err, core.ErrNotFound) { + core.NotFound(w, "user") + return + } + if errors.Is(err, core.ErrInvalidInput) { + core.BadRequest(w, err.Error()) + return + } + core.InternalServerError(w, err) + return + } + + core.OK(w, ToUserResponse(user)) +} + func (h *Handler) DeleteMe(w http.ResponseWriter, r *http.Request) { userID := middleware.GetUserID(r.Context()) @@ -109,10 +158,9 @@ func (h *Handler) RegisterAdminRoutes( r.Use(adminOnly) r.Get("/", h.ListUsers) + r.Post("/", h.AdminCreateUser) r.Get("/{userID}", h.GetUser) - r.Put("/{userID}", h.UpdateUser) - r.Put("/{userID}/role", h.UpdateUserRole) - r.Put("/{userID}/tier", h.UpdateUserTier) + r.Put("/{userID}", h.AdminUpdateUser) r.Delete("/{userID}", h.DeleteUser) }) } @@ -159,11 +207,11 @@ func (h *Handler) GetUser(w http.ResponseWriter, r *http.Request) { core.OK(w, ToUserResponse(user)) } -// UpdateUser updates a specific user's profile (admin only). -func (h *Handler) UpdateUser(w http.ResponseWriter, r *http.Request) { +// AdminUpdateUser updates a specific user — name, role, tier (admin only). +func (h *Handler) AdminUpdateUser(w http.ResponseWriter, r *http.Request) { userID := chi.URLParam(r, "userID") - var req UpdateUserRequest + var req AdminUpdateUserRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { core.BadRequest(w, "invalid request body") return @@ -174,12 +222,16 @@ func (h *Handler) UpdateUser(w http.ResponseWriter, r *http.Request) { return } - user, err := h.service.UpdateUser(r.Context(), userID, req) + user, err := h.service.AdminUpdateUser(r.Context(), userID, req) if err != nil { if errors.Is(err, core.ErrNotFound) { core.NotFound(w, "user") return } + if errors.Is(err, core.ErrInvalidInput) { + core.BadRequest(w, err.Error()) + return + } core.InternalServerError(w, err) return } @@ -187,11 +239,9 @@ func (h *Handler) UpdateUser(w http.ResponseWriter, r *http.Request) { core.OK(w, ToUserResponse(user)) } -// UpdateUserRole changes a user's role (admin only). -func (h *Handler) UpdateUserRole(w http.ResponseWriter, r *http.Request) { - userID := chi.URLParam(r, "userID") - - var req UpdateUserRoleRequest +// AdminCreateUser creates a new user (admin only). +func (h *Handler) AdminCreateUser(w http.ResponseWriter, r *http.Request) { + var req AdminCreateUserRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { core.BadRequest(w, "invalid request body") return @@ -202,45 +252,21 @@ func (h *Handler) UpdateUserRole(w http.ResponseWriter, r *http.Request) { return } - user, err := h.service.UpdateUserRole(r.Context(), userID, req.Role) + user, err := h.service.AdminCreateUser(r.Context(), req) if err != nil { - if errors.Is(err, core.ErrNotFound) { - core.NotFound(w, "user") + if errors.Is(err, core.ErrDuplicateKey) { + core.JSONError(w, core.DuplicateError("email")) + return + } + if errors.Is(err, core.ErrInvalidInput) { + core.BadRequest(w, err.Error()) return } core.InternalServerError(w, err) return } - core.OK(w, ToUserResponse(user)) -} - -// UpdateUserTier changes a user's subscription tier (admin only). -func (h *Handler) UpdateUserTier(w http.ResponseWriter, r *http.Request) { - userID := chi.URLParam(r, "userID") - - var req UpdateUserTierRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - core.BadRequest(w, "invalid request body") - return - } - - if err := h.validator.Struct(req); err != nil { - core.BadRequest(w, core.FormatValidationError(err)) - return - } - - user, err := h.service.UpdateUserTier(r.Context(), userID, req.Tier) - if err != nil { - if errors.Is(err, core.ErrNotFound) { - core.NotFound(w, "user") - return - } - core.InternalServerError(w, err) - return - } - - core.OK(w, ToUserResponse(user)) + core.Created(w, ToUserResponse(user)) } // DeleteUser soft deletes a user account (admin only). @@ -248,7 +274,11 @@ func (h *Handler) DeleteUser(w http.ResponseWriter, r *http.Request) { requesterID := middleware.GetUserID(r.Context()) targetID := chi.URLParam(r, "userID") - if err := h.service.CanDeleteUser(r.Context(), requesterID, targetID); err != nil { + if err := h.service.CanDeleteUser( + r.Context(), + requesterID, + targetID, + ); err != nil { if errors.Is(err, core.ErrForbidden) { core.Forbidden(w, "insufficient permissions") return diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/user/repository.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/user/repository.go index 39c00012..22b4cd94 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/user/repository.go +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/user/repository.go @@ -21,6 +21,7 @@ type Repository interface { GetByEmail(ctx context.Context, email string) (*User, error) Update(ctx context.Context, user *User) error UpdatePassword(ctx context.Context, id, passwordHash string) error + UpdateEmail(ctx context.Context, id, email string) error IncrementTokenVersion(ctx context.Context, id string) error SoftDelete(ctx context.Context, id string) error List(ctx context.Context, params ListUsersParams) ([]User, int, error) @@ -149,6 +150,30 @@ func (r *repository) UpdatePassword( return nil } +func (r *repository) UpdateEmail(ctx context.Context, id, email string) error { + query := ` + UPDATE users + SET email = $2, updated_at = NOW() + WHERE id = $1 AND deleted_at IS NULL` + + result, err := r.db.ExecContext(ctx, query, id, email) + if err != nil { + if isDuplicateKeyError(err) { + return fmt.Errorf("update email: %w", core.ErrDuplicateKey) + } + return fmt.Errorf("update email: %w", err) + } + + rows, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("update email: %w", err) + } + if rows == 0 { + return fmt.Errorf("update email: %w", core.ErrNotFound) + } + return nil +} + func (r *repository) IncrementTokenVersion( ctx context.Context, id string, diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/user/service.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/user/service.go index f772e6fd..5bb1d20a 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/user/service.go +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/user/service.go @@ -80,6 +80,64 @@ func (s *Service) UpdatePassword( return s.repo.UpdatePassword(ctx, userID, passwordHash) } +// SetRole flips a user's role. Used by ADMIN_EMAIL bootstrapping in +// auth.Service: the configured email gets promoted to admin on every +// login/register so a fresh DB after env changes still has an admin. +func (s *Service) SetRole(ctx context.Context, userID, role string) error { + if role != RoleUser && role != RoleAdmin { + return fmt.Errorf( + "set role: invalid role %q: %w", + role, + core.ErrInvalidInput, + ) + } + user, err := s.repo.GetByID(ctx, userID) + if err != nil { + return err + } + user.Role = role + return s.repo.Update(ctx, user) +} + +// UpdateEmail changes a user's email after re-verifying their password. +// Email is normalized to lowercase. Returns ErrDuplicateKey if the new +// email is already taken by another account. +func (s *Service) UpdateEmail( + ctx context.Context, + userID, currentPassword, newEmail string, +) (*User, error) { + newEmail = strings.ToLower(strings.TrimSpace(newEmail)) + if newEmail == "" { + return nil, fmt.Errorf("update email: empty: %w", core.ErrInvalidInput) + } + + user, err := s.repo.GetByID(ctx, userID) + if err != nil { + return nil, err + } + + valid, _, err := core.VerifyPasswordWithRehash( + currentPassword, + user.PasswordHash, + ) + if err != nil { + return nil, fmt.Errorf("verify password: %w", err) + } + if !valid { + return nil, fmt.Errorf("update email: %w", core.ErrUnauthorized) + } + + if user.Email == newEmail { + return user, nil + } + + if err := s.repo.UpdateEmail(ctx, userID, newEmail); err != nil { + return nil, err + } + user.Email = newEmail + return user, nil +} + func (s *Service) GetUser(ctx context.Context, id string) (*User, error) { return s.repo.GetByID(ctx, id) } @@ -105,24 +163,40 @@ func (s *Service) UpdateUser( return user, nil } -func (s *Service) UpdateUserRole( +func (s *Service) AdminUpdateUser( ctx context.Context, - id, role string, + id string, + req AdminUpdateUserRequest, ) (*User, error) { - if role != RoleUser && role != RoleAdmin { - return nil, fmt.Errorf( - "update role: invalid role %q: %w", - role, - core.ErrInvalidInput, - ) - } - user, err := s.repo.GetByID(ctx, id) if err != nil { return nil, err } - user.Role = role + if req.Name != nil { + user.Name = *req.Name + } + if req.Role != nil { + if *req.Role != RoleUser && *req.Role != RoleAdmin { + return nil, fmt.Errorf( + "update role: invalid role %q: %w", + *req.Role, + core.ErrInvalidInput, + ) + } + user.Role = *req.Role + } + if req.Tier != nil { + if *req.Tier != TierFree && *req.Tier != TierPro && + *req.Tier != TierEnterprise { + return nil, fmt.Errorf( + "update tier: invalid tier %q: %w", + *req.Tier, + core.ErrInvalidInput, + ) + } + user.Tier = *req.Tier + } if err := s.repo.Update(ctx, user); err != nil { return nil, err @@ -131,26 +205,46 @@ func (s *Service) UpdateUserRole( return user, nil } -func (s *Service) UpdateUserTier( +func (s *Service) AdminCreateUser( ctx context.Context, - id, tier string, + req AdminCreateUserRequest, ) (*User, error) { - if tier != TierFree && tier != TierPro && tier != TierEnterprise { - return nil, fmt.Errorf( - "update tier: invalid tier %q: %w", - tier, - core.ErrInvalidInput, - ) - } - - user, err := s.repo.GetByID(ctx, id) + hash, err := core.HashPassword(req.Password) if err != nil { - return nil, err + return nil, fmt.Errorf("hash password: %w", err) } - user.Tier = tier + user := &User{ + ID: uuid.New().String(), + Email: strings.ToLower(req.Email), + PasswordHash: hash, + Name: req.Name, + Role: RoleUser, + Tier: TierFree, + } + if req.Role != nil { + if *req.Role != RoleUser && *req.Role != RoleAdmin { + return nil, fmt.Errorf( + "create user: invalid role %q: %w", + *req.Role, + core.ErrInvalidInput, + ) + } + user.Role = *req.Role + } + if req.Tier != nil { + if *req.Tier != TierFree && *req.Tier != TierPro && + *req.Tier != TierEnterprise { + return nil, fmt.Errorf( + "create user: invalid tier %q: %w", + *req.Tier, + core.ErrInvalidInput, + ) + } + user.Tier = *req.Tier + } - if err := s.repo.Update(ctx, user); err != nil { + if err := s.repo.Create(ctx, user); err != nil { return nil, err } diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/ws/envelope_test.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/ws/envelope_test.go index 675acb49..474c29af 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/ws/envelope_test.go +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/ws/envelope_test.go @@ -13,7 +13,10 @@ import ( ) func TestEncodeEnvelope(t *testing.T) { - out, err := ws.EncodeEnvelope("heartbeat", []byte(`{"ts":"2026-05-01T00:00:00Z"}`)) + out, err := ws.EncodeEnvelope( + "heartbeat", + []byte(`{"ts":"2026-05-01T00:00:00Z"}`), + ) require.NoError(t, err) var got map[string]any diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/ws/handler.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/ws/handler.go index 257a870a..21ffcf16 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/ws/handler.go +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/ws/handler.go @@ -6,19 +6,60 @@ package ws import ( "net/http" "strings" + "sync" + "sync/atomic" cdrws "github.com/coder/websocket" "github.com/carterperez-dev/monitor-the-situation/backend/internal/events" + "github.com/carterperez-dev/monitor-the-situation/backend/internal/middleware" ) -type Handler struct { - hub *Hub +// defaultMaxConnsPerIP is permissive — a single user with React +// StrictMode in dev (effect runs twice), two tabs, and a brief reconnect +// race already burns ~6 simultaneous connections from one IP. The hub- +// level MaxSubscribers (default 5000) is the real DoS guard; this just +// blocks a single host from running thousands of sockets. +const defaultMaxConnsPerIP = 25 + +type HandlerConfig struct { + Hub *Hub + MaxConnsPerIP int + TrustedProxyHops int } -func NewHandler(hub *Hub) *Handler { return &Handler{hub: hub} } +type Handler struct { + hub *Hub + maxConnsPerIP int + trustedProxyHops int + perIP sync.Map +} + +func NewHandler(hub *Hub) *Handler { + return NewHandlerWithConfig(HandlerConfig{Hub: hub}) +} + +func NewHandlerWithConfig(cfg HandlerConfig) *Handler { + if cfg.MaxConnsPerIP <= 0 { + cfg.MaxConnsPerIP = defaultMaxConnsPerIP + } + return &Handler{ + hub: cfg.Hub, + maxConnsPerIP: cfg.MaxConnsPerIP, + trustedProxyHops: cfg.TrustedProxyHops, + } +} func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + ip := middleware.ClientIP(r, h.trustedProxyHops) + counter := h.counterFor(ip) + if counter.Add(1) > int32(h.maxConnsPerIP) { + counter.Add(-1) + http.Error(w, "too many connections", http.StatusTooManyRequests) + return + } + defer counter.Add(-1) + c, err := cdrws.Accept(w, r, &cdrws.AcceptOptions{ InsecureSkipVerify: false, }) @@ -30,6 +71,20 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { _ = h.hub.Serve(r.Context(), c, topics) } +func (h *Handler) counterFor(ip string) *atomic.Int32 { + if existing, ok := h.perIP.Load(ip); ok { + if c, ok := existing.(*atomic.Int32); ok { + return c + } + } + fresh := &atomic.Int32{} + actual, _ := h.perIP.LoadOrStore(ip, fresh) + if c, ok := actual.(*atomic.Int32); ok { + return c + } + return fresh +} + func parseTopics(raw string) []events.Topic { if raw == "" { return events.AllTopics() diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/ws/handler_test.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/ws/handler_test.go new file mode 100644 index 00000000..0bddc13a --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/ws/handler_test.go @@ -0,0 +1,101 @@ +// ©AngelaMos | 2026 +// handler_test.go + +package ws_test + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" + + cdrws "github.com/coder/websocket" + "github.com/stretchr/testify/require" + + "github.com/carterperez-dev/monitor-the-situation/backend/internal/ws" +) + +func TestHandler_PerIPCap_RejectsBeyondLimit(t *testing.T) { + hub := ws.NewHub(ws.HubConfig{}) + h := ws.NewHandlerWithConfig(ws.HandlerConfig{ + Hub: hub, + MaxConnsPerIP: 2, + }) + srv := httptest.NewServer( + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + r.RemoteAddr = "203.0.113.7:1111" + h.ServeHTTP(w, r) + }), + ) + defer srv.Close() + + wsURL := "ws" + strings.TrimPrefix(srv.URL, "http") + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + + conns := make([]*cdrws.Conn, 0, 2) + for i := 0; i < 2; i++ { + c, _, err := cdrws.Dial(ctx, wsURL, nil) + require.NoError(t, err) + conns = append(conns, c) + } + require.Eventually(t, func() bool { + return hub.SubscriberCount() == 2 + }, time.Second, 10*time.Millisecond) + + _, resp, err := cdrws.Dial(ctx, wsURL, nil) + require.Error(t, err, "third connection from same IP must be rejected") + if resp != nil { + require.Equal(t, http.StatusTooManyRequests, resp.StatusCode) + } + + for _, c := range conns { + _ = c.CloseNow() + } +} + +func TestHandler_HubCapacity_RefusesNewConn(t *testing.T) { + hub := ws.NewHub(ws.HubConfig{MaxSubscribers: 1}) + h := ws.NewHandlerWithConfig(ws.HandlerConfig{ + Hub: hub, + MaxConnsPerIP: 100, + }) + var ipMu sync.Mutex + ipCounter := 0 + srv := httptest.NewServer( + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ipMu.Lock() + ipCounter++ + r.RemoteAddr = "10.0.0." + string(rune('0'+ipCounter)) + ":1111" + ipMu.Unlock() + h.ServeHTTP(w, r) + }), + ) + defer srv.Close() + + wsURL := "ws" + strings.TrimPrefix(srv.URL, "http") + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + + c1, _, err := cdrws.Dial(ctx, wsURL, nil) + require.NoError(t, err) + defer func() { _ = c1.CloseNow() }() + + require.Eventually(t, func() bool { + return hub.SubscriberCount() == 1 + }, time.Second, 10*time.Millisecond) + + c2, _, err := cdrws.Dial(ctx, wsURL, nil) + if err == nil { + _, _, readErr := c2.Read(ctx) + require.Error( + t, + readErr, + "second connection should be closed by hub at capacity", + ) + _ = c2.CloseNow() + } +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/ws/hub.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/ws/hub.go index 4c7c9440..62f8581a 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/ws/hub.go +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/ws/hub.go @@ -5,6 +5,7 @@ package ws import ( "context" + "errors" "log/slog" "sync" "time" @@ -15,28 +16,40 @@ import ( ) const ( - defaultSubscriberBuf = 16 + // Per-connection outbound buffer. Must absorb peak event rate × + // browser stutter window: heartbeat (5s) + coinbase (~8/s peak) + + // ISS (10s) + occasional bursts of CVE/KEV/ransomware. 16 is too + // small — a single browser GC pause fills it and triggers slow- + // consumer close, which the user sees as "NO SIGNAL". + defaultSubscriberBuf = 256 defaultPingInterval = 30 * time.Second defaultPingTimeout = 10 * time.Second defaultWriteTimeout = 5 * time.Second + defaultMaxSubs = 5000 ) +// ErrAtCapacity signals the hub has reached MaxSubscribers and refuses +// to accept new connections. +var ErrAtCapacity = errors.New("ws hub at capacity") + type HubConfig struct { SubscriberBufferSize int PingInterval time.Duration PingTimeout time.Duration WriteTimeout time.Duration + MaxSubscribers int Logger *slog.Logger } type Hub struct { - mu sync.Mutex - subs map[*subscriber]struct{} - bufSize int - pingInterval time.Duration - pingTimeout time.Duration - writeTimeout time.Duration - logger *slog.Logger + mu sync.Mutex + subs map[*subscriber]struct{} + bufSize int + pingInterval time.Duration + pingTimeout time.Duration + writeTimeout time.Duration + maxSubscribers int + logger *slog.Logger } func NewHub(cfg HubConfig) *Hub { @@ -52,17 +65,21 @@ func NewHub(cfg HubConfig) *Hub { if cfg.WriteTimeout <= 0 { cfg.WriteTimeout = defaultWriteTimeout } + if cfg.MaxSubscribers <= 0 { + cfg.MaxSubscribers = defaultMaxSubs + } logger := cfg.Logger if logger == nil { logger = slog.Default() } return &Hub{ - subs: make(map[*subscriber]struct{}), - bufSize: cfg.SubscriberBufferSize, - pingInterval: cfg.PingInterval, - pingTimeout: cfg.PingTimeout, - writeTimeout: cfg.WriteTimeout, - logger: logger, + subs: make(map[*subscriber]struct{}), + bufSize: cfg.SubscriberBufferSize, + pingInterval: cfg.PingInterval, + pingTimeout: cfg.PingTimeout, + writeTimeout: cfg.WriteTimeout, + maxSubscribers: cfg.MaxSubscribers, + logger: logger, } } @@ -86,14 +103,38 @@ func (h *Hub) Broadcast(topic events.Topic, payload []byte) { } } -func (h *Hub) Serve(ctx context.Context, c *cdrws.Conn, topics []events.Topic) error { +func (h *Hub) Serve( + ctx context.Context, + c *cdrws.Conn, + topics []events.Topic, +) error { sub := newSubscriber(topics, h.bufSize, func() { _ = c.Close(cdrws.StatusPolicyViolation, "slow consumer") }) - h.add(sub) + if !h.add(sub) { + _ = c.Close(cdrws.StatusTryAgainLater, "server at capacity") + return ErrAtCapacity + } defer h.remove(sub) - connCtx := c.CloseRead(ctx) + // Drain client→server messages without closing on receive. The + // dashboard's WS protocol is currently server-push only (server + // broadcasts, client listens), but the frontend's setReady() handshake + // sends a `{"op":"init"}` line. The previous c.CloseRead(ctx) call + // killed the connection with StatusPolicyViolation on receiving any + // data message — turning every client init into a forced disconnect + // and reconnect-loop. This loop reads and discards instead, leaving + // ping/pong + close frames intact. + connCtx, cancelRead := context.WithCancel(ctx) + defer cancelRead() + go func() { + for { + if _, _, err := c.Read(connCtx); err != nil { + cancelRead() + return + } + } + }() pingT := time.NewTicker(h.pingInterval) defer pingT.Stop() @@ -120,10 +161,14 @@ func (h *Hub) Serve(ctx context.Context, c *cdrws.Conn, topics []events.Topic) e } } -func (h *Hub) add(sub *subscriber) { +func (h *Hub) add(sub *subscriber) bool { h.mu.Lock() defer h.mu.Unlock() + if len(h.subs) >= h.maxSubscribers { + return false + } h.subs[sub] = struct{}{} + return true } func (h *Hub) remove(sub *subscriber) { diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/ws/hub_test.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/ws/hub_test.go index b773824d..d2ed8274 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/ws/hub_test.go +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/ws/hub_test.go @@ -22,11 +22,17 @@ import ( func TestHub_SubscribeAndReceive(t *testing.T) { hub := ws.NewHub(ws.HubConfig{}) - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - c, err := cdrws.Accept(w, r, &cdrws.AcceptOptions{InsecureSkipVerify: true}) - require.NoError(t, err) - _ = hub.Serve(r.Context(), c, []events.Topic{events.TopicHeartbeat}) - })) + srv := httptest.NewServer( + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c, err := cdrws.Accept( + w, + r, + &cdrws.AcceptOptions{InsecureSkipVerify: true}, + ) + require.NoError(t, err) + _ = hub.Serve(r.Context(), c, []events.Topic{events.TopicHeartbeat}) + }), + ) defer srv.Close() ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) @@ -51,11 +57,17 @@ func TestHub_SubscribeAndReceive(t *testing.T) { func TestHub_TopicFiltering(t *testing.T) { hub := ws.NewHub(ws.HubConfig{}) - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - c, err := cdrws.Accept(w, r, &cdrws.AcceptOptions{InsecureSkipVerify: true}) - require.NoError(t, err) - _ = hub.Serve(r.Context(), c, []events.Topic{events.TopicHeartbeat}) - })) + srv := httptest.NewServer( + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c, err := cdrws.Accept( + w, + r, + &cdrws.AcceptOptions{InsecureSkipVerify: true}, + ) + require.NoError(t, err) + _ = hub.Serve(r.Context(), c, []events.Topic{events.TopicHeartbeat}) + }), + ) defer srv.Close() ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) @@ -82,14 +94,73 @@ func TestHub_TopicFiltering(t *testing.T) { require.NotContains(t, string(msg), "CVE-2026-0001") } +// TestHub_ClientMessageDoesNotKillConnection: regression for the bug +// where Hub.Serve used c.CloseRead(ctx), which silently closes the +// connection with StatusPolicyViolation on receiving any client→server +// message. The frontend's setReady() handshake sends a `{"op":"init"}` +// line, which would trigger this and put the dashboard into a 10-second +// flap loop. Hub now drains client messages instead. +func TestHub_ClientMessageDoesNotKillConnection(t *testing.T) { + hub := ws.NewHub(ws.HubConfig{}) + + srv := httptest.NewServer( + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c, err := cdrws.Accept( + w, + r, + &cdrws.AcceptOptions{InsecureSkipVerify: true}, + ) + require.NoError(t, err) + _ = hub.Serve(r.Context(), c, []events.Topic{events.TopicHeartbeat}) + }), + ) + defer srv.Close() + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + wsURL := "ws" + strings.TrimPrefix(srv.URL, "http") + conn, _, err := cdrws.Dial(ctx, wsURL, nil) + require.NoError(t, err) + defer func() { _ = conn.CloseNow() }() + + require.Eventually(t, func() bool { + return hub.SubscriberCount() == 1 + }, time.Second, 10*time.Millisecond) + + require.NoError( + t, + conn.Write(ctx, cdrws.MessageText, []byte(`{"op":"init"}`)), + ) + + // Server must keep delivering events — pre-fix this Broadcast would + // race with the Policy-Violation close from CloseRead and frequently + // fail to reach the client. + time.Sleep(100 * time.Millisecond) + hub.Broadcast(events.TopicHeartbeat, []byte(`{"ts":"after-init"}`)) + + readCtx, readCancel := context.WithTimeout(ctx, time.Second) + defer readCancel() + _, msg, err := conn.Read(readCtx) + require.NoError(t, err, "connection must survive client init message") + require.Contains(t, string(msg), "after-init") + require.Equal(t, 1, hub.SubscriberCount()) +} + func TestHub_SlowConsumerClosed(t *testing.T) { hub := ws.NewHub(ws.HubConfig{SubscriberBufferSize: 2}) - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - c, err := cdrws.Accept(w, r, &cdrws.AcceptOptions{InsecureSkipVerify: true}) - require.NoError(t, err) - _ = hub.Serve(r.Context(), c, []events.Topic{events.TopicHeartbeat}) - })) + srv := httptest.NewServer( + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c, err := cdrws.Accept( + w, + r, + &cdrws.AcceptOptions{InsecureSkipVerify: true}, + ) + require.NoError(t, err) + _ = hub.Serve(r.Context(), c, []events.Topic{events.TopicHeartbeat}) + }), + ) defer srv.Close() ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/ws/subscriber.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/ws/subscriber.go index 851b2a31..ed4a6388 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/ws/subscriber.go +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/ws/subscriber.go @@ -11,7 +11,11 @@ type subscriber struct { closeSlow func() } -func newSubscriber(topics []events.Topic, bufSize int, closeSlow func()) *subscriber { +func newSubscriber( + topics []events.Topic, + bufSize int, + closeSlow func(), +) *subscriber { t := make(map[events.Topic]struct{}, len(topics)) for _, top := range topics { t[top] = struct{}{} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/migrations/0005_minute_bar_volume_rename.sql b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/migrations/0005_minute_bar_volume_rename.sql new file mode 100644 index 00000000..a643d4c8 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/migrations/0005_minute_bar_volume_rename.sql @@ -0,0 +1,18 @@ +-- ©AngelaMos | 2026 +-- 0005_minute_bar_volume_rename.sql + +-- The Coinbase ticker channel publishes a rolling 24-hour volume, not per-trade +-- size, so the `volume` column on btc_eth_minute was always a snapshot of the +-- 24h figure at the close of that minute — never per-minute volume. Renaming +-- to `volume_24h_at_close` so the column name stops lying. Per-minute volume +-- requires the level2 / market_trades channel; that's a separate change. + +-- +goose Up +-- +goose StatementBegin +ALTER TABLE btc_eth_minute RENAME COLUMN volume TO volume_24h_at_close; +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +ALTER TABLE btc_eth_minute RENAME COLUMN volume_24h_at_close TO volume; +-- +goose StatementEnd diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/compose.yml b/PROJECTS/advanced/monitor-the-situation-dashboard/compose.yml index cde7224f..5c6db56a 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/compose.yml +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/compose.yml @@ -35,7 +35,7 @@ services: expose: - "8080" env_file: - - .env + - .env.prod environment: - APP_ENVIRONMENT=production - DATABASE_URL=postgres://${POSTGRES_USER:-monitor}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-monitor}?sslmode=disable diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/conf/nginx/dev.nginx b/PROJECTS/advanced/monitor-the-situation-dashboard/conf/nginx/dev.nginx index 9eaa0e54..776d701e 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/conf/nginx/dev.nginx +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/conf/nginx/dev.nginx @@ -19,7 +19,7 @@ server { proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection $connection_upgrade; - proxy_set_header Host $host; + proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/conf/nginx/prod.nginx b/PROJECTS/advanced/monitor-the-situation-dashboard/conf/nginx/prod.nginx index 17ead27a..5364b191 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/conf/nginx/prod.nginx +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/conf/nginx/prod.nginx @@ -26,7 +26,7 @@ server { proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection $connection_upgrade; - proxy_set_header Host $host; + proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/dev.compose.yml b/PROJECTS/advanced/monitor-the-situation-dashboard/dev.compose.yml index e4bce9a0..88cec2b0 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/dev.compose.yml +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/dev.compose.yml @@ -35,9 +35,13 @@ services: env_file: - .env environment: - - APP_ENVIRONMENT=development + - APP_ENVIRONMENT=${APP_ENVIRONMENT:-development} - DATABASE_URL=postgres://${POSTGRES_USER:-monitor}:${POSTGRES_PASSWORD:-monitor}@postgres:5432/${POSTGRES_DB:-monitor}?sslmode=disable - - REDIS_URL=redis://redis:6379/0 + - REDIS_URL=redis://:${REDIS_PASSWORD:-monitor}@redis:6379/0 + # nginx adds one XFF hop in dev. Without this the WS per-IP cap + # buckets every connection under nginx's container IP and runs out + # at 5 → "CONNECTING" stuck on the dashboard. + - TRUSTED_PROXY_HOPS=1 depends_on: postgres: condition: service_healthy @@ -95,13 +99,15 @@ services: container_name: ${APP_NAME:-monitor}-redis-dev ports: - "${REDIS_HOST_PORT:-6432}:6379" - command: redis-server --appendonly yes + command: redis-server --appendonly yes --requirepass ${REDIS_PASSWORD:-monitor} volumes: - redisdata-dev:/data networks: - backend + environment: + REDIS_PASSWORD: ${REDIS_PASSWORD:-monitor} healthcheck: - test: ["CMD", "redis-cli", "ping"] + test: ["CMD-SHELL", "redis-cli -a $$REDIS_PASSWORD ping || exit 1"] interval: 10s timeout: 5s retries: 5 diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/biome.json b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/biome.json index 7f29029a..b830269a 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/biome.json +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/biome.json @@ -6,7 +6,14 @@ "useIgnoreFile": true }, "files": { - "includes": ["**/*.ts", "**/*.tsx", "**/*.js", "**/*.jsx", "**/*.json"] + "includes": [ + "**/*.ts", + "**/*.tsx", + "**/*.js", + "**/*.jsx", + "**/*.json", + "!.pnpm-store" + ] }, "formatter": { "enabled": true, diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/index.html b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/index.html index 1c9c7a4d..8f5c2dc0 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/index.html +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/index.html @@ -22,13 +22,16 @@ /> - Monitor The Situation Dashboard + + + + I glow in the dark => { const response = await apiClient.get(API_ENDPOINTS.ADMIN.USERS.LIST, { - params: { page, size }, + params: { page, page_size: size }, }) const data: unknown = response.data @@ -147,7 +147,7 @@ interface AdminUpdateUserParams { const performAdminUpdateUser = async ( params: AdminUpdateUserParams ): Promise => { - const response = await apiClient.patch( + const response = await apiClient.put( API_ENDPOINTS.ADMIN.USERS.UPDATE(params.id), params.data ) diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/hooks/useAlerts.ts b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/hooks/useAlerts.ts new file mode 100644 index 00000000..7b1e3820 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/hooks/useAlerts.ts @@ -0,0 +1,111 @@ +// =================== +// © AngelaMos | 2026 +// useAlerts.ts +// =================== + +import { + type UseMutationResult, + type UseQueryResult, + useMutation, + useQuery, + useQueryClient, +} from '@tanstack/react-query' +import { toast } from 'sonner' +import { apiClient } from '@/core/api' + +export interface AlertRule { + id: string + name: string + topic: string + predicate: string + cooldown_sec: number + enabled: boolean + created_at: string + updated_at: string +} + +const ALERT_RULES_KEY = ['me', 'alerts'] as const +const ALERT_HISTORY_KEY = ['me', 'alerts', 'history'] as const + +const fetchRules = async (): Promise => { + const res = await apiClient.get('/v1/me/alerts/') + return Array.isArray(res.data) ? res.data : [] +} + +export const useAlertRules = (): UseQueryResult => + useQuery({ + queryKey: ALERT_RULES_KEY, + queryFn: fetchRules, + staleTime: 30_000, + }) + +interface UpdateRuleVars { + id: string + patch: Partial< + Pick + > +} + +const performUpdateRule = async ({ + id, + patch, +}: UpdateRuleVars): Promise => { + const res = await apiClient.patch(`/v1/me/alerts/${id}`, patch) + return res.data +} + +export const useUpdateAlertRule = (): UseMutationResult< + AlertRule, + Error, + UpdateRuleVars +> => { + const qc = useQueryClient() + return useMutation({ + mutationFn: performUpdateRule, + onSuccess: () => { + void qc.invalidateQueries({ queryKey: ALERT_RULES_KEY }) + }, + onError: () => { + toast.error('Failed to update alert rule') + }, + }) +} + +const performDeleteRule = async (id: string): Promise => { + await apiClient.delete(`/v1/me/alerts/${id}`) +} + +export const useDeleteAlertRule = (): UseMutationResult => { + const qc = useQueryClient() + return useMutation({ + mutationFn: performDeleteRule, + onSuccess: () => { + void qc.invalidateQueries({ queryKey: ALERT_RULES_KEY }) + toast.success('Rule removed') + }, + onError: () => { + toast.error('Failed to remove rule') + }, + }) +} + +export interface AlertHistoryEntry { + id: number + rule_id: string + fired_at: string + delivered_to: string[] | null +} + +const fetchHistory = async (): Promise => { + const res = await apiClient.get( + '/v1/me/alerts/history?limit=20' + ) + return Array.isArray(res.data) ? res.data : [] +} + +export const useAlertHistory = (): UseQueryResult => + useQuery({ + queryKey: ALERT_HISTORY_KEY, + queryFn: fetchHistory, + staleTime: 15_000, + }) diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/hooks/useAuth.ts b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/hooks/useAuth.ts index a0aea655..66f79b17 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/hooks/useAuth.ts +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/hooks/useAuth.ts @@ -15,12 +15,9 @@ import { AUTH_ERROR_MESSAGES, AUTH_SUCCESS_MESSAGES, AuthResponseError, - isValidLogoutAllResponse, - isValidTokenResponse, isValidTokenWithUserResponse, isValidUserResponse, type LoginRequest, - type LogoutAllResponse, type PasswordChangeRequest, type TokenWithUserResponse, type UserResponse, @@ -62,17 +59,10 @@ export const useCurrentUser = (): UseQueryResult => { const performLogin = async ( credentials: LoginRequest ): Promise => { - const formData = new URLSearchParams() - formData.append('username', credentials.username) - formData.append('password', credentials.password) - - const response = await apiClient.post( - API_ENDPOINTS.AUTH.LOGIN, - formData, - { - headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, - } - ) + const response = await apiClient.post(API_ENDPOINTS.AUTH.LOGIN, { + email: credentials.email, + password: credentials.password, + }) const data: unknown = response.data @@ -97,14 +87,11 @@ export const useLogin = (): UseMutationResult< return useMutation({ mutationFn: performLogin, onSuccess: (data: TokenWithUserResponse): void => { - login(data.user, data.access_token) + login(data.user, data.tokens.access_token) queryClient.setQueryData(authQueries.me(), data.user) - const welcomeMessage = AUTH_SUCCESS_MESSAGES.WELCOME_BACK( - data.user.full_name - ) - toast.success(welcomeMessage) + toast.success(AUTH_SUCCESS_MESSAGES.WELCOME_BACK(data.user.name)) }, onError: (error: Error): void => { const message = @@ -115,7 +102,8 @@ export const useLogin = (): UseMutationResult< } const performLogout = async (): Promise => { - await apiClient.post(API_ENDPOINTS.AUTH.LOGOUT) + // Refresh-token cookie is sent automatically by axios via withCredentials. + await apiClient.post(API_ENDPOINTS.AUTH.LOGOUT, {}) } export const useLogout = (): UseMutationResult => { @@ -141,36 +129,22 @@ export const useLogout = (): UseMutationResult => { }) } -const performLogoutAll = async (): Promise => { - const response = await apiClient.post(API_ENDPOINTS.AUTH.LOGOUT_ALL) - const data: unknown = response.data - - if (!isValidLogoutAllResponse(data)) { - throw new AuthResponseError( - AUTH_ERROR_MESSAGES.INVALID_LOGOUT_RESPONSE, - API_ENDPOINTS.AUTH.LOGOUT_ALL - ) - } - - return data +const performLogoutAll = async (): Promise => { + await apiClient.post(API_ENDPOINTS.AUTH.LOGOUT_ALL) } -export const useLogoutAll = (): UseMutationResult< - LogoutAllResponse, - Error, - void -> => { +export const useLogoutAll = (): UseMutationResult => { const queryClient = useQueryClient() const logout = useAuthStore((s) => s.logout) return useMutation({ mutationFn: performLogoutAll, - onSuccess: (data: LogoutAllResponse): void => { + onSuccess: (): void => { logout() queryClient.removeQueries({ queryKey: authQueries.all() }) - toast.success(`Logged out from ${data.revoked_sessions} session(s)`) + toast.success('Logged out from all sessions') window.location.href = ROUTES.LOGIN }, @@ -212,29 +186,25 @@ export const useChangePassword = (): UseMutationResult< export const useRefreshAuth = (): (() => Promise) => { const queryClient = useQueryClient() - const { setAccessToken, login, logout } = useAuthStore() + const { login, logout } = useAuthStore() return async (): Promise => { try { - const response = await apiClient.post(API_ENDPOINTS.AUTH.REFRESH) + const response = await apiClient.post( + API_ENDPOINTS.AUTH.REFRESH, + {} + ) const data: unknown = response.data - if (!isValidTokenResponse(data)) { + if (!isValidTokenWithUserResponse(data)) { throw new AuthResponseError( AUTH_ERROR_MESSAGES.INVALID_TOKEN_RESPONSE, API_ENDPOINTS.AUTH.REFRESH ) } - setAccessToken(data.access_token) - - const userResponse = await apiClient.get(API_ENDPOINTS.AUTH.ME) - const userData: unknown = userResponse.data - - if (isValidUserResponse(userData)) { - login(userData, data.access_token) - queryClient.setQueryData(authQueries.me(), userData) - } + login(data.user, data.tokens.access_token) + queryClient.setQueryData(authQueries.me(), data.user) } catch { logout() queryClient.removeQueries({ queryKey: authQueries.all() }) diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/hooks/useBgpHijackData.ts b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/hooks/useBgpHijackData.ts index da7a8e27..64ded5b6 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/hooks/useBgpHijackData.ts +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/hooks/useBgpHijackData.ts @@ -1,25 +1,42 @@ // ©AngelaMos | 2026 // useBgpHijackData.ts +import { useQuery } from '@tanstack/react-query' import { useEffect } from 'react' -import { isValidBgpHijack, type BgpHijack } from '@/api/types' -import { useSnapshot } from '@/api/snapshot' +import { type BgpHijack, isValidBgpHijack } from '@/api/types' +import { API_ENDPOINTS, QUERY_KEYS } from '@/config' +import { apiClient } from '@/core/api' import { useBgpHijackStore } from '@/stores/bgpHijack' interface BgpHijackData { items: BgpHijack[] } +const INTEL_LIMIT = 100 +const STALE_MS = 60_000 + +const fetchRecentHijacks = async (): Promise => { + const res = await apiClient.get(API_ENDPOINTS.INTEL.HIJACKS, { + params: { limit: INTEL_LIMIT }, + }) + if (!Array.isArray(res.data)) return [] + return res.data.filter(isValidBgpHijack) +} + export function useBgpHijackData(): BgpHijackData { - const { data } = useSnapshot() const items = useBgpHijackStore((s) => s.items) const push = useBgpHijackStore((s) => s.push) - const raw = data?.bgp_hijack - const seed = isValidBgpHijack(raw) ? raw : undefined + const { data } = useQuery({ + queryKey: QUERY_KEYS.INTEL.HIJACKS(), + queryFn: fetchRecentHijacks, + staleTime: STALE_MS, + }) + useEffect(() => { - if (seed) push(seed) - }, [seed, push]) + if (!data) return + for (const ev of data) push(ev) + }, [data, push]) return { items } } diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/hooks/useCoinbasePrices.ts b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/hooks/useCoinbasePrices.ts new file mode 100644 index 00000000..9062a2af --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/hooks/useCoinbasePrices.ts @@ -0,0 +1,36 @@ +// ©AngelaMos | 2026 +// useCoinbasePrices.ts + +import { useEffect } from 'react' +import { useSnapshot } from '@/api/snapshot' +import { isValidCoinbasePriceMap } from '@/api/types' +import { type MinuteBar, type PriceTick, usePrices } from '@/stores/prices' + +const EMPTY_BARS: MinuteBar[] = [] + +interface CoinbasePricesData { + latest: PriceTick | undefined + history: MinuteBar[] +} + +export function useCoinbasePrices(symbol: string): CoinbasePricesData { + const { data } = useSnapshot() + const latest = usePrices((s) => s.latest[symbol]) + const historyRaw = usePrices((s) => s.history[symbol]) + const history = historyRaw ?? EMPTY_BARS + const pushTick = usePrices((s) => s.pushTick) + + const priceMap = data?.coinbase_price + const seed = isValidCoinbasePriceMap(priceMap) ? priceMap[symbol] : undefined + useEffect(() => { + if (!seed) return + pushTick({ + symbol: seed.symbol, + ts: new Date(seed.ts).getTime(), + price: seed.price, + volume24h: seed.volume_24h, + }) + }, [seed, pushTick]) + + return { latest, history } +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/hooks/useCveData.ts b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/hooks/useCveData.ts new file mode 100644 index 00000000..12c20faa --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/hooks/useCveData.ts @@ -0,0 +1,45 @@ +// ©AngelaMos | 2026 +// useCveData.ts + +import { useQuery } from '@tanstack/react-query' +import { useEffect } from 'react' +import { type CveEvent, isValidCveEvent } from '@/api/types' +import { API_ENDPOINTS, QUERY_KEYS } from '@/config' +import { apiClient } from '@/core/api' +import { useCveStore } from '@/stores/cve' + +interface CveData { + items: CveEvent[] +} + +const INTEL_LIMIT = 200 +const STALE_MS = 5 * 60_000 + +const fetchRecentCVEs = async (): Promise => { + const res = await apiClient.get(API_ENDPOINTS.INTEL.CVES, { + params: { limit: INTEL_LIMIT }, + }) + if (!Array.isArray(res.data)) return [] + return res.data.filter(isValidCveEvent) +} + +export function useCveData(): CveData { + const items = useCveStore((s) => s.items) + const push = useCveStore((s) => s.push) + + // /intel/cves backfills the panel on cold load. WS deltas (cve_new) layer + // on top via the dashboard lifecycle. Snapshot's single-event slot is no + // longer the source of truth here. + const { data } = useQuery({ + queryKey: QUERY_KEYS.INTEL.CVES(), + queryFn: fetchRecentCVEs, + staleTime: STALE_MS, + }) + + useEffect(() => { + if (!data) return + for (const ev of data) push(ev) + }, [data, push]) + + return { items } +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/hooks/useDShieldData.ts b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/hooks/useDShieldData.ts new file mode 100644 index 00000000..6952b469 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/hooks/useDShieldData.ts @@ -0,0 +1,11 @@ +// ©AngelaMos | 2026 +// useDShieldData.ts + +import { useSnapshot } from '@/api/snapshot' +import { type DShieldData, isValidDShieldData } from '@/api/types' + +export function useDShieldData(): DShieldData { + const { data } = useSnapshot() + const raw = data?.scan_firehose + return isValidDShieldData(raw) ? raw : {} +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/hooks/useEarthquakeData.ts b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/hooks/useEarthquakeData.ts index 093c2bf7..0dcf90f3 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/hooks/useEarthquakeData.ts +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/hooks/useEarthquakeData.ts @@ -1,25 +1,42 @@ // ©AngelaMos | 2026 // useEarthquakeData.ts +import { useQuery } from '@tanstack/react-query' import { useEffect } from 'react' -import { isValidEarthquakePayload, type EarthquakePayload } from '@/api/types' -import { useSnapshot } from '@/api/snapshot' +import { type EarthquakePayload, isValidEarthquakePayload } from '@/api/types' +import { API_ENDPOINTS, QUERY_KEYS } from '@/config' +import { apiClient } from '@/core/api' import { useEarthquakeStore } from '@/stores/earthquake' interface EarthquakeData { items: EarthquakePayload[] } +const INTEL_LIMIT = 100 +const STALE_MS = 60_000 + +const fetchRecentQuakes = async (): Promise => { + const res = await apiClient.get(API_ENDPOINTS.INTEL.QUAKES, { + params: { limit: INTEL_LIMIT }, + }) + if (!Array.isArray(res.data)) return [] + return res.data.filter(isValidEarthquakePayload) +} + export function useEarthquakeData(): EarthquakeData { - const { data } = useSnapshot() const items = useEarthquakeStore((s) => s.items) const push = useEarthquakeStore((s) => s.push) - const raw = data?.earthquake - const seed = isValidEarthquakePayload(raw) ? raw : undefined + const { data } = useQuery({ + queryKey: QUERY_KEYS.INTEL.QUAKES(), + queryFn: fetchRecentQuakes, + staleTime: STALE_MS, + }) + useEffect(() => { - if (seed) push(seed) - }, [seed, push]) + if (!data) return + for (const ev of data) push(ev) + }, [data, push]) return { items } } diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/hooks/useIssPosition.ts b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/hooks/useIssPosition.ts new file mode 100644 index 00000000..73bdefd1 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/hooks/useIssPosition.ts @@ -0,0 +1,11 @@ +// ©AngelaMos | 2026 +// useIssPosition.ts + +import { useSnapshot } from '@/api/snapshot' +import { type IssPosition, isValidIssPosition } from '@/api/types' + +export function useIssPosition(): IssPosition | undefined { + const { data } = useSnapshot() + const raw = data?.iss_position + return isValidIssPosition(raw) ? raw : undefined +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/hooks/useKevData.ts b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/hooks/useKevData.ts new file mode 100644 index 00000000..58a9443c --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/hooks/useKevData.ts @@ -0,0 +1,42 @@ +// ©AngelaMos | 2026 +// useKevData.ts + +import { useQuery } from '@tanstack/react-query' +import { useEffect } from 'react' +import { isValidKevEntry, type KevEntry } from '@/api/types' +import { API_ENDPOINTS, QUERY_KEYS } from '@/config' +import { apiClient } from '@/core/api' +import { useKevStore } from '@/stores/kev' + +interface KevData { + items: KevEntry[] +} + +const INTEL_LIMIT = 100 +const STALE_MS = 5 * 60_000 + +const fetchRecentKEV = async (): Promise => { + const res = await apiClient.get(API_ENDPOINTS.INTEL.KEV, { + params: { limit: INTEL_LIMIT }, + }) + if (!Array.isArray(res.data)) return [] + return res.data.filter(isValidKevEntry) +} + +export function useKevData(): KevData { + const items = useKevStore((s) => s.items) + const push = useKevStore((s) => s.push) + + const { data } = useQuery({ + queryKey: QUERY_KEYS.INTEL.KEV(), + queryFn: fetchRecentKEV, + staleTime: STALE_MS, + }) + + useEffect(() => { + if (!data) return + for (const ev of data) push(ev) + }, [data, push]) + + return { items } +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/hooks/useNotifications.ts b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/hooks/useNotifications.ts new file mode 100644 index 00000000..f0e75ab1 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/hooks/useNotifications.ts @@ -0,0 +1,222 @@ +// =================== +// © AngelaMos | 2026 +// useNotifications.ts +// =================== + +import { + type UseMutationResult, + type UseQueryResult, + useMutation, + useQuery, + useQueryClient, +} from '@tanstack/react-query' +import { toast } from 'sonner' +import { + type ChannelListResponse, + type ChannelResponse, + type CreateChannelRequest, + isValidChannelListResponse, + isValidTelegramStatus, + NOTIFICATION_ERROR_MESSAGES, + NOTIFICATION_SUCCESS_MESSAGES, + type RegisterTelegramRequest, + type RegisterTelegramResponse, + type TelegramStatusResponse, +} from '@/api/types' +import { API_ENDPOINTS, QUERY_KEYS } from '@/config' +import { apiClient } from '@/core/api' + +export const notificationQueries = { + channels: () => QUERY_KEYS.NOTIFICATIONS.CHANNELS(), + telegramStatus: () => QUERY_KEYS.NOTIFICATIONS.TELEGRAM_STATUS(), +} as const + +const fetchChannels = async (): Promise => { + const response = await apiClient.get( + API_ENDPOINTS.NOTIFICATIONS.CHANNELS + ) + const data: unknown = response.data + + if (!isValidChannelListResponse(data)) { + throw new Error(NOTIFICATION_ERROR_MESSAGES.INVALID_RESPONSE) + } + + return data +} + +export const useNotificationChannels = (): UseQueryResult< + ChannelListResponse, + Error +> => { + return useQuery({ + queryKey: notificationQueries.channels(), + queryFn: fetchChannels, + staleTime: 1000 * 30, + }) +} + +const fetchTelegramStatus = async (): Promise => { + const response = await apiClient.get( + API_ENDPOINTS.NOTIFICATIONS.TELEGRAM_STATUS + ) + const data: unknown = response.data + + if (!isValidTelegramStatus(data)) { + throw new Error(NOTIFICATION_ERROR_MESSAGES.INVALID_RESPONSE) + } + + return data +} + +export const useTelegramStatus = (): UseQueryResult< + TelegramStatusResponse, + Error +> => { + return useQuery({ + queryKey: notificationQueries.telegramStatus(), + queryFn: fetchTelegramStatus, + staleTime: 1000 * 15, + }) +} + +const performCreateChannel = async ( + data: CreateChannelRequest +): Promise => { + const response = await apiClient.post( + API_ENDPOINTS.NOTIFICATIONS.CHANNELS, + data + ) + return response.data +} + +export const useCreateChannel = (): UseMutationResult< + ChannelResponse, + Error, + CreateChannelRequest +> => { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: performCreateChannel, + onSuccess: (): void => { + void queryClient.invalidateQueries({ + queryKey: notificationQueries.channels(), + }) + toast.success(NOTIFICATION_SUCCESS_MESSAGES.CHANNEL_ADDED) + }, + onError: (): void => { + toast.error(NOTIFICATION_ERROR_MESSAGES.CREATE_FAILED) + }, + }) +} + +const performDeleteChannel = async (id: string): Promise => { + await apiClient.delete(API_ENDPOINTS.NOTIFICATIONS.CHANNEL_BY_ID(id)) +} + +export const useDeleteChannel = (): UseMutationResult => { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: performDeleteChannel, + onSuccess: (): void => { + void queryClient.invalidateQueries({ + queryKey: notificationQueries.channels(), + }) + toast.success(NOTIFICATION_SUCCESS_MESSAGES.CHANNEL_DELETED) + }, + onError: (): void => { + toast.error(NOTIFICATION_ERROR_MESSAGES.DELETE_FAILED) + }, + }) +} + +const performTestChannel = async (id: string): Promise => { + await apiClient.post(API_ENDPOINTS.NOTIFICATIONS.CHANNEL_TEST(id)) +} + +export const useTestChannel = (): UseMutationResult => { + return useMutation({ + mutationFn: performTestChannel, + onSuccess: (): void => { + toast.success(NOTIFICATION_SUCCESS_MESSAGES.TEST_SENT) + }, + onError: (error: Error): void => { + toast.error(`${NOTIFICATION_ERROR_MESSAGES.TEST_FAILED}: ${error.message}`) + }, + }) +} + +const performRegisterTelegram = async ( + data: RegisterTelegramRequest +): Promise => { + const response = await apiClient.post( + API_ENDPOINTS.NOTIFICATIONS.TELEGRAM, + data + ) + return response.data +} + +export const useRegisterTelegram = (): UseMutationResult< + RegisterTelegramResponse, + Error, + RegisterTelegramRequest +> => { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: performRegisterTelegram, + onSuccess: (): void => { + void queryClient.invalidateQueries({ + queryKey: notificationQueries.channels(), + }) + void queryClient.invalidateQueries({ + queryKey: notificationQueries.telegramStatus(), + }) + toast.success(NOTIFICATION_SUCCESS_MESSAGES.TELEGRAM_REGISTERED) + }, + onError: (): void => { + toast.error(NOTIFICATION_ERROR_MESSAGES.REGISTER_TELEGRAM_FAILED) + }, + }) +} + +const performUnlinkTelegram = async (): Promise => { + await apiClient.delete(API_ENDPOINTS.NOTIFICATIONS.TELEGRAM) +} + +export const useUnlinkTelegram = (): UseMutationResult => { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: performUnlinkTelegram, + onSuccess: (): void => { + void queryClient.invalidateQueries({ + queryKey: notificationQueries.channels(), + }) + void queryClient.invalidateQueries({ + queryKey: notificationQueries.telegramStatus(), + }) + toast.success(NOTIFICATION_SUCCESS_MESSAGES.TELEGRAM_UNLINKED) + }, + onError: (): void => { + toast.error(NOTIFICATION_ERROR_MESSAGES.UNLINK_TELEGRAM_FAILED) + }, + }) +} + +const performTestTelegram = async (): Promise => { + await apiClient.post(API_ENDPOINTS.NOTIFICATIONS.TELEGRAM_TEST) +} + +export const useTestTelegram = (): UseMutationResult => { + return useMutation({ + mutationFn: performTestTelegram, + onSuccess: (): void => { + toast.success(NOTIFICATION_SUCCESS_MESSAGES.TEST_SENT) + }, + onError: (error: Error): void => { + toast.error(`${NOTIFICATION_ERROR_MESSAGES.TEST_FAILED}: ${error.message}`) + }, + }) +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/hooks/useOutageData.ts b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/hooks/useOutageData.ts index 0717bafc..56a448fa 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/hooks/useOutageData.ts +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/hooks/useOutageData.ts @@ -1,25 +1,42 @@ // ©AngelaMos | 2026 // useOutageData.ts +import { useQuery } from '@tanstack/react-query' import { useEffect } from 'react' -import { isValidInternetOutage, type InternetOutage } from '@/api/types' -import { useSnapshot } from '@/api/snapshot' +import { type InternetOutage, isValidInternetOutage } from '@/api/types' +import { API_ENDPOINTS, QUERY_KEYS } from '@/config' +import { apiClient } from '@/core/api' import { useOutageStore } from '@/stores/outage' interface OutageData { items: InternetOutage[] } +const INTEL_LIMIT = 100 +const STALE_MS = 60_000 + +const fetchRecentOutages = async (): Promise => { + const res = await apiClient.get(API_ENDPOINTS.INTEL.OUTAGES, { + params: { limit: INTEL_LIMIT }, + }) + if (!Array.isArray(res.data)) return [] + return res.data.filter(isValidInternetOutage) +} + export function useOutageData(): OutageData { - const { data } = useSnapshot() const items = useOutageStore((s) => s.items) const push = useOutageStore((s) => s.push) - const raw = data?.internet_outage - const seed = isValidInternetOutage(raw) ? raw : undefined + const { data } = useQuery({ + queryKey: QUERY_KEYS.INTEL.OUTAGES(), + queryFn: fetchRecentOutages, + staleTime: STALE_MS, + }) + useEffect(() => { - if (seed) push(seed) - }, [seed, push]) + if (!data) return + for (const ev of data) push(ev) + }, [data, push]) return { items } } diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/hooks/useRansomwareData.ts b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/hooks/useRansomwareData.ts new file mode 100644 index 00000000..2136aa3b --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/hooks/useRansomwareData.ts @@ -0,0 +1,42 @@ +// ©AngelaMos | 2026 +// useRansomwareData.ts + +import { useQuery } from '@tanstack/react-query' +import { useEffect } from 'react' +import { isValidRansomwareVictim, type RansomwareVictim } from '@/api/types' +import { API_ENDPOINTS, QUERY_KEYS } from '@/config' +import { apiClient } from '@/core/api' +import { useRansomwareStore } from '@/stores/ransomware' + +interface RansomwareData { + items: RansomwareVictim[] +} + +const INTEL_LIMIT = 100 +const STALE_MS = 60_000 + +const fetchRecentRansomware = async (): Promise => { + const res = await apiClient.get(API_ENDPOINTS.INTEL.RANSOMWARE, { + params: { limit: INTEL_LIMIT }, + }) + if (!Array.isArray(res.data)) return [] + return res.data.filter(isValidRansomwareVictim) +} + +export function useRansomwareData(): RansomwareData { + const items = useRansomwareStore((s) => s.items) + const push = useRansomwareStore((s) => s.push) + + const { data } = useQuery({ + queryKey: QUERY_KEYS.INTEL.RANSOMWARE(), + queryFn: fetchRecentRansomware, + staleTime: STALE_MS, + }) + + useEffect(() => { + if (!data) return + for (const ev of data) push(ev) + }, [data, push]) + + return { items } +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/hooks/useSpaceWeather.ts b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/hooks/useSpaceWeather.ts new file mode 100644 index 00000000..9b669195 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/hooks/useSpaceWeather.ts @@ -0,0 +1,11 @@ +// ©AngelaMos | 2026 +// useSpaceWeather.ts + +import { useSnapshot } from '@/api/snapshot' +import { isValidSpaceWeatherData, type SpaceWeatherData } from '@/api/types' + +export function useSpaceWeather(): SpaceWeatherData { + const { data } = useSnapshot() + const raw = data?.space_weather + return isValidSpaceWeatherData(raw) ? raw : {} +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/hooks/useUsers.ts b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/hooks/useUsers.ts index ba3a2264..36e35be9 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/hooks/useUsers.ts +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/hooks/useUsers.ts @@ -5,9 +5,7 @@ import { type UseMutationResult, - type UseQueryResult, useMutation, - useQuery, useQueryClient, } from '@tanstack/react-query' import { toast } from 'sonner' @@ -21,60 +19,21 @@ import { type UserUpdateRequest, } from '@/api/types' import { API_ENDPOINTS, QUERY_KEYS } from '@/config' -import { apiClient, QUERY_STRATEGIES } from '@/core/api' +import { apiClient } from '@/core/api' import { useAuthStore } from '@/core/lib' import { authQueries } from './useAuth' export const userQueries = { all: () => QUERY_KEYS.USERS.ALL, - byId: (id: string) => QUERY_KEYS.USERS.BY_ID(id), me: () => QUERY_KEYS.USERS.ME(), } as const -const fetchUserById = async (id: string): Promise => { - const response = await apiClient.get(API_ENDPOINTS.USERS.BY_ID(id)) - const data: unknown = response.data - - if (!isValidUserResponse(data)) { - throw new UserResponseError( - USER_ERROR_MESSAGES.INVALID_USER_RESPONSE, - API_ENDPOINTS.USERS.BY_ID(id) - ) - } - - return data -} - -export const useUser = (id: string): UseQueryResult => { - return useQuery({ - queryKey: userQueries.byId(id), - queryFn: () => fetchUserById(id), - enabled: id.length > 0, - ...QUERY_STRATEGIES.standard, - }) -} - -const performRegister = async ( - data: UserCreateRequest -): Promise => { - const response = await apiClient.post( - API_ENDPOINTS.USERS.REGISTER, - data - ) - const responseData: unknown = response.data - - if (!isValidUserResponse(responseData)) { - throw new UserResponseError( - USER_ERROR_MESSAGES.INVALID_USER_RESPONSE, - API_ENDPOINTS.USERS.REGISTER - ) - } - - return responseData +const performRegister = async (data: UserCreateRequest): Promise => { + await apiClient.post(API_ENDPOINTS.AUTH.REGISTER, data) } export const useRegister = (): UseMutationResult< - UserResponse, + void, Error, UserCreateRequest > => { @@ -96,7 +55,7 @@ export const useRegister = (): UseMutationResult< const performUpdateProfile = async ( data: UserUpdateRequest ): Promise => { - const response = await apiClient.patch(API_ENDPOINTS.USERS.ME, data) + const response = await apiClient.put(API_ENDPOINTS.USERS.ME, data) const responseData: unknown = response.data if (!isValidUserResponse(responseData)) { @@ -136,3 +95,48 @@ export const useUpdateProfile = (): UseMutationResult< }, }) } + +interface UpdateEmailVars { + current_password: string + new_email: string +} + +const performUpdateEmail = async ( + data: UpdateEmailVars +): Promise => { + const response = await apiClient.put(API_ENDPOINTS.USERS.EMAIL, data) + const responseData: unknown = response.data + if (!isValidUserResponse(responseData)) { + throw new UserResponseError( + USER_ERROR_MESSAGES.INVALID_USER_RESPONSE, + API_ENDPOINTS.USERS.EMAIL + ) + } + return responseData +} + +export const useUpdateEmail = (): UseMutationResult< + UserResponse, + Error, + UpdateEmailVars +> => { + const queryClient = useQueryClient() + const updateUser = useAuthStore((s) => s.updateUser) + + return useMutation({ + mutationFn: performUpdateEmail, + onSuccess: (data: UserResponse): void => { + updateUser(data) + queryClient.setQueryData(authQueries.me(), data) + queryClient.setQueryData(userQueries.me(), data) + toast.success('Email updated') + }, + onError: (error: Error): void => { + const message = + error instanceof UserResponseError + ? error.message + : 'Failed to update email' + toast.error(message) + }, + }) +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/types/auth.types.ts b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/types/auth.types.ts index bf1ff764..a4265fd3 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/types/auth.types.ts +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/types/auth.types.ts @@ -16,26 +16,29 @@ export type UserRole = (typeof UserRole)[keyof typeof UserRole] export const userResponseSchema = z.object({ id: z.string().uuid(), - created_at: z.string().datetime(), - updated_at: z.string().datetime().nullable(), email: z.string().email(), - full_name: z.string().nullable(), - is_active: z.boolean(), - is_verified: z.boolean(), + name: z.string(), role: z.nativeEnum(UserRole), + tier: z.string(), + created_at: z.string().datetime(), + updated_at: z.string().datetime().optional(), }) export const tokenResponseSchema = z.object({ access_token: z.string(), + refresh_token: z.string(), token_type: z.string(), + expires_in: z.number(), + expires_at: z.string().datetime(), }) -export const tokenWithUserResponseSchema = tokenResponseSchema.extend({ +export const tokenWithUserResponseSchema = z.object({ user: userResponseSchema, + tokens: tokenResponseSchema, }) export const loginRequestSchema = z.object({ - username: z.string().email(), + email: z.string().email(), password: z .string() .min(PASSWORD_CONSTRAINTS.MIN_LENGTH) @@ -74,17 +77,13 @@ export type LogoutAllResponse = z.infer export const isValidUserResponse = (data: unknown): data is UserResponse => { if (data === null || data === undefined) return false if (typeof data !== 'object') return false - - const result = userResponseSchema.safeParse(data) - return result.success + return userResponseSchema.safeParse(data).success } export const isValidTokenResponse = (data: unknown): data is TokenResponse => { if (data === null || data === undefined) return false if (typeof data !== 'object') return false - - const result = tokenResponseSchema.safeParse(data) - return result.success + return tokenResponseSchema.safeParse(data).success } export const isValidTokenWithUserResponse = ( @@ -92,9 +91,7 @@ export const isValidTokenWithUserResponse = ( ): data is TokenWithUserResponse => { if (data === null || data === undefined) return false if (typeof data !== 'object') return false - - const result = tokenWithUserResponseSchema.safeParse(data) - return result.success + return tokenWithUserResponseSchema.safeParse(data).success } export const isValidLogoutAllResponse = ( @@ -102,9 +99,7 @@ export const isValidLogoutAllResponse = ( ): data is LogoutAllResponse => { if (data === null || data === undefined) return false if (typeof data !== 'object') return false - - const result = logoutAllResponseSchema.safeParse(data) - return result.success + return logoutAllResponseSchema.safeParse(data).success } export class AuthResponseError extends Error { diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/types/coinbase.types.ts b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/types/coinbase.types.ts new file mode 100644 index 00000000..48d1fcdb --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/types/coinbase.types.ts @@ -0,0 +1,32 @@ +// ©AngelaMos | 2026 +// coinbase.types.ts + +import { z } from 'zod' + +export const coinbaseTickSchema = z.object({ + symbol: z.string(), + ts: z.string(), + price: z.string(), + volume_24h: z.string().optional(), +}) + +export type CoinbaseTick = z.infer + +export const coinbasePriceMapSchema = z.record(z.string(), coinbaseTickSchema) + +export type CoinbasePriceMap = z.infer + +export const isValidCoinbaseTick = (data: unknown): data is CoinbaseTick => { + if (data === null || data === undefined || typeof data !== 'object') + return false + return coinbaseTickSchema.safeParse(data).success +} + +export const isValidCoinbasePriceMap = ( + data: unknown +): data is CoinbasePriceMap => { + if (data === null || data === undefined || typeof data !== 'object') + return false + if (Array.isArray(data)) return false + return coinbasePriceMapSchema.safeParse(data).success +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/types/cve.types.ts b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/types/cve.types.ts new file mode 100644 index 00000000..0df53d31 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/types/cve.types.ts @@ -0,0 +1,43 @@ +// ©AngelaMos | 2026 +// cve.types.ts + +import { z } from 'zod' + +export const cveEventSchema = z.object({ + CveID: z.string(), + Published: z.string(), + LastModified: z.string(), + Severity: z.string(), + CVSS: z.number().nullable(), + EPSSScore: z.number().nullable(), + EPSSPercentile: z.number().nullable(), + InKEV: z.boolean().optional(), +}) + +export type CveEvent = z.infer + +export const kevEntrySchema = z.object({ + cveID: z.string(), + vendorProject: z.string(), + product: z.string(), + vulnerabilityName: z.string(), + dateAdded: z.string(), + dueDate: z.string().optional(), + knownRansomwareCampaignUse: z.string().optional(), + shortDescription: z.string().optional(), + requiredAction: z.string().optional(), +}) + +export type KevEntry = z.infer + +export const isValidCveEvent = (data: unknown): data is CveEvent => { + if (data === null || data === undefined || typeof data !== 'object') + return false + return cveEventSchema.safeParse(data).success +} + +export const isValidKevEntry = (data: unknown): data is KevEntry => { + if (data === null || data === undefined || typeof data !== 'object') + return false + return kevEntrySchema.safeParse(data).success +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/types/geo.types.ts b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/types/geo.types.ts index b20380e6..86982db4 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/types/geo.types.ts +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/types/geo.types.ts @@ -29,9 +29,7 @@ export type EarthquakeProperties = z.infer export const earthquakePayloadSchema = z.object({ id: z.string(), - geometry: z - .object({ coordinates: z.array(z.number()).optional() }) - .optional(), + geometry: z.object({ coordinates: z.array(z.number()).optional() }).optional(), properties: earthquakePropertiesSchema.optional(), }) @@ -50,18 +48,21 @@ export const internetOutageSchema = z.object({ export type InternetOutage = z.infer export const isValidIssPosition = (data: unknown): data is IssPosition => { - if (data === null || data === undefined || typeof data !== 'object') return false + if (data === null || data === undefined || typeof data !== 'object') + return false return issPositionSchema.safeParse(data).success } export const isValidEarthquakePayload = ( data: unknown ): data is EarthquakePayload => { - if (data === null || data === undefined || typeof data !== 'object') return false + if (data === null || data === undefined || typeof data !== 'object') + return false return earthquakePayloadSchema.safeParse(data).success } export const isValidInternetOutage = (data: unknown): data is InternetOutage => { - if (data === null || data === undefined || typeof data !== 'object') return false + if (data === null || data === undefined || typeof data !== 'object') + return false return internetOutageSchema.safeParse(data).success } diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/types/index.ts b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/types/index.ts index 7f26f3c6..a0522bae 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/types/index.ts +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/types/index.ts @@ -4,4 +4,10 @@ // =================== export * from './auth.types' +export * from './coinbase.types' +export * from './cve.types' +export * from './geo.types' +export * from './intel.types' +export * from './notification.types' +export * from './threat.types' export * from './user.types' diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/types/intel.types.ts b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/types/intel.types.ts new file mode 100644 index 00000000..d44e8d61 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/types/intel.types.ts @@ -0,0 +1,53 @@ +// ©AngelaMos | 2026 +// intel.types.ts + +import { z } from 'zod' + +export const spaceWeatherDataSchema = z.object({ + kp: z.number().optional(), + bz_gsm: z.number().optional(), + speed_kms: z.number().optional(), + density: z.number().optional(), + xray_class: z.string().optional(), + xray_flux: z.number().optional(), + ts: z.string().optional(), + pushed: z.number().optional(), +}) + +export type SpaceWeatherData = z.infer + +export const wikiItnSchema = z.object({ + slug: z.string(), + text: z.string(), +}) + +export type WikiItn = z.infer + +export const gdeltSpikeSchema = z.object({ + theme: z.string(), + time: z.string(), + count: z.number(), + zscore: z.number(), +}) + +export type GdeltSpike = z.infer + +export const isValidSpaceWeatherData = ( + data: unknown +): data is SpaceWeatherData => { + if (data === null || data === undefined || typeof data !== 'object') + return false + return spaceWeatherDataSchema.safeParse(data).success +} + +export const isValidWikiItn = (data: unknown): data is WikiItn => { + if (data === null || data === undefined || typeof data !== 'object') + return false + return wikiItnSchema.safeParse(data).success +} + +export const isValidGdeltSpike = (data: unknown): data is GdeltSpike => { + if (data === null || data === undefined || typeof data !== 'object') + return false + return gdeltSpikeSchema.safeParse(data).success +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/types/notification.types.ts b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/types/notification.types.ts new file mode 100644 index 00000000..8f3d0ec3 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/types/notification.types.ts @@ -0,0 +1,89 @@ +// =================== +// © AngelaMos | 2026 +// notification.types.ts +// =================== + +import { z } from 'zod' + +export const channelTypeSchema = z.enum(['slack', 'discord', 'telegram']) +export type ChannelType = z.infer + +export const channelResponseSchema = z.object({ + id: z.string(), + type: channelTypeSchema, + label: z.string(), + invalid: z.boolean(), + created_at: z.string(), +}) + +export const telegramStatusResponseSchema = z.object({ + configured: z.boolean(), + linked: z.boolean(), + pending_link: z.boolean(), + webhook_url: z.string().optional(), + webhook_registered: z.boolean().optional(), + created_at: z.string().optional(), +}) + +export const channelListResponseSchema = z.object({ + channels: z.array(channelResponseSchema), + telegram: telegramStatusResponseSchema, +}) + +export const registerTelegramResponseSchema = z.object({ + webhook_url: z.string(), + webhook_registered: z.boolean(), +}) + +export const createChannelRequestSchema = z.object({ + type: z.enum(['slack', 'discord']), + label: z.string().min(1).max(100), + webhook_url: z.string().url().max(2048), +}) + +export const registerTelegramRequestSchema = z.object({ + bot_token: z.string().min(10).max(200), +}) + +export type ChannelResponse = z.infer +export type TelegramStatusResponse = z.infer +export type ChannelListResponse = z.infer +export type RegisterTelegramResponse = z.infer< + typeof registerTelegramResponseSchema +> +export type CreateChannelRequest = z.infer +export type RegisterTelegramRequest = z.infer< + typeof registerTelegramRequestSchema +> + +export const isValidChannelListResponse = ( + data: unknown +): data is ChannelListResponse => { + if (data === null || data === undefined) return false + return channelListResponseSchema.safeParse(data).success +} + +export const isValidTelegramStatus = ( + data: unknown +): data is TelegramStatusResponse => { + if (data === null || data === undefined) return false + return telegramStatusResponseSchema.safeParse(data).success +} + +export const NOTIFICATION_ERROR_MESSAGES = { + LIST_FAILED: 'Failed to load notification channels', + CREATE_FAILED: 'Failed to add notification channel', + DELETE_FAILED: 'Failed to remove notification channel', + TEST_FAILED: 'Test notification failed', + REGISTER_TELEGRAM_FAILED: 'Failed to register Telegram bot', + UNLINK_TELEGRAM_FAILED: 'Failed to unlink Telegram', + INVALID_RESPONSE: 'Invalid response from server', +} as const + +export const NOTIFICATION_SUCCESS_MESSAGES = { + CHANNEL_ADDED: 'Notification channel added', + CHANNEL_DELETED: 'Notification channel removed', + TEST_SENT: 'Test notification sent', + TELEGRAM_REGISTERED: 'Telegram bot registered', + TELEGRAM_UNLINKED: 'Telegram unlinked', +} as const diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/types/threat.types.ts b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/types/threat.types.ts index acae5284..a07ecb21 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/types/threat.types.ts +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/types/threat.types.ts @@ -31,6 +31,8 @@ export const dshieldSourceSchema = z.object({ reports: z.number(), targets: z.number(), country: z.string().optional(), + classification: z.string().optional(), + actor: z.string().optional(), }) export type DShieldSource = z.infer @@ -58,12 +60,14 @@ export type DShieldData = z.infer export const isValidRansomwareVictim = ( data: unknown ): data is RansomwareVictim => { - if (data === null || data === undefined || typeof data !== 'object') return false + if (data === null || data === undefined || typeof data !== 'object') + return false return ransomwareVictimSchema.safeParse(data).success } export const isValidDShieldData = (data: unknown): data is DShieldData => { - if (data === null || data === undefined || typeof data !== 'object') return false + if (data === null || data === undefined || typeof data !== 'object') + return false return dshieldDataSchema.safeParse(data).success } @@ -89,6 +93,7 @@ export const bgpHijackSchema = z.object({ export type BgpHijack = z.infer export const isValidBgpHijack = (data: unknown): data is BgpHijack => { - if (data === null || data === undefined || typeof data !== 'object') return false + if (data === null || data === undefined || typeof data !== 'object') + return false return bgpHijackSchema.safeParse(data).success } diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/types/user.types.ts b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/types/user.types.ts index 2fd36234..4031596f 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/types/user.types.ts +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/types/user.types.ts @@ -13,7 +13,8 @@ export const userListResponseSchema = z.object({ items: z.array(userResponseSchema), total: z.number(), page: z.number(), - size: z.number(), + page_size: z.number(), + total_pages: z.number(), }) export const userCreateRequestSchema = z.object({ @@ -22,11 +23,11 @@ export const userCreateRequestSchema = z.object({ .string() .min(PASSWORD_CONSTRAINTS.MIN_LENGTH) .max(PASSWORD_CONSTRAINTS.MAX_LENGTH), - full_name: z.string().max(255).nullable().optional(), + name: z.string().min(1).max(100), }) export const userUpdateRequestSchema = z.object({ - full_name: z.string().max(255).nullable().optional(), + name: z.string().min(1).max(100).optional(), }) export const adminUserCreateRequestSchema = z.object({ @@ -35,18 +36,15 @@ export const adminUserCreateRequestSchema = z.object({ .string() .min(PASSWORD_CONSTRAINTS.MIN_LENGTH) .max(PASSWORD_CONSTRAINTS.MAX_LENGTH), - full_name: z.string().max(255).nullable().optional(), + name: z.string().min(1).max(100), role: z.nativeEnum(UserRole).optional(), - is_active: z.boolean().optional(), - is_verified: z.boolean().optional(), + tier: z.string().optional(), }) export const adminUserUpdateRequestSchema = z.object({ - email: z.string().email().optional(), - full_name: z.string().max(255).nullable().optional(), + name: z.string().min(1).max(100).optional(), role: z.nativeEnum(UserRole).optional(), - is_active: z.boolean().optional(), - is_verified: z.boolean().optional(), + tier: z.string().optional(), }) export const paginationParamsSchema = z.object({ @@ -66,29 +64,7 @@ export const isValidUserListResponse = ( ): data is UserListResponse => { if (data === null || data === undefined) return false if (typeof data !== 'object') return false - - const result = userListResponseSchema.safeParse(data) - return result.success -} - -export const isValidUserCreateRequest = ( - data: unknown -): data is UserCreateRequest => { - if (data === null || data === undefined) return false - if (typeof data !== 'object') return false - - const result = userCreateRequestSchema.safeParse(data) - return result.success -} - -export const isValidAdminUserCreateRequest = ( - data: unknown -): data is AdminUserCreateRequest => { - if (data === null || data === undefined) return false - if (typeof data !== 'object') return false - - const result = adminUserCreateRequestSchema.safeParse(data) - return result.success + return userListResponseSchema.safeParse(data).success } export class UserResponseError extends Error { diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/ws.test.ts b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/ws.test.ts index bd43e1d8..0b01fcd7 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/ws.test.ts +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/ws.test.ts @@ -1,7 +1,7 @@ // ©AngelaMos | 2026 // ws.test.ts -import { describe, it, expect, vi } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { createDashboardWS, type WSDriver } from './ws' class FakeDriver implements WSDriver { diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/ws.ts b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/ws.ts index e24135c5..4fcc8d3b 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/ws.ts +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/api/ws.ts @@ -10,10 +10,9 @@ export interface WSDriver { } export interface WSEvent { - topic: string - source?: string - timestamp?: string - payload?: unknown + ch: string + data?: unknown + ts?: string } export interface BackoffConfig { @@ -39,32 +38,41 @@ interface DashboardWS { disconnect: () => void } +const INIT_OP = '{"op":"init"}' + export function createDashboardWS(opts: CreateDashboardWSOpts): DashboardWS { const backoff = opts.backoff ?? DEFAULT_BACKOFF const onEvent = opts.onEvent ?? (() => undefined) let driver: WSDriver | null = null let ready = false + let opened = false let closed = false let buffer: WSEvent[] = [] let nextDelay = backoff.initialMs let reconnectTimer: ReturnType | null = null + function sendInitIfReady() { + if (ready && opened && driver) { + driver.send(INIT_OP) + } + } + function attach() { if (closed) return const d = opts.driver() driver = d + opened = false d.onOpen = () => { - // reset backoff on a successful open; init is sent only after setReady() nextDelay = backoff.initialMs - if (ready) sendInit() + opened = true + sendInitIfReady() } d.onMessage = (data) => { let parsed: WSEvent try { parsed = JSON.parse(data) as WSEvent } catch { - console.warn('coinbase ws: malformed frame', data) return } if (ready) { @@ -75,6 +83,7 @@ export function createDashboardWS(opts: CreateDashboardWSOpts): DashboardWS { } d.onClose = () => { driver = null + opened = false if (closed) return const delay = nextDelay nextDelay = Math.min(nextDelay * 2, backoff.maxMs) @@ -82,24 +91,21 @@ export function createDashboardWS(opts: CreateDashboardWSOpts): DashboardWS { } } - function sendInit() { - driver?.send(JSON.stringify({ op: 'init' })) - } - return { connect() { attach() }, setReady() { ready = true + sendInitIfReady() const flush = buffer buffer = [] for (const ev of flush) onEvent(ev) - sendInit() }, disconnect() { closed = true ready = false + opened = false if (reconnectTimer) { clearTimeout(reconnectTimer) reconnectTimer = null diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/config.ts b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/config.ts index d6fcfb30..fc86b087 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/config.ts +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/config.ts @@ -7,6 +7,7 @@ const API_VERSION = 'v1' export const API_ENDPOINTS = { AUTH: { LOGIN: `/${API_VERSION}/auth/login`, + REGISTER: `/${API_VERSION}/auth/register`, REFRESH: `/${API_VERSION}/auth/refresh`, LOGOUT: `/${API_VERSION}/auth/logout`, LOGOUT_ALL: `/${API_VERSION}/auth/logout-all`, @@ -15,9 +16,8 @@ export const API_ENDPOINTS = { }, USERS: { BASE: `/${API_VERSION}/users`, - BY_ID: (id: string) => `/${API_VERSION}/users/${id}`, ME: `/${API_VERSION}/users/me`, - REGISTER: `/${API_VERSION}/users`, + EMAIL: `/${API_VERSION}/users/me/email`, }, ADMIN: { USERS: { @@ -28,6 +28,23 @@ export const API_ENDPOINTS = { DELETE: (id: string) => `/${API_VERSION}/admin/users/${id}`, }, }, + NOTIFICATIONS: { + CHANNELS: `/${API_VERSION}/notifications/channels`, + CHANNEL_BY_ID: (id: string) => `/${API_VERSION}/notifications/channels/${id}`, + CHANNEL_TEST: (id: string) => + `/${API_VERSION}/notifications/channels/${id}/test`, + TELEGRAM: `/${API_VERSION}/notifications/telegram`, + TELEGRAM_STATUS: `/${API_VERSION}/notifications/telegram/status`, + TELEGRAM_TEST: `/${API_VERSION}/notifications/telegram/test`, + }, + INTEL: { + CVES: `/${API_VERSION}/intel/cves`, + KEV: `/${API_VERSION}/intel/kev`, + HIJACKS: `/${API_VERSION}/intel/hijacks`, + OUTAGES: `/${API_VERSION}/intel/outages`, + RANSOMWARE: `/${API_VERSION}/intel/ransomware`, + QUAKES: `/${API_VERSION}/intel/quakes`, + }, } as const export const QUERY_KEYS = { @@ -37,7 +54,6 @@ export const QUERY_KEYS = { }, USERS: { ALL: ['users'] as const, - BY_ID: (id: string) => [...QUERY_KEYS.USERS.ALL, 'detail', id] as const, ME: () => [...QUERY_KEYS.USERS.ALL, 'me'] as const, }, ADMIN: { @@ -50,6 +66,21 @@ export const QUERY_KEYS = { [...QUERY_KEYS.ADMIN.USERS.ALL(), 'detail', id] as const, }, }, + NOTIFICATIONS: { + ALL: ['notifications'] as const, + CHANNELS: () => [...QUERY_KEYS.NOTIFICATIONS.ALL, 'channels'] as const, + TELEGRAM_STATUS: () => + [...QUERY_KEYS.NOTIFICATIONS.ALL, 'telegram-status'] as const, + }, + INTEL: { + ALL: ['intel'] as const, + CVES: () => [...QUERY_KEYS.INTEL.ALL, 'cves'] as const, + KEV: () => [...QUERY_KEYS.INTEL.ALL, 'kev'] as const, + HIJACKS: () => [...QUERY_KEYS.INTEL.ALL, 'hijacks'] as const, + OUTAGES: () => [...QUERY_KEYS.INTEL.ALL, 'outages'] as const, + RANSOMWARE: () => [...QUERY_KEYS.INTEL.ALL, 'ransomware'] as const, + QUAKES: () => [...QUERY_KEYS.INTEL.ALL, 'quakes'] as const, + }, } as const export const ROUTES = { diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/core/api/api.config.ts b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/core/api/api.config.ts index 8347fa00..f2722898 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/core/api/api.config.ts +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/core/api/api.config.ts @@ -21,6 +21,17 @@ interface RefreshSubscriber { reject: (error: Error) => void } +interface GoEnvelope { + success: boolean + data?: unknown + meta?: { + page: number + page_size: number + total: number + total_pages: number + } +} + const getBaseURL = (): string => { return import.meta.env.VITE_API_URL ?? '/api' } @@ -54,9 +65,10 @@ const addRefreshSubscriber = ( } const handleTokenRefresh = async (): Promise => { - const response = await apiClient.post<{ access_token: string }>( - API_ENDPOINTS.AUTH.REFRESH - ) + // Refresh token rides in the HttpOnly cookie set by the backend on login. + // axios sends it automatically because the client is configured with + // withCredentials: true. We don't read or send it from JS at all. + const response = await apiClient.post(API_ENDPOINTS.AUTH.REFRESH, {}) if ( response.data === null || @@ -70,7 +82,10 @@ const handleTokenRefresh = async (): Promise => { ) } - const accessToken = response.data.access_token + const payload = response.data as { + tokens?: { access_token?: string } + } + const accessToken = payload.tokens?.access_token if (typeof accessToken !== 'string' || accessToken.length === 0) { throw new ApiError( 'Invalid access token', @@ -87,6 +102,33 @@ const handleAuthFailure = (): void => { window.location.href = '/login' } +apiClient.interceptors.response.use( + (response) => { + if ( + response.status !== 204 && + response.data !== null && + response.data !== undefined && + typeof response.data === 'object' && + 'success' in (response.data as object) + ) { + const envelope = response.data as GoEnvelope + if (envelope.meta !== undefined && envelope.meta !== null) { + response.data = { + items: envelope.data, + page: envelope.meta.page, + page_size: envelope.meta.page_size, + total: envelope.meta.total, + total_pages: envelope.meta.total_pages, + } + } else { + response.data = envelope.data + } + } + return response + }, + (error: unknown) => Promise.reject(error) +) + apiClient.interceptors.request.use( (config: InternalAxiosRequestConfig): InternalAxiosRequestConfig => { const token = useAuthStore.getState().accessToken @@ -113,8 +155,21 @@ apiClient.interceptors.response.use( const isNotRetried = originalRequest._retry !== true const isNotRefreshEndpoint = originalRequest.url?.includes(API_ENDPOINTS.AUTH.REFRESH) !== true + const hadBearerToken = + typeof originalRequest.headers.Authorization === 'string' && + originalRequest.headers.Authorization.startsWith('Bearer ') + // Cold-load hydration: persisted isAuthenticated=true but accessToken + // is missing (it's not persisted across reloads). The first protected + // call has no Bearer header, gets 401, and we should still try a + // refresh via the HttpOnly cookie. + const persistedAuth = useAuthStore.getState().isAuthenticated - if (isUnauthorized && isNotRetried && isNotRefreshEndpoint) { + if ( + isUnauthorized && + isNotRetried && + isNotRefreshEndpoint && + (hadBearerToken || persistedAuth) + ) { if (isRefreshing) { return new Promise((resolve, reject) => { addRefreshSubscriber( diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/core/api/errors.ts b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/core/api/errors.ts index fde5ec65..1d507781 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/core/api/errors.ts +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/core/api/errors.ts @@ -61,6 +61,8 @@ export class ApiError extends Error { } interface ApiErrorResponse { + success?: boolean + error?: { code?: string; message?: string } detail?: string | { msg: string; type: string }[] message?: string } @@ -75,7 +77,9 @@ export function transformAxiosError(error: AxiosError): ApiError { let message = 'An error occurred' let details: Record | undefined - if (data?.detail) { + if (data?.error?.message) { + message = data.error.message + } else if (data?.detail) { if (typeof data.detail === 'string') { message = data.detail } else if (Array.isArray(data.detail)) { diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/core/app/protected-route.tsx b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/core/app/protected-route.tsx index 486eec78..6e8d8496 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/core/app/protected-route.tsx +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/core/app/protected-route.tsx @@ -4,6 +4,7 @@ // =================== import { Navigate, Outlet, useLocation } from 'react-router-dom' +import { useCurrentUser } from '@/api/hooks' import type { UserRole } from '@/api/types' import { ROUTES } from '@/config' import { useAuthStore } from '@/core/lib' @@ -20,11 +21,18 @@ export function ProtectedRoute({ const location = useLocation() const { isAuthenticated, isLoading, user } = useAuthStore() - if (isLoading) { - return
Loading...
+ // On cold load, isAuthenticated is restored from localStorage but the + // access token is gone (not persisted). useCurrentUser fires /auth/me; + // the axios interceptor catches the inevitable 401 and refreshes via + // the HttpOnly cookie. While that's in flight we hold the route — no + // flash of protected content before bouncing to /login. + const { isLoading: hydrating, isError: hydrationFailed } = useCurrentUser() + + if (isLoading || (isAuthenticated && hydrating && user === null)) { + return
Loading…
} - if (!isAuthenticated) { + if (!isAuthenticated || hydrationFailed) { return ( void setLoading: (loading: boolean) => void setAccessToken: (token: string | null) => void + setUser: (user: UserResponse | null) => void updateUser: (updates: Partial) => void } @@ -64,6 +70,9 @@ export const useAuthStore = create()( setAccessToken: (token) => set({ accessToken: token }, false, 'auth/setAccessToken'), + setUser: (user) => + set({ user, isAuthenticated: user !== null }, false, 'auth/setUser'), + updateUser: (updates) => set( (state) => ({ diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/lib/countryCentroids.ts b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/lib/countryCentroids.ts index 88878957..5fa0f535 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/lib/countryCentroids.ts +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/lib/countryCentroids.ts @@ -23,7 +23,6 @@ const SUPPLEMENTARY_CENTROIDS: Record = { VA: { lat: 41.9029, lng: 12.4534 }, MV: { lat: 3.2028, lng: 73.2207 }, BB: { lat: 13.1939, lng: -59.5432 }, - BS: { lat: 25.0343, lng: -77.3963 }, BM: { lat: 32.3078, lng: -64.7505 }, KY: { lat: 19.3133, lng: -81.2546 }, JM: { lat: 18.1096, lng: -77.2975 }, diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/lib/ethos-tokens.ts b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/lib/ethos-tokens.ts index 7332d2d2..a71b3dbb 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/lib/ethos-tokens.ts +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/lib/ethos-tokens.ts @@ -11,18 +11,18 @@ export const colors = { fg4: '#404040', ok: '#4ade80', amber: '#f59e0b', -} as const; +} as const export const density = { rowPy: 4, rowPx: 8, lineHeightTight: 1.25, letterSpacingLabel: '0.08em', -} as const; +} as const export const typeSizes = { - body: 12, - label: 11, - numL: 20, - numXL: 28, -} as const; + body: 10, + label: 10, + numL: 18, + numXL: 25, +} as const diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/lib/usePriceFlash.ts b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/lib/usePriceFlash.ts new file mode 100644 index 00000000..0cd0a1eb --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/lib/usePriceFlash.ts @@ -0,0 +1,45 @@ +// ©AngelaMos | 2026 +// usePriceFlash.ts + +import { useEffect, useRef, useState } from 'react' + +export type FlashDir = 'up' | 'down' | null + +interface FlashState { + dir: FlashDir + key: number +} + +const FLASH_MS = 600 + +export function usePriceFlash(price: number | null): FlashState { + const prevRef = useRef(null) + const [state, setState] = useState({ dir: null, key: 0 }) + const timerRef = useRef | null>(null) + + useEffect(() => { + if (price === null) return + + const prev = prevRef.current + prevRef.current = price + + if (prev === null || price === prev) return + + if (timerRef.current !== null) clearTimeout(timerRef.current) + + const dir: FlashDir = price > prev ? 'up' : 'down' + setState((s) => ({ dir, key: s.key + 1 })) + + timerRef.current = setTimeout(() => { + setState((s) => ({ ...s, dir: null })) + }, FLASH_MS) + }, [price]) + + useEffect(() => { + return () => { + if (timerRef.current !== null) clearTimeout(timerRef.current) + } + }, []) + + return state +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/lib/useThrottledValue.ts b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/lib/useThrottledValue.ts new file mode 100644 index 00000000..6dfd5058 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/lib/useThrottledValue.ts @@ -0,0 +1,43 @@ +// ©AngelaMos | 2026 +// useThrottledValue.ts + +import { useEffect, useRef, useState } from 'react' + +const DEFAULT_INTERVAL_MS = 200 + +export function useThrottledValue( + value: T, + intervalMs: number = DEFAULT_INTERVAL_MS +): T { + const [throttled, setThrottled] = useState(value) + const latestRef = useRef(value) + const lastEmitRef = useRef(0) + const timerRef = useRef | null>(null) + + useEffect(() => { + latestRef.current = value + const now = performance.now() + const elapsed = now - lastEmitRef.current + + if (elapsed >= intervalMs) { + lastEmitRef.current = now + setThrottled(value) + return + } + + if (timerRef.current !== null) return + timerRef.current = setTimeout(() => { + timerRef.current = null + lastEmitRef.current = performance.now() + setThrottled(latestRef.current) + }, intervalMs - elapsed) + }, [value, intervalMs]) + + useEffect(() => { + return () => { + if (timerRef.current !== null) clearTimeout(timerRef.current) + } + }, []) + + return throttled +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/main.tsx b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/main.tsx index 1b1f2c8a..d37d2419 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/main.tsx +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/main.tsx @@ -5,6 +5,9 @@ import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' +import '@fontsource-variable/inter/index.css' +import '@fontsource/geist-mono/400.css' +import '@fontsource/geist-mono/500.css' import App from './App' import './styles.scss' diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/admin/index.tsx b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/admin/index.tsx index 53fa22a1..6d24d339 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/admin/index.tsx +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/admin/index.tsx @@ -37,27 +37,22 @@ export function Component(): React.ReactElement { const handleCreate = (formData: FormData): void => { const email = formData.get('email') as string const password = formData.get('password') as string - const fullName = (formData.get('fullName') as string) || undefined + const rawName = (formData.get('fullName') as string) ?? '' + const name = rawName.trim() || email.split('@')[0] || 'user' const role = formData.get('role') as UserRole - const isActive = formData.get('isActive') === 'on' createUser.mutate( - { email, password, full_name: fullName, role, is_active: isActive }, + { email, password, name, role }, { onSuccess: () => setModal({ type: 'closed' }) } ) } const handleUpdate = (userId: string, formData: FormData): void => { - const email = formData.get('email') as string - const fullName = (formData.get('fullName') as string) || undefined + const name = (formData.get('fullName') as string) || undefined const role = formData.get('role') as UserRole - const isActive = formData.get('isActive') === 'on' updateUser.mutate( - { - id: userId, - data: { email, full_name: fullName, role, is_active: isActive }, - }, + { id: userId, data: { name, role } }, { onSuccess: () => setModal({ type: 'closed' }) } ) } @@ -87,7 +82,6 @@ export function Component(): React.ReactElement {
Email
Name
Role
-
Status
Actions
@@ -104,7 +98,7 @@ export function Component(): React.ReactElement { {user.email}
- {user.full_name ?? '—'} + {user.name}
-
- - {user.is_active ? 'Active' : 'Inactive'} - -
-
@@ -315,7 +298,7 @@ export function Component(): React.ReactElement { name="fullName" type="text" className={styles.input} - defaultValue={modal.user.full_name ?? ''} + defaultValue={modal.user.name} />
@@ -332,14 +315,6 @@ export function Component(): React.ReactElement {
-
-
{formatUTC(now)}
+
+
+ + {showPWAHint && ( +
+ Tap ShareAdd to Home Screen for + fullscreen +
+ )} +
{isAuthenticated ? (

Login

diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/login/login.module.scss b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/login/login.module.scss index 6d01ed24..4134dcf8 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/login/login.module.scss +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/login/login.module.scss @@ -6,8 +6,10 @@ @use '@/styles' as *; .page { + position: relative; min-height: 100vh; min-height: 100dvh; + font-size: 16px; @include flex-center; background-color: $bg-default; background-image: radial-gradient( @@ -35,21 +37,22 @@ } .header { - margin-bottom: $space-6; + margin-bottom: $space-7; } .title { - font-size: $font-size-2xl; + font-size: 2.6rem; font-weight: $font-weight-semibold; color: $white; - margin-bottom: $space-2; + margin-bottom: $space-3; } .subtitle { - font-size: $font-size-sm; + font-size: 1.25rem; color: $text-light; } + .form { @include flex-column; gap: $space-5; @@ -61,7 +64,7 @@ } .label { - font-size: $font-size-sm; + font-size: 1.25rem; font-weight: $font-weight-medium; color: $white; } @@ -73,7 +76,7 @@ background-color: transparent; border: 1px solid $border-default; border-radius: $radius-md; - font-size: $font-size-base; + font-size: 1.1rem; color: $white; @include transition-fast; @@ -116,8 +119,8 @@ } svg { - width: 20px; - height: 20px; + width: 17px; + height: 17px; } } @@ -136,7 +139,7 @@ background-color: $white; border: none; border-radius: $radius-md; - font-size: $font-size-base; + font-size: 1.35rem; font-weight: $font-weight-medium; color: $black; cursor: pointer; @@ -153,9 +156,9 @@ } .footer { - margin-top: $space-6; + margin-top: $space-7; text-align: center; - font-size: $font-size-sm; + font-size: $font-size-lg; color: $text-light; } @@ -169,3 +172,29 @@ color: $text-light; } } + +.backButton { + position: absolute; + top: $space-5; + left: $space-5; + display: inline-flex; + align-items: center; + gap: $space-2; + background: transparent; + border: none; + color: $text-muted; + font-size: $font-size-sm; + font-weight: $font-weight-medium; + cursor: pointer; + padding: $space-1 0; + @include transition-fast; + + @include hover { + color: $text-light; + } + + svg { + width: 16px; + height: 16px; + } +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/panels/BGPHijackPanel.tsx b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/panels/BGPHijackPanel.tsx index 028b5214..49a93e3f 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/panels/BGPHijackPanel.tsx +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/panels/BGPHijackPanel.tsx @@ -3,11 +3,13 @@ import { useEffect, useRef, useState } from 'react' import { useBgpHijackData } from '@/api/hooks' -import { Panel } from './Panel' +import { useFreshness } from '@/stores/freshness' import styles from './BGPHijackPanel.module.scss' +import { Panel } from './Panel' const HIJACK_ROW_LIMIT = 6 const FLASH_DURATION_MS = 600 +const STALE_AFTER_MS = 600_000 const MS_PER_HOUR = 3_600_000 const MS_PER_MINUTE = 60_000 const HOURS_PER_DAY = 24 @@ -38,12 +40,23 @@ export function BGPHijackPanel(): React.ReactElement { const recent = items.slice(0, HIJACK_ROW_LIMIT) const now = Date.now() + const lastTickAt = useFreshness((s) => s.ts.bgp) + const isStale = + items.length === 0 + ? undefined + : lastTickAt !== undefined && Date.now() - lastTickAt > STALE_AFTER_MS + return ( diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/panels/BTCPanel.module.scss b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/panels/BTCPanel.module.scss index c6bb247f..0640fdc1 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/panels/BTCPanel.module.scss +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/panels/BTCPanel.module.scss @@ -50,10 +50,23 @@ font-variant-numeric: tabular-nums; } -.spark { - display: flex; - align-items: center; - justify-content: center; - padding: 8px; - color: var(--fg-2); +.up { color: var(--ok); } +.down { color: #f87171; } + +@keyframes flash-up { + 0% { color: var(--ok); } + 100% { color: var(--fg-1); } +} + +@keyframes flash-down { + 0% { color: #f87171; } + 100% { color: var(--fg-1); } +} + +.flashUp { + animation: flash-up 600ms ease-out forwards; +} + +.flashDown { + animation: flash-down 600ms ease-out forwards; } diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/panels/BTCPanel.tsx b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/panels/BTCPanel.tsx index f8594086..92d8ce29 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/panels/BTCPanel.tsx +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/panels/BTCPanel.tsx @@ -1,75 +1,62 @@ // ©AngelaMos | 2026 // BTCPanel.tsx -import { useEffect } from 'react' -import { useSnapshot } from '@/api/snapshot' -import { usePrices } from '@/stores/prices' +import { useCoinbasePrices } from '@/api/hooks' +import { usePriceFlash } from '@/lib/usePriceFlash' +import { useThrottledValue } from '@/lib/useThrottledValue' import styles from './BTCPanel.module.scss' import { Panel } from './Panel' -import { Sparkline } from './shared/Sparkline' const SYMBOL = 'BTC-USD' -const SPARKLINE_WIDTH = 280 -const SPARKLINE_HEIGHT = 22 const PERCENT_DECIMALS = 2 const PRICE_DECIMALS = 2 const STALE_AFTER_MS = 60_000 - -interface CoinbaseSnapshotTick { - symbol: string - ts: string - price: string - volume_24h?: string -} +const PRICE_THROTTLE_MS = 1_500 export function BTCPanel(): React.ReactElement { - const { data } = useSnapshot() - const latest = usePrices((s) => s.latest[SYMBOL]) - const history = usePrices((s) => s.history[SYMBOL]) - const pushTick = usePrices((s) => s.pushTick) + const { latest, history } = useCoinbasePrices(SYMBOL) - const seed = data?.coinbase_price as CoinbaseSnapshotTick | undefined - useEffect(() => { - if (!seed || seed.symbol !== SYMBOL) return - pushTick({ - symbol: seed.symbol, - ts: new Date(seed.ts).getTime(), - price: seed.price, - volume24h: seed.volume_24h, - }) - }, [seed, pushTick]) - - const priceNum = latest ? Number(latest.price) : null - const closes = (history ?? []).map((b) => Number(b.close)) + const rawPriceNum = latest ? Number(latest.price) : null + const priceNum = useThrottledValue(rawPriceNum, PRICE_THROTTLE_MS) + const closes = history.map((b) => Number(b.close)) const pct1h = computeChangePct(closes) + const { dir: flashDir, key: flashKey } = usePriceFlash(priceNum) const lastTickAt = latest?.ts const isStale = latest === undefined ? undefined : Date.now() - latest.ts > STALE_AFTER_MS + const priceClass = [ + styles.price, + flashDir === 'up' + ? styles.flashUp + : flashDir === 'down' + ? styles.flashDown + : '', + ] + .filter(Boolean) + .join(' ') + return (
- {fmtPrice(priceNum)} + + {fmtPrice(priceNum)} + USD
-
- -
) } @@ -83,10 +70,22 @@ function ChangeRow({ label: string pct: number | null }): React.ReactElement { + const isUp = pct !== null && pct >= 0 return (
{label} - {fmtPct(pct)} + + {pct === null ? ( + '—' + ) : ( + <> + + {isUp ? '△' : '▽'} + + {` ${Math.abs(pct).toFixed(PERCENT_DECIMALS)} %`} + + )} +
) } @@ -107,12 +106,6 @@ function computeChangePct(closes: number[]): number | null { return ((last - first) / first) * 100 } -function fmtPct(pct: number | null): string { - if (pct === null) return '—' - const glyph = pct >= 0 ? '△' : '▽' - return `${glyph} ${Math.abs(pct).toFixed(PERCENT_DECIMALS)} %` -} - function fmtPrice(n: number | null): string { if (n === null || !Number.isFinite(n)) return '—' return n.toLocaleString(undefined, { diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/panels/CVEVelocityPanel.tsx b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/panels/CVEVelocityPanel.tsx index f0390906..84cd9f6e 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/panels/CVEVelocityPanel.tsx +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/panels/CVEVelocityPanel.tsx @@ -1,9 +1,10 @@ // ©AngelaMos | 2026 // CVEVelocityPanel.tsx -import { useEffect, useMemo } from 'react' -import { useSnapshot } from '@/api/snapshot' -import { type CveEvent, useCveStore } from '@/stores/cve' +import { useMemo } from 'react' +import { useCveData } from '@/api/hooks' +import type { CveEvent } from '@/api/types' +import { useFreshness } from '@/stores/freshness' import styles from './CVEVelocityPanel.module.scss' import { Panel } from './Panel' import { KPI } from './shared/KPI' @@ -11,6 +12,7 @@ import { Sparkline } from './shared/Sparkline' const RECENT_ROW_LIMIT = 5 const SPARKLINE_HOURS = 24 +const STALE_AFTER_MS = 900_000 const SPARKLINE_WIDTH = 280 const SPARKLINE_HEIGHT = 28 const MS_PER_HOUR = 3_600_000 @@ -18,38 +20,42 @@ const MS_PER_MINUTE = 60_000 const HOURS_PER_DAY = 24 export function CVEVelocityPanel(): React.ReactElement { - const { data } = useSnapshot() - const items = useCveStore((s) => s.items) - const push = useCveStore((s) => s.push) - - const seed = data?.cve_new as CveEvent | undefined - useEffect(() => { - if (seed?.CveID) push(seed) - }, [seed, push]) - - const now = Date.now() + const { items } = useCveData() + const nowMinute = Math.floor(Date.now() / MS_PER_MINUTE) * MS_PER_MINUTE const counts = useMemo(() => { return { - h1: countWithin(items, 1, now), - h6: countWithin(items, 6, now), - h24: countWithin(items, SPARKLINE_HOURS, now), + h1: countWithin(items, 1, nowMinute), + h6: countWithin(items, 6, nowMinute), + h24: countWithin(items, SPARKLINE_HOURS, nowMinute), } - }, [items, now]) + }, [items, nowMinute]) const hourly = useMemo( - () => hourlyBuckets(items, SPARKLINE_HOURS, now), - [items, now] + () => hourlyBuckets(items, SPARKLINE_HOURS, nowMinute), + [items, nowMinute] ) const recent = items.slice(0, RECENT_ROW_LIMIT) + const now = Date.now() + + const lastTickAt = useFreshness((s) => s.ts.cve) + const isStale = + items.length === 0 + ? undefined + : lastTickAt !== undefined && Date.now() - lastTickAt > STALE_AFTER_MS return (
diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/panels/DShieldPanel.module.scss b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/panels/DShieldPanel.module.scss index 00be4c3f..7c694774 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/panels/DShieldPanel.module.scss +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/panels/DShieldPanel.module.scss @@ -46,3 +46,21 @@ font-family: var(--font-mono); font-variant-numeric: tabular-nums; } + +.tag { + padding: var(--row-py) var(--row-px); + font-family: var(--font-mono); + font-size: var(--type-col); + letter-spacing: var(--letter-spacing-label); + text-transform: uppercase; + color: var(--fg-3); + white-space: nowrap; +} + +.tagBenign { + color: var(--fg-2); +} + +.tagMalicious { + color: var(--amber); +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/panels/DShieldPanel.tsx b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/panels/DShieldPanel.tsx index 233ca464..53179a59 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/panels/DShieldPanel.tsx +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/panels/DShieldPanel.tsx @@ -1,18 +1,21 @@ // ©AngelaMos | 2026 // DShieldPanel.tsx -import { type DShieldDailySummary, type DShieldPort } from '@/api/types' import { useDShieldData } from '@/api/hooks' +import type { DShieldDailySummary, DShieldPort, DShieldSource } from '@/api/types' +import { useFreshness } from '@/stores/freshness' import styles from './DShieldPanel.module.scss' import { Panel } from './Panel' const PORT_ROW_LIMIT = 8 const SOURCE_ROW_LIMIT = 8 +const STALE_AFTER_MS = 300_000 const THOUSAND = 1_000 const MILLION = 1_000_000 export function DShieldPanel(): React.ReactElement { const ds = useDShieldData() + const lastTickAt = useFreshness((s) => s.ts.dshield) const ports = toArray(ds.topports) .filter(isPort) @@ -26,13 +29,22 @@ export function DShieldPanel(): React.ReactElement { .slice(0, SOURCE_ROW_LIMIT) const summary = pickLatestSummary(ds.dailysummary) + const hasData = ports.length > 0 || sources.length > 0 + const isStale = !hasData + ? undefined + : lastTickAt !== undefined && Date.now() - lastTickAt > STALE_AFTER_MS return (
@@ -60,6 +72,7 @@ export function DShieldPanel(): React.ReactElement { + @@ -68,6 +81,7 @@ export function DShieldPanel(): React.ReactElement { + ))} @@ -117,3 +131,18 @@ function fmtN(n: number): string { if (n >= THOUSAND) return `${(n / THOUSAND).toFixed(1)}k` return String(n) } + +function renderTag(s: DShieldSource): string { + if (s.actor && s.actor.length > 0) return s.actor.toUpperCase() + if (s.classification && s.classification.length > 0) { + return s.classification.toUpperCase() + } + return '' +} + +function tagClass(s: DShieldSource): string { + const c = (s.classification ?? '').toLowerCase() + if (c === 'malicious') return `${styles.tag} ${styles.tagMalicious}` + if (c === 'benign') return `${styles.tag} ${styles.tagBenign}` + return styles.tag +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/panels/ETHPanel.module.scss b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/panels/ETHPanel.module.scss index d393c3a0..70d205bd 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/panels/ETHPanel.module.scss +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/panels/ETHPanel.module.scss @@ -50,10 +50,23 @@ font-variant-numeric: tabular-nums; } -.spark { - display: flex; - align-items: center; - justify-content: center; - padding: 8px; - color: var(--fg-2); +.up { color: var(--ok); } +.down { color: #f87171; } + +@keyframes flash-up { + 0% { color: var(--ok); } + 100% { color: var(--fg-1); } +} + +@keyframes flash-down { + 0% { color: #f87171; } + 100% { color: var(--fg-1); } +} + +.flashUp { + animation: flash-up 600ms ease-out forwards; +} + +.flashDown { + animation: flash-down 600ms ease-out forwards; } diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/panels/ETHPanel.tsx b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/panels/ETHPanel.tsx index 0f1718c2..9eaeab50 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/panels/ETHPanel.tsx +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/panels/ETHPanel.tsx @@ -1,75 +1,62 @@ // ©AngelaMos | 2026 // ETHPanel.tsx -import { useEffect } from 'react' -import { useSnapshot } from '@/api/snapshot' -import { usePrices } from '@/stores/prices' +import { useCoinbasePrices } from '@/api/hooks' +import { usePriceFlash } from '@/lib/usePriceFlash' +import { useThrottledValue } from '@/lib/useThrottledValue' import styles from './ETHPanel.module.scss' import { Panel } from './Panel' -import { Sparkline } from './shared/Sparkline' const SYMBOL = 'ETH-USD' -const SPARKLINE_WIDTH = 280 -const SPARKLINE_HEIGHT = 22 const PERCENT_DECIMALS = 2 const PRICE_DECIMALS = 2 const STALE_AFTER_MS = 60_000 - -interface CoinbaseSnapshotTick { - symbol: string - ts: string - price: string - volume_24h?: string -} +const PRICE_THROTTLE_MS = 1_500 export function ETHPanel(): React.ReactElement { - const { data } = useSnapshot() - const latest = usePrices((s) => s.latest[SYMBOL]) - const history = usePrices((s) => s.history[SYMBOL]) - const pushTick = usePrices((s) => s.pushTick) + const { latest, history } = useCoinbasePrices(SYMBOL) - const seed = data?.coinbase_price as CoinbaseSnapshotTick | undefined - useEffect(() => { - if (!seed || seed.symbol !== SYMBOL) return - pushTick({ - symbol: seed.symbol, - ts: new Date(seed.ts).getTime(), - price: seed.price, - volume24h: seed.volume_24h, - }) - }, [seed, pushTick]) - - const priceNum = latest ? Number(latest.price) : null - const closes = (history ?? []).map((b) => Number(b.close)) + const rawPriceNum = latest ? Number(latest.price) : null + const priceNum = useThrottledValue(rawPriceNum, PRICE_THROTTLE_MS) + const closes = history.map((b) => Number(b.close)) const pct1h = computeChangePct(closes) + const { dir: flashDir, key: flashKey } = usePriceFlash(priceNum) const lastTickAt = latest?.ts const isStale = latest === undefined ? undefined : Date.now() - latest.ts > STALE_AFTER_MS + const priceClass = [ + styles.price, + flashDir === 'up' + ? styles.flashUp + : flashDir === 'down' + ? styles.flashDown + : '', + ] + .filter(Boolean) + .join(' ') + return (
- {fmtPrice(priceNum)} + + {fmtPrice(priceNum)} + USD
-
- -
) } @@ -83,10 +70,22 @@ function ChangeRow({ label: string pct: number | null }): React.ReactElement { + const isUp = pct !== null && pct >= 0 return (
{label} - {fmtPct(pct)} + + {pct === null ? ( + '—' + ) : ( + <> + + {isUp ? '△' : '▽'} + + {` ${Math.abs(pct).toFixed(PERCENT_DECIMALS)} %`} + + )} +
) } @@ -107,12 +106,6 @@ function computeChangePct(closes: number[]): number | null { return ((last - first) / first) * 100 } -function fmtPct(pct: number | null): string { - if (pct === null) return '—' - const glyph = pct >= 0 ? '△' : '▽' - return `${glyph} ${Math.abs(pct).toFixed(PERCENT_DECIMALS)} %` -} - function fmtPrice(n: number | null): string { if (n === null || !Number.isFinite(n)) return '—' return n.toLocaleString(undefined, { diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/panels/EarthquakePanel.tsx b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/panels/EarthquakePanel.tsx index 95955a7e..d8d505c2 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/panels/EarthquakePanel.tsx +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/panels/EarthquakePanel.tsx @@ -3,11 +3,13 @@ import { useEffect, useRef, useState } from 'react' import { useEarthquakeData } from '@/api/hooks' -import { Panel } from './Panel' +import { useFreshness } from '@/stores/freshness' import styles from './EarthquakePanel.module.scss' +import { Panel } from './Panel' const QUAKE_ROW_LIMIT = 8 const FLASH_DURATION_MS = 600 +const STALE_AFTER_MS = 600_000 const MS_PER_HOUR = 3_600_000 const MS_PER_MINUTE = 60_000 const HOURS_PER_DAY = 24 @@ -38,12 +40,23 @@ export function EarthquakePanel(): React.ReactElement { const recent = items.slice(0, QUAKE_ROW_LIMIT) const now = Date.now() + const lastTickAt = useFreshness((s) => s.ts.quake) + const isStale = + items.length === 0 + ? undefined + : lastTickAt !== undefined && Date.now() - lastTickAt > STALE_AFTER_MS + return (
Source IP Reports TgtTag
{s.source} {fmtN(s.reports)} {fmtN(s.targets)}{renderTag(s)}
@@ -59,15 +72,10 @@ export function EarthquakePanel(): React.ReactElement { return ( - - + ) })} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/panels/ISSPanel.tsx b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/panels/ISSPanel.tsx index 37d691df..bf5b63db 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/panels/ISSPanel.tsx +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/panels/ISSPanel.tsx @@ -1,7 +1,7 @@ // ©AngelaMos | 2026 // ISSPanel.tsx -import { useSnapshot } from '@/api/snapshot' +import { useIssPosition } from '@/api/hooks' import styles from './ISSPanel.module.scss' import { Panel } from './Panel' @@ -9,18 +9,8 @@ const STALE_AFTER_MS = 30_000 const COORDS_DECIMALS = 2 const ALT_DECIMALS = 0 -interface IssPositionData { - latitude: number - longitude: number - altitude: number - velocity: number - timestamp: number - fetched_at?: string -} - export function ISSPanel(): React.ReactElement { - const { data } = useSnapshot() - const iss = data?.iss_position as IssPositionData | undefined + const iss = useIssPosition() const lastTickAt = iss?.fetched_at ? new Date(iss.fetched_at).getTime() @@ -34,6 +24,8 @@ export function ISSPanel(): React.ReactElement { s.items) - const push = useKevStore((s) => s.push) - - const seed = data?.kev_added as KevEntry | undefined - useEffect(() => { - if (seed?.cveID) push(seed) - }, [seed, push]) - + const { items } = useKevData() const recent = items.slice(0, KEV_ROW_LIMIT) + const lastTickAt = useFreshness((s) => s.ts.kev) + const isStale = + items.length === 0 + ? undefined + : lastTickAt !== undefined && Date.now() - lastTickAt > STALE_AFTER_MS + return (
{fmtMag(q.properties?.mag)} + {q.properties?.place ?? '—'} - {fmtAgo(q.properties?.time, now)} - {fmtAgo(q.properties?.time, now)}
diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/panels/OutagePanel.tsx b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/panels/OutagePanel.tsx index a10f4810..b4320d4e 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/panels/OutagePanel.tsx +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/panels/OutagePanel.tsx @@ -3,11 +3,14 @@ import { useEffect, useRef, useState } from 'react' import { useOutageData } from '@/api/hooks' -import { Panel } from './Panel' +import { useFreshness } from '@/stores/freshness' import styles from './OutagePanel.module.scss' +import { Panel } from './Panel' const OUTAGE_ROW_LIMIT = 6 +const REGIME_CC = 'IR' const FLASH_DURATION_MS = 600 +const STALE_AFTER_MS = 1_800_000 const MS_PER_HOUR = 3_600_000 const MS_PER_MINUTE = 60_000 const HOURS_PER_DAY = 24 @@ -38,12 +41,23 @@ export function OutagePanel(): React.ReactElement { const recent = items.slice(0, OUTAGE_ROW_LIMIT) const now = Date.now() + const lastTickAt = useFreshness((s) => s.ts.outage) + const isStale = + items.length === 0 + ? undefined + : lastTickAt !== undefined && Date.now() - lastTickAt > STALE_AFTER_MS + return (
@@ -64,7 +78,9 @@ export function OutagePanel(): React.ReactElement { className={styles.info} title={o.reason ?? o.outageType ?? ''} > - {fmtCause(o.reason, o.outageType)} + {isRegime(o.locations) + ? 'Regime 👎' + : fmtCause(o.reason, o.outageType)} @@ -79,6 +95,10 @@ export function OutagePanel(): React.ReactElement { OutagePanel.displayName = 'OutagePanel' +function isRegime(locations: string[] | undefined): boolean { + return locations?.[0] === REGIME_CC +} + function fmtCC(locations: string[] | undefined): string { if (!locations || locations.length === 0) return '—' const first = locations[0] ?? '—' @@ -90,8 +110,8 @@ function fmtCause( reason: string | undefined, outageType: string | undefined ): string { - if (reason && reason.trim()) return reason - if (outageType && outageType.trim()) return outageType + if (reason?.trim()) return reason + if (outageType?.trim()) return outageType return '—' } diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/panels/Panel.module.scss b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/panels/Panel.module.scss index 1187f67e..a1c0f7e4 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/panels/Panel.module.scss +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/panels/Panel.module.scss @@ -2,31 +2,66 @@ // Panel.module.scss .panel { + position: relative; display: flex; flex-direction: column; border-bottom: 1px solid var(--fg-4); min-height: 0; + padding-left: 2px; +} + +.panel::before { + content: ''; + position: absolute; + left: 0; + top: 0; + bottom: 0; + width: 2px; + background: var(--rule-strong); } .head { + position: relative; display: flex; align-items: center; justify-content: space-between; - height: 28px; - padding: 0 8px; + min-height: 34px; + padding: 5px 10px 5px 12px; background: var(--bg); - border-bottom: 1px solid var(--fg-4); + border-bottom: 1px solid var(--rule-strong); +} + +.titleBlock { + display: flex; + flex-direction: column; + gap: 1px; + min-width: 0; } .title { + font-family: var(--font-sans); font-size: var(--type-label); + font-weight: 500; letter-spacing: var(--letter-spacing-label); text-transform: uppercase; - color: var(--fg-2); + color: var(--fg-1); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } .subtitle { color: var(--fg-3); + font-weight: 400; +} + +.source { + font-family: var(--font-sans); + font-size: var(--type-meta); + letter-spacing: 0.04em; + color: var(--fg-3); + text-transform: lowercase; + font-weight: 400; } .meta { @@ -34,6 +69,7 @@ align-items: center; gap: 8px; color: var(--fg-3); + flex-shrink: 0; } .rawLink { @@ -58,3 +94,16 @@ padding: 4px 0; min-height: 0; } + +.accentBgp { &::before { background: var(--accent-bgp); } } +.accentOutage { &::before { background: var(--accent-outage); } } +.accentKev { &::before { background: var(--accent-kev); } } +.accentRansomware { &::before { background: var(--accent-ransomware); } } +.accentDshield { &::before { background: var(--accent-dshield); } } +.accentQuake { &::before { background: var(--accent-quake); } } +.accentIss { &::before { background: var(--accent-iss); } } +.accentSpacewx { &::before { background: var(--accent-spacewx); } } +.accentCve { &::before { background: var(--accent-cve); } } +.accentWiki { &::before { background: var(--accent-wiki); } } +.accentBtc { &::before { background: var(--accent-btc); } } +.accentEth { &::before { background: var(--accent-eth); } } diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/panels/Panel.tsx b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/panels/Panel.tsx index d9485b86..5d3a7cb4 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/panels/Panel.tsx +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/panels/Panel.tsx @@ -6,34 +6,74 @@ import { FiExternalLink } from 'react-icons/fi' import styles from './Panel.module.scss' import { StaleIndicator } from './shared/StaleIndicator' +export type PanelAccent = + | 'bgp' + | 'outage' + | 'kev' + | 'ransomware' + | 'dshield' + | 'quake' + | 'iss' + | 'spacewx' + | 'cve' + | 'wiki' + | 'btc' + | 'eth' + export interface PanelProps { title: string subtitle?: string + source?: string + accent?: PanelAccent rawHref?: string rawLabel?: string isStale?: boolean lastTickAt?: number + batch?: boolean children: ReactNode } export function Panel({ title, subtitle, + source, + accent, rawHref, rawLabel, isStale, lastTickAt, + batch, children, }: PanelProps): React.ReactElement { + const ACCENT_CLASS: Record = { + bgp: styles.accentBgp, + outage: styles.accentOutage, + kev: styles.accentKev, + ransomware: styles.accentRansomware, + dshield: styles.accentDshield, + quake: styles.accentQuake, + iss: styles.accentIss, + spacewx: styles.accentSpacewx, + cve: styles.accentCve, + wiki: styles.accentWiki, + btc: styles.accentBtc, + eth: styles.accentEth, + } + const accentClass = accent ? ACCENT_CLASS[accent] : '' + const panelClass = [styles.panel, accentClass].filter(Boolean).join(' ') + return ( -
+
- - {title} - {subtitle && · {subtitle}} + + + {title} + {subtitle && · {subtitle}} + + {source && {source}} - + {rawHref && ( s.items) - const push = useRansomwareStore((s) => s.push) - - const seed = data?.ransomware_victim as RansomwareVictim | undefined - useEffect(() => { - if (seed?.post_title) push(seed) - }, [seed, push]) - + const { items } = useRansomwareData() const seenKeys = useRef>(new Set()) const [flashKeys, setFlashKeys] = useState>(new Set()) @@ -52,12 +42,23 @@ export function RansomwarePanel(): React.ReactElement { const recent = items.slice(0, VICTIM_ROW_LIMIT) const now = Date.now() + const lastTickAt = useFreshness((s) => s.ts.ransomware) + const isStale = + items.length === 0 + ? undefined + : lastTickAt !== undefined && Date.now() - lastTickAt > STALE_AFTER_MS + return (
{fmtState(o.endDate)} {fmtAgo(o.startDate, now)}
diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/panels/SpaceWeatherPanel.tsx b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/panels/SpaceWeatherPanel.tsx index a17cedf2..9f5afec1 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/panels/SpaceWeatherPanel.tsx +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/panels/SpaceWeatherPanel.tsx @@ -1,7 +1,7 @@ // ©AngelaMos | 2026 // SpaceWeatherPanel.tsx -import { useSnapshot } from '@/api/snapshot' +import { useSpaceWeather } from '@/api/hooks' import { Panel } from './Panel' import styles from './SpaceWeatherPanel.module.scss' @@ -23,20 +23,8 @@ const DENSITY_DECIMALS = 2 const XRAY_FLUX_DECIMALS = 1 const XRAY_ELEVATED_PATTERN = /^[MX]/ -interface SpaceWeatherData { - kp?: number - bz_gsm?: number - speed_kms?: number - density?: number - xray_class?: string - xray_flux?: number - ts?: string - pushed?: number -} - export function SpaceWeatherPanel(): React.ReactElement { - const { data } = useSnapshot() - const sw = (data?.space_weather as SpaceWeatherData | undefined) ?? {} + const sw = useSpaceWeather() const lastTickAt = sw.ts ? new Date(sw.ts).getTime() : undefined const isStaleByTime = @@ -44,16 +32,17 @@ export function SpaceWeatherPanel(): React.ReactElement { const kpElevated = sw.kp !== undefined && sw.kp >= KP_ELEVATED_THRESHOLD const xrayElevated = sw.xray_class !== undefined && XRAY_ELEVATED_PATTERN.test(sw.xray_class) - const inAlarm = kpElevated || xrayElevated - return (
Kp Index diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/panels/WikiPanel.module.scss b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/panels/WikiPanel.module.scss new file mode 100644 index 00000000..2cd0b22e --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/panels/WikiPanel.module.scss @@ -0,0 +1,61 @@ +// ©AngelaMos | 2026 +// WikiPanel.module.scss + +.list { + padding: 0 8px 4px; +} + +.empty { + padding: var(--row-py) 0; + font-family: var(--font-mono); + font-size: var(--type-body); + color: var(--fg-3); +} + +.row { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 8px; + padding: var(--row-py) 0; + border-bottom: 1px solid var(--fg-4); + + &:last-child { + border-bottom: none; + } +} + +.headline { + font-size: var(--type-body); + color: var(--fg-1); + line-height: 1.35; + flex: 1; + min-width: 0; + overflow: hidden; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + + &[href] { + text-decoration: none; + + &:hover { + color: var(--fg-1); + text-decoration: underline; + text-underline-offset: 2px; + } + + &:focus-visible { + outline: 1px solid var(--fg-2); + outline-offset: 1px; + } + } +} + +.ts { + font-family: var(--font-mono); + font-size: var(--type-label); + color: var(--fg-3); + flex-shrink: 0; + letter-spacing: var(--letter-spacing-label); +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/panels/WikiPanel.tsx b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/panels/WikiPanel.tsx new file mode 100644 index 00000000..da1cdebe --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/panels/WikiPanel.tsx @@ -0,0 +1,72 @@ +// ©AngelaMos | 2026 +// WikiPanel.tsx + +import { useFreshness } from '@/stores/freshness' +import { useTicker } from '@/stores/ticker' +import { Panel } from './Panel' +import styles from './WikiPanel.module.scss' + +const MAX_ITEMS = 6 +const STALE_AFTER_MS = 3_600_000 +const MS_PER_SECOND = 1_000 +const SECONDS_PER_MINUTE = 60 +const SECONDS_PER_HOUR = 3_600 + +export function WikiPanel(): React.ReactElement { + const all = useTicker((s) => s.items) + const items = all + .filter((i) => i.source === 'Wikipedia') + .slice(-MAX_ITEMS) + .reverse() + + const lastTickAt = useFreshness((s) => s.ts.wiki) + const isStale = + items.length === 0 + ? undefined + : lastTickAt !== undefined && Date.now() - lastTickAt > STALE_AFTER_MS + + return ( + + + + ) +} + +WikiPanel.displayName = 'WikiPanel' + +function fmtRel(ts: number): string { + const diff = (Date.now() - ts) / MS_PER_SECOND + if (diff < SECONDS_PER_MINUTE) return `${Math.floor(diff)}s` + if (diff < SECONDS_PER_HOUR) return `${Math.floor(diff / SECONDS_PER_MINUTE)}m` + return `${Math.floor(diff / SECONDS_PER_HOUR)}h` +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/panels/shared/StaleIndicator.module.scss b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/panels/shared/StaleIndicator.module.scss index f2597471..e5fbeb3d 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/panels/shared/StaleIndicator.module.scss +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/panels/shared/StaleIndicator.module.scss @@ -12,10 +12,10 @@ background: var(--ok); } -.stale { +.batch { background: var(--amber); } -.unknown { - background: var(--fg-4); +.stale { + background: var(--fg-3); } diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/panels/shared/StaleIndicator.tsx b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/panels/shared/StaleIndicator.tsx index 7b981905..164866ba 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/panels/shared/StaleIndicator.tsx +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/panels/shared/StaleIndicator.tsx @@ -6,18 +6,23 @@ import styles from './StaleIndicator.module.scss' export interface StaleIndicatorProps { stale?: boolean lastTickAt?: number + batch?: boolean } export function StaleIndicator({ stale, lastTickAt, + batch, }: StaleIndicatorProps): React.ReactElement { - const className = - stale === undefined - ? `${styles.dot} ${styles.unknown}` - : stale - ? `${styles.dot} ${styles.stale}` - : `${styles.dot} ${styles.live}` + let state: 'live' | 'batch' | 'stale' + if (stale === undefined || stale === true) { + state = 'stale' + } else if (batch) { + state = 'batch' + } else { + state = 'live' + } + const className = `${styles.dot} ${styles[state]}` const title = lastTickAt ? `Last update ${new Date(lastTickAt).toISOString().slice(11, 19)} UTC` diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/register/index.tsx b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/register/index.tsx index f0f003f7..ec7fd310 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/register/index.tsx +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/register/index.tsx @@ -4,6 +4,7 @@ */ import { useState } from 'react' +import { FiArrowLeft } from 'react-icons/fi' import { LuEye, LuEyeOff } from 'react-icons/lu' import { Link, useNavigate } from 'react-router-dom' import { toast } from 'sonner' @@ -35,6 +36,7 @@ export function Component(): React.ReactElement { const { registerEmail, setRegisterEmail, clearRegisterForm } = useAuthFormStore() + const [name, setName] = useState('') const [password, setPassword] = useState('') const [confirmPassword, setConfirmPassword] = useState('') const [showPassword, setShowPassword] = useState(false) @@ -45,6 +47,7 @@ export function Component(): React.ReactElement { const result = registerFormSchema.safeParse({ email: registerEmail, + name, password, confirmPassword, }) @@ -56,11 +59,14 @@ export function Component(): React.ReactElement { } register.mutate( - { email: result.data.email, password: result.data.password }, + { + email: result.data.email, + name: result.data.name, + password: result.data.password, + }, { onSuccess: () => { clearRegisterForm() - toast.success('Account created successfully') navigate(ROUTES.LOGIN) }, } @@ -69,6 +75,15 @@ export function Component(): React.ReactElement { return (
+

Sign up

@@ -76,6 +91,20 @@ export function Component(): React.ReactElement {
+
+ + setName(e.target.value)} + autoComplete="name" + /> +
+