first commit

This commit is contained in:
2026-08-25 06:37:14 +01:00
commit 5c3e72b40e
26 changed files with 6301 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
.claude/
tests
/cryptomon
+235
View File
@@ -0,0 +1,235 @@
GNU AFFERO GENERAL PUBLIC LICENSE
Version 3, 19 November 2007
Copyright (C) 2007 Free Software Foundation, Inc. <http://fsf.org/>
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.
crowdsec-dashy
Copyright (C) 2026 nahakubuilder
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 <http://www.gnu.org/licenses/>.
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 <http://www.gnu.org/licenses/>.
+91
View File
@@ -0,0 +1,91 @@
# Cryptomon
A self-hosted, single-binary crypto portfolio dashboard backed by Kraken.
Read-only: it tracks your holdings, cost cryptomon, and P&L — it never places
trades or moves funds.
## Features
- Live portfolio value and P&L, updating in real time from Kraken's
websocket ticker, visible in the top bar on every page.
- Per-position detail (holdings, cost cryptomon, current value) and a
transfers view.
- Auto-imports trades and balances from Kraken; supports manual ledger
entries for anything bought elsewhere.
- Single-user authentication: username/password + optional TOTP-based MFA
(with QR code setup), all served over your own network.
- Kraken API credentials are encrypted at rest.
## Requirements
- Go 1.26+ (to build from source)
- A Kraken account and API key with **read-only** permissions
(Query Funds, Query Closed Orders & Trades — no withdrawal or trading
rights needed)
## Setup
Build the binary:
```sh
go build -o cryptomon .
```
Run it:
```sh
./cryptomon
```
On first run it creates a `data/` directory next to the binary containing:
- `settings.conf` — port, poll intervals, base currency, and a random
encryption key used to encrypt your Kraken API secret at rest
- `app.db` — SQLite database (portfolio data, login, MFA state)
By default the server listens on `:8080` — open `http://localhost:8080`.
### First login
The default login is `admin` / `admin`. On first sign-in you'll be
required to set your own username and password. From there, MFA
(authenticator app via TOTP, with QR code setup) can optionally be
enabled from the Account page.
If you forget your credentials, run:
```sh
./cryptomon -userreset
```
This resets the login back to `admin` / `admin` and disables MFA. You'll
be asked to set a new username/password on next sign-in.
### Connect Kraken
Go to **Settings** and enter a Kraken API key/secret with read-only
permissions. Balances and trade history will sync automatically on the
configured poll interval.
## Configuration
`data/settings.conf` is a plain `key=value` file, auto-created with
defaults on first run:
| Key | Default | Meaning |
|---|---|---|
| `port` | `8080` | HTTP listen port |
| `poll_interval_seconds` | `60` | How often balances/trades/prices refresh |
| `history_refresh_seconds` | `300` | How often historical price deltas refresh |
| `db_path` | `data/app.db` | Path to the SQLite database |
| `base_currency` | `GBP` | Currency all values are displayed in (also changeable from Settings) |
| `app_secret` | *(random, generated once)* | AES-256 key encrypting Kraken credentials at rest — do not share or edit |
Edit `port` and restart to change the listen address; the rest can also
be changed from the web UI where applicable.
---
## License
See [LICENSE](LICENSE).
+20
View File
@@ -0,0 +1,20 @@
module cryptomon
go 1.26.6
require (
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
modernc.org/sqlite v1.57.0
)
require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/mattn/go-isatty v0.0.24 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
golang.org/x/sys v0.47.0 // indirect
modernc.org/libc v1.74.4 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
)
+52
View File
@@ -0,0 +1,52 @@
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo=
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0=
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M=
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
modernc.org/cc/v4 v4.29.1 h1:MKgdCV3WykTSPqpVrnxdEDS0HEd2FHpKZDzxzU5LyeI=
modernc.org/cc/v4 v4.29.1/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
modernc.org/ccgo/v4 v4.34.6 h1:sBgfIwyN0TQ9C5hwIeuqyeAKyMWnbvj2fvpF4L11uzU=
modernc.org/ccgo/v4 v4.34.6/go.mod h1:SZ8YcN9NG7XVsQYdm6jYBvi8PQP1qi+kqB6OhjqI3Fk=
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.1.4 h1:2g65LGVSmFQrXeITAw97x7hCRvZFcyE1uDP+7Vng7JI=
modernc.org/gc/v3 v3.1.4/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.74.4 h1:fX1Omw4o2/1C2iRkkIsrQTasJQldLhRmuPreXLoWs9k=
modernc.org/libc v1.74.4/go.mod h1:eeQAS9W3sZeKYMFubydxJpII9ybHWshk+7or7bLG9co=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.57.0 h1:qNQP6xnx5M0ISNtlnxoOX0+cD5bJ0/gr9aMmndFczzg=
modernc.org/sqlite v1.57.0/go.mod h1:yCJ2cmAaIkHQ25oXWrF8H4O1lIfPYPR26yCEDj2P3pQ=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
+200
View File
@@ -0,0 +1,200 @@
// Package auth provides the stdlib-only crypto primitives behind login:
// PBKDF2-HMAC-SHA256 password hashing, RFC 6238 TOTP for MFA, and
// HMAC-signed session tokens. It has no knowledge of the database or HTTP —
// see internal/authsvc for the service that wires these into the app.
package auth
import (
"crypto/hmac"
"crypto/rand"
"crypto/sha1"
"crypto/sha256"
"crypto/subtle"
"encoding/base32"
"encoding/base64"
"encoding/binary"
"encoding/hex"
"fmt"
"strconv"
"strings"
"time"
)
const pbkdf2Iterations = 210000
// HashPassword returns a self-describing hash string for storage:
// "pbkdf2$<iterations>$<saltHex>$<hashHex>".
func HashPassword(password string) (string, error) {
salt := make([]byte, 16)
if _, err := rand.Read(salt); err != nil {
return "", err
}
hash := pbkdf2(password, salt, pbkdf2Iterations, 32)
return fmt.Sprintf("pbkdf2$%d$%s$%s", pbkdf2Iterations, hex.EncodeToString(salt), hex.EncodeToString(hash)), nil
}
// VerifyPassword checks password against a hash produced by HashPassword.
func VerifyPassword(password, encoded string) bool {
parts := strings.Split(encoded, "$")
if len(parts) != 4 || parts[0] != "pbkdf2" {
return false
}
iterations, err := strconv.Atoi(parts[1])
if err != nil || iterations <= 0 {
return false
}
salt, err := hex.DecodeString(parts[2])
if err != nil {
return false
}
want, err := hex.DecodeString(parts[3])
if err != nil {
return false
}
got := pbkdf2(password, salt, iterations, len(want))
return subtle.ConstantTimeCompare(got, want) == 1
}
// pbkdf2 implements RFC 2898 PBKDF2-HMAC-SHA256.
func pbkdf2(password string, salt []byte, iterations, keyLen int) []byte {
prf := hmac.New(sha256.New, []byte(password))
hashLen := prf.Size()
numBlocks := (keyLen + hashLen - 1) / hashLen
dk := make([]byte, 0, numBlocks*hashLen)
var blockIndex [4]byte
for block := 1; block <= numBlocks; block++ {
prf.Reset()
prf.Write(salt)
binary.BigEndian.PutUint32(blockIndex[:], uint32(block))
prf.Write(blockIndex[:])
u := prf.Sum(nil)
t := append([]byte(nil), u...)
for i := 1; i < iterations; i++ {
prf.Reset()
prf.Write(u)
u = prf.Sum(nil)
for j := range t {
t[j] ^= u[j]
}
}
dk = append(dk, t...)
}
return dk[:keyLen]
}
// --- TOTP (RFC 6238) / HOTP (RFC 4226): HMAC-SHA1, 6 digits, 30s step. ---
// SHA1 is dictated by the TOTP standard that every authenticator app
// implements — it's not a general-purpose choice, just the protocol's.
const (
totpStep = 30
totpDigits = 6
)
var base32Enc = base32.StdEncoding.WithPadding(base32.NoPadding)
// NewTOTPSecret generates a random 160-bit secret, base32-encoded for
// display/entry into an authenticator app.
func NewTOTPSecret() (string, error) {
raw := make([]byte, 20)
if _, err := rand.Read(raw); err != nil {
return "", err
}
return base32Enc.EncodeToString(raw), nil
}
// OTPAuthURI builds the otpauth:// URI most authenticator apps accept for
// manual key entry (no QR code — see the account page's hint text).
func OTPAuthURI(issuer, account, secretBase32 string) string {
return fmt.Sprintf("otpauth://totp/%s:%s?secret=%s&issuer=%s&digits=%d&period=%d",
issuer, account, secretBase32, issuer, totpDigits, totpStep)
}
func totpAt(secretBase32 string, t time.Time) (string, error) {
secret, err := base32Enc.DecodeString(strings.ToUpper(strings.TrimSpace(secretBase32)))
if err != nil {
return "", err
}
counter := uint64(t.Unix() / totpStep)
return hotp(secret, counter, totpDigits), nil
}
// ValidateTOTP checks code against the current 30s step, tolerating one
// step of clock drift either side.
func ValidateTOTP(secretBase32, code string) bool {
code = strings.TrimSpace(code)
if code == "" {
return false
}
now := time.Now()
for _, skew := range [3]time.Duration{0, -totpStep * time.Second, totpStep * time.Second} {
want, err := totpAt(secretBase32, now.Add(skew))
if err == nil && subtle.ConstantTimeCompare([]byte(want), []byte(code)) == 1 {
return true
}
}
return false
}
func hotp(secret []byte, counter uint64, digits int) string {
var buf [8]byte
binary.BigEndian.PutUint64(buf[:], counter)
mac := hmac.New(sha1.New, secret)
mac.Write(buf[:])
sum := mac.Sum(nil)
offset := sum[len(sum)-1] & 0x0f
code := (uint32(sum[offset])&0x7f)<<24 | uint32(sum[offset+1])<<16 | uint32(sum[offset+2])<<8 | uint32(sum[offset+3])
mod := uint32(1)
for i := 0; i < digits; i++ {
mod *= 10
}
return fmt.Sprintf("%0*d", digits, code%mod)
}
// --- Session tokens: stateless, HMAC-signed "username|version|expiry". ---
// version lets a password change/reset invalidate every issued token at
// once, just by bumping the stored session_version.
func NewSessionToken(key []byte, username string, version int, ttl time.Duration) string {
expiry := time.Now().Add(ttl).Unix()
payload := fmt.Sprintf("%s|%d|%d", username, version, expiry)
mac := hmac.New(sha256.New, key)
mac.Write([]byte(payload))
return base64.RawURLEncoding.EncodeToString([]byte(payload)) + "." + base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
}
// ParseSessionToken verifies the signature and expiry, returning the
// embedded username/version. The caller must still check these against the
// current stored auth record (see authsvc.Service.CheckSession).
func ParseSessionToken(key []byte, token string) (username string, version int, ok bool) {
payloadB64, sigB64, found := strings.Cut(token, ".")
if !found {
return "", 0, false
}
payload, err := base64.RawURLEncoding.DecodeString(payloadB64)
if err != nil {
return "", 0, false
}
gotSig, err := base64.RawURLEncoding.DecodeString(sigB64)
if err != nil {
return "", 0, false
}
mac := hmac.New(sha256.New, key)
mac.Write(payload)
if !hmac.Equal(gotSig, mac.Sum(nil)) {
return "", 0, false
}
parts := strings.SplitN(string(payload), "|", 3)
if len(parts) != 3 {
return "", 0, false
}
v, err := strconv.Atoi(parts[1])
if err != nil {
return "", 0, false
}
expiry, err := strconv.ParseInt(parts[2], 10, 64)
if err != nil || time.Now().Unix() > expiry {
return "", 0, false
}
return parts[0], v, true
}
+81
View File
@@ -0,0 +1,81 @@
package auth
import (
"encoding/hex"
"testing"
"time"
)
// Known-answer test for the hand-rolled PBKDF2-HMAC-SHA256 core, so a typo
// in the loop (block index, XOR fold, etc.) fails loudly instead of just
// producing a hash that happens to round-trip against itself.
func TestPBKDF2Vector(t *testing.T) {
got := pbkdf2("password", []byte("salt"), 1, 32)
want, _ := hex.DecodeString("120fb6cffcf8b32c43e7225256c4f837a86548c92ccc35480805987cb70be17b")
if hex.EncodeToString(got) != hex.EncodeToString(want) {
t.Fatalf("pbkdf2(password,salt,1,32) = %x, want %x", got, want)
}
}
func TestHashAndVerifyPassword(t *testing.T) {
hash, err := HashPassword("correct horse battery staple")
if err != nil {
t.Fatal(err)
}
if !VerifyPassword("correct horse battery staple", hash) {
t.Fatal("VerifyPassword rejected the correct password")
}
if VerifyPassword("wrong password", hash) {
t.Fatal("VerifyPassword accepted the wrong password")
}
}
// RFC 4226 Appendix D HOTP test vectors (secret "12345678901234567890",
// 6-digit truncation) — TOTP is just HOTP with counter = unixtime/step, so
// this pins down the shared HMAC/truncation core.
func TestHOTPVectors(t *testing.T) {
secret := []byte("12345678901234567890")
want := []string{
"755224", "287082", "359152", "969429", "338314",
"254676", "287922", "162583", "399871", "520489",
}
for counter, code := range want {
got := hotp(secret, uint64(counter), 6)
if got != code {
t.Fatalf("hotp(counter=%d) = %s, want %s", counter, got, code)
}
}
}
func TestValidateTOTPRoundtrip(t *testing.T) {
secret, err := NewTOTPSecret()
if err != nil {
t.Fatal(err)
}
code, err := totpAt(secret, time.Now())
if err != nil {
t.Fatal(err)
}
if !ValidateTOTP(secret, code) {
t.Fatal("ValidateTOTP rejected a freshly generated code")
}
if ValidateTOTP(secret, "000000") {
t.Fatal("ValidateTOTP accepted an arbitrary wrong code")
}
}
func TestSessionTokenRoundtrip(t *testing.T) {
key := []byte("0123456789abcdef0123456789abcdef")
tok := NewSessionToken(key, "admin", 3, time.Hour)
user, version, ok := ParseSessionToken(key, tok)
if !ok || user != "admin" || version != 3 {
t.Fatalf("roundtrip failed: user=%q version=%d ok=%v", user, version, ok)
}
if _, _, ok := ParseSessionToken(key, tok+"tampered"); ok {
t.Fatal("ParseSessionToken accepted a tampered token")
}
otherKey := []byte("ffffffffffffffffffffffffffffffff")
if _, _, ok := ParseSessionToken(otherKey, tok); ok {
t.Fatal("ParseSessionToken accepted a token signed with a different key")
}
}
+252
View File
@@ -0,0 +1,252 @@
// Package authsvc wires internal/auth's stdlib crypto primitives into the
// app's single-user login: default admin/admin credentials, forced
// first-login password change, optional TOTP MFA, and stateless signed
// session tokens keyed by the same app_secret used for exchange credentials.
package authsvc
import (
"errors"
"fmt"
"regexp"
"time"
"cryptomon/internal/auth"
"cryptomon/internal/config"
"cryptomon/internal/crypto"
"cryptomon/internal/store"
)
const (
DefaultUsername = "admin"
DefaultPassword = "admin"
issuer = "Basis"
// SessionTTL is how long an issued session cookie stays valid.
SessionTTL = 30 * 24 * time.Hour
MinPasswordLen = 8
)
var usernameRe = regexp.MustCompile(`^[A-Za-z0-9_.-]{3,32}$`)
var (
ErrInvalidUsername = fmt.Errorf("username must be 3-32 characters: letters, numbers, dot, underscore, or hyphen")
ErrPasswordTooShort = fmt.Errorf("password must be at least %d characters", MinPasswordLen)
ErrPasswordMismatch = errors.New("new password and confirmation do not match")
)
type Service struct {
store *store.Store
cfg *config.Config
}
func New(st *store.Store, cfg *config.Config) *Service {
return &Service{store: st, cfg: cfg}
}
// EnsureDefault seeds the admin/admin login on first run. Safe to call every
// startup — it's a no-op once a row exists.
func (s *Service) EnsureDefault() error {
hash, err := auth.HashPassword(DefaultPassword)
if err != nil {
return err
}
return s.store.EnsureAuth(DefaultUsername, hash)
}
// ResetToDefault restores admin/admin, forces a password change on next
// login, and disables MFA. Used by the `-userreset` CLI flag.
func (s *Service) ResetToDefault() error {
hash, err := auth.HashPassword(DefaultPassword)
if err != nil {
return err
}
return s.store.ResetAuth(DefaultUsername, hash)
}
// LoginStatus reports the current user's must-change/session-version/MFA
// state, used right after a password check to decide the login's next step.
func (s *Service) LoginStatus() (mustChange bool, sessionVersion int, mfaEnabled bool, err error) {
rec, err := s.store.GetAuth()
if err != nil {
return false, 0, false, err
}
return rec.MustChangePassword, rec.SessionVersion, rec.MFAEnabled, nil
}
// pendingMFAVersion is an impossible session_version (real ones start at 1
// and only increase), so a pending-MFA token can never pass CheckSession and
// be used as a real session cookie even if it leaked.
const pendingMFAVersion = -1
const pendingMFATTL = 5 * time.Minute
// NewPendingMFAToken issues a short-lived token proving username+password
// were already verified, so the login page's MFA modal doesn't need the
// password resent alongside the code.
func (s *Service) NewPendingMFAToken(username string) string {
return auth.NewSessionToken(s.cfg.AppSecret, username, pendingMFAVersion, pendingMFATTL)
}
// CompleteMFALogin verifies a pending-MFA token plus TOTP code, returning
// the session data needed to issue the real login cookie.
func (s *Service) CompleteMFALogin(pendingToken, code string) (ok bool, username string, mustChange bool, sessionVersion int, err error) {
user, version, valid := auth.ParseSessionToken(s.cfg.AppSecret, pendingToken)
if !valid || version != pendingMFAVersion {
return false, "", false, 0, nil
}
rec, err := s.store.GetAuth()
if err != nil {
return false, "", false, 0, err
}
if rec.Username != user || !rec.MFAEnabled {
return false, "", false, 0, nil
}
secret, decErr := crypto.Decrypt(s.cfg.AppSecret, rec.MFASecretEnc)
if decErr != nil || !auth.ValidateTOTP(secret, code) {
return false, "", false, 0, nil
}
return true, rec.Username, rec.MustChangePassword, rec.SessionVersion, nil
}
// VerifyPassword checks password alone (no MFA), for re-authenticating
// before a credentials change — the user is already inside an authenticated
// session, so requiring a fresh TOTP code too would be redundant friction.
func (s *Service) VerifyPassword(username, password string) (bool, error) {
rec, err := s.store.GetAuth()
if err != nil {
return false, err
}
return rec.Username == username && auth.VerifyPassword(password, rec.PasswordHash), nil
}
// SetCredentials updates username/password (used both for the forced
// first-login setup and later changes), clearing must-change and bumping
// the session version so every other signed-in cookie is invalidated. It
// returns the new session_version so the caller can issue a fresh cookie.
func (s *Service) SetCredentials(username, newPassword, confirmPassword string) (int, error) {
if !usernameRe.MatchString(username) {
return 0, ErrInvalidUsername
}
if len(newPassword) < MinPasswordLen {
return 0, ErrPasswordTooShort
}
if newPassword != confirmPassword {
return 0, ErrPasswordMismatch
}
hash, err := auth.HashPassword(newPassword)
if err != nil {
return 0, err
}
if err := s.store.SetAuthCredentials(username, hash); err != nil {
return 0, err
}
rec, err := s.store.GetAuth()
if err != nil {
return 0, err
}
return rec.SessionVersion, nil
}
func (s *Service) Username() (string, error) {
rec, err := s.store.GetAuth()
if err != nil {
return "", err
}
return rec.Username, nil
}
func (s *Service) MustChangePassword() (bool, error) {
rec, err := s.store.GetAuth()
if err != nil {
return false, err
}
return rec.MustChangePassword, nil
}
func (s *Service) MFAEnabled() (bool, error) {
rec, err := s.store.GetAuth()
if err != nil {
return false, err
}
return rec.MFAEnabled, nil
}
// BeginMFA generates a new TOTP secret and stores it (encrypted) as pending
// until confirmed with a code. Calling it again before confirming replaces
// the pending secret.
func (s *Service) BeginMFA() (secretBase32, otpauthURI string, err error) {
rec, err := s.store.GetAuth()
if err != nil {
return "", "", err
}
secret, err := auth.NewTOTPSecret()
if err != nil {
return "", "", err
}
secretEnc, err := crypto.Encrypt(s.cfg.AppSecret, secret)
if err != nil {
return "", "", err
}
if err := s.store.SetMFAPending(secretEnc); err != nil {
return "", "", err
}
return secret, auth.OTPAuthURI(issuer, rec.Username, secret), nil
}
// PendingMFASecret returns the not-yet-confirmed secret (for redisplay if
// the account page reloads mid-setup), or ok=false if there is none.
func (s *Service) PendingMFASecret() (secretBase32, otpauthURI string, ok bool, err error) {
rec, err := s.store.GetAuth()
if err != nil {
return "", "", false, err
}
if rec.MFASecretEnc == "" || rec.MFAEnabled {
return "", "", false, nil
}
secret, err := crypto.Decrypt(s.cfg.AppSecret, rec.MFASecretEnc)
if err != nil {
return "", "", false, err
}
return secret, auth.OTPAuthURI(issuer, rec.Username, secret), true, nil
}
func (s *Service) ConfirmMFA(code string) error {
secret, _, ok, err := s.PendingMFASecret()
if err != nil {
return err
}
if !ok {
return errors.New("no MFA setup in progress")
}
if !auth.ValidateTOTP(secret, code) {
return errors.New("invalid code")
}
return s.store.ConfirmMFA()
}
func (s *Service) DisableMFA() error {
return s.store.DisableMFA()
}
// NewSession issues a signed session token for the given username/version.
func (s *Service) NewSession(username string, version int) string {
return auth.NewSessionToken(s.cfg.AppSecret, username, version, SessionTTL)
}
// CheckSession verifies token's signature/expiry and that it still matches
// the current stored username/session_version (so a password change or
// -userreset immediately invalidates every previously issued cookie).
func (s *Service) CheckSession(token string) (valid, mustChange bool, err error) {
username, version, ok := auth.ParseSessionToken(s.cfg.AppSecret, token)
if !ok {
return false, false, nil
}
rec, err := s.store.GetAuth()
if err != nil {
return false, false, err
}
if rec.Username != username || rec.SessionVersion != version {
return false, false, nil
}
return true, rec.MustChangePassword, nil
}
+143
View File
@@ -0,0 +1,143 @@
// Package config reads and writes data/settings.conf, a plain key=value
// file holding non-secret app settings plus the AES key (app_secret) used
// to encrypt exchange credentials stored in the database.
package config
import (
"bufio"
"crypto/rand"
"encoding/hex"
"fmt"
"os"
"strconv"
"strings"
"sync"
)
type Config struct {
Port int
PollIntervalSeconds int
HistoryRefreshSeconds int
DBPath string
AppSecret []byte // 32 bytes, AES-256 key
path string
mu sync.RWMutex
baseCurrency string // guarded by mu: changeable at runtime via the settings page
}
func defaults() *Config {
return &Config{
Port: 8080,
PollIntervalSeconds: 60,
HistoryRefreshSeconds: 300,
DBPath: "data/app.db",
baseCurrency: "GBP",
}
}
// Load reads path, creating it with defaults (and a fresh random app_secret)
// if it doesn't exist yet.
func Load(path string) (*Config, error) {
cfg := defaults()
cfg.path = path
f, err := os.Open(path)
if os.IsNotExist(err) {
secret := make([]byte, 32)
if _, err := rand.Read(secret); err != nil {
return nil, fmt.Errorf("generate app_secret: %w", err)
}
cfg.AppSecret = secret
if err := cfg.save(); err != nil {
return nil, err
}
return cfg, nil
}
if err != nil {
return nil, err
}
defer f.Close()
values := map[string]string{}
scanner := bufio.NewScanner(f)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}
k, v, ok := strings.Cut(line, "=")
if !ok {
continue
}
values[strings.TrimSpace(k)] = strings.TrimSpace(v)
}
if err := scanner.Err(); err != nil {
return nil, err
}
if v, ok := values["port"]; ok {
if n, err := strconv.Atoi(v); err == nil {
cfg.Port = n
}
}
if v, ok := values["poll_interval_seconds"]; ok {
if n, err := strconv.Atoi(v); err == nil {
cfg.PollIntervalSeconds = n
}
}
if v, ok := values["history_refresh_seconds"]; ok {
if n, err := strconv.Atoi(v); err == nil {
cfg.HistoryRefreshSeconds = n
}
}
if v, ok := values["db_path"]; ok && v != "" {
cfg.DBPath = v
}
if v, ok := values["base_currency"]; ok && v != "" {
cfg.baseCurrency = strings.ToUpper(v)
}
secretHex, ok := values["app_secret"]
if !ok || secretHex == "" {
return nil, fmt.Errorf("settings.conf missing app_secret")
}
secret, err := hex.DecodeString(secretHex)
if err != nil || len(secret) != 32 {
return nil, fmt.Errorf("settings.conf app_secret must be 32 random bytes hex-encoded")
}
cfg.AppSecret = secret
return cfg, nil
}
// BaseCurrency is the fiat currency (e.g. "GBP") all values are shown in.
func (c *Config) BaseCurrency() string {
c.mu.RLock()
defer c.mu.RUnlock()
return c.baseCurrency
}
// SetBaseCurrency updates and persists the base currency.
func (c *Config) SetBaseCurrency(v string) error {
v = strings.ToUpper(strings.TrimSpace(v))
if v == "" {
return fmt.Errorf("base currency required")
}
c.mu.Lock()
defer c.mu.Unlock()
c.baseCurrency = v
return c.save()
}
// save must be called with mu held (directly or via the zero-value path
// during Load, before the Config is shared across goroutines).
func (c *Config) save() error {
var b strings.Builder
fmt.Fprintf(&b, "port=%d\n", c.Port)
fmt.Fprintf(&b, "poll_interval_seconds=%d\n", c.PollIntervalSeconds)
fmt.Fprintf(&b, "history_refresh_seconds=%d\n", c.HistoryRefreshSeconds)
fmt.Fprintf(&b, "db_path=%s\n", c.DBPath)
fmt.Fprintf(&b, "base_currency=%s\n", c.baseCurrency)
fmt.Fprintf(&b, "app_secret=%s\n", hex.EncodeToString(c.AppSecret))
return os.WriteFile(c.path, []byte(b.String()), 0600)
}
+55
View File
@@ -0,0 +1,55 @@
// Package crypto provides AES-256-GCM encrypt/decrypt for exchange
// credentials stored in the database, keyed by the app_secret from
// data/settings.conf (see internal/config).
package crypto
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"encoding/base64"
"fmt"
)
// Encrypt returns nonce||ciphertext, base64-encoded, for storage in a DB column.
func Encrypt(key []byte, plaintext string) (string, error) {
block, err := aes.NewCipher(key)
if err != nil {
return "", err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return "", err
}
nonce := make([]byte, gcm.NonceSize())
if _, err := rand.Read(nonce); err != nil {
return "", err
}
sealed := gcm.Seal(nonce, nonce, []byte(plaintext), nil)
return base64.StdEncoding.EncodeToString(sealed), nil
}
// Decrypt reverses Encrypt.
func Decrypt(key []byte, encoded string) (string, error) {
raw, err := base64.StdEncoding.DecodeString(encoded)
if err != nil {
return "", err
}
block, err := aes.NewCipher(key)
if err != nil {
return "", err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return "", err
}
if len(raw) < gcm.NonceSize() {
return "", fmt.Errorf("ciphertext too short")
}
nonce, ciphertext := raw[:gcm.NonceSize()], raw[gcm.NonceSize():]
plaintext, err := gcm.Open(nil, nonce, ciphertext, nil)
if err != nil {
return "", err
}
return string(plaintext), nil
}
+538
View File
@@ -0,0 +1,538 @@
// Package kraken is a minimal client for the parts of the Kraken REST API
// this app needs: public ticker/OHLC price data, and private balance/trade
// history for auto-importing purchase lots. No SDK — plain net/http plus
// stdlib crypto for request signing, per Kraken's documented spec.
package kraken
import (
"crypto/hmac"
"crypto/sha256"
"crypto/sha512"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"sort"
"strconv"
"strings"
"time"
)
const baseURL = "https://api.kraken.com"
// fiatCurrencies are Kraken's supported cash currencies. Balance/Ledger
// entries for these represent cash sitting in the account, not a crypto
// position — callers exclude them from portfolio tracking.
var fiatCurrencies = map[string]bool{
"USD": true, "EUR": true, "GBP": true, "JPY": true,
"CHF": true, "CAD": true, "AUD": true,
}
// IsFiat reports whether altname (as returned by AssetAltName) is one of
// Kraken's cash currencies rather than a crypto asset.
func IsFiat(altname string) bool { return fiatCurrencies[altname] }
type Client struct {
apiKey string
apiSecret string // base64-encoded, as issued by Kraken
http *http.Client
pairs map[string]pairInfo // keyed by Kraken's pair name, e.g. XXBTZUSD
assets map[string]string // asset code -> altname, e.g. XXBT -> XBT
}
type pairInfo struct {
AltName string `json:"altname"`
Base string `json:"base"`
Quote string `json:"quote"`
}
func New(apiKey, apiSecret string) *Client {
return &Client{
apiKey: apiKey,
apiSecret: apiSecret,
http: &http.Client{Timeout: 15 * time.Second},
}
}
type envelope struct {
Error []string `json:"error"`
Result json.RawMessage `json:"result"`
}
func (c *Client) doPublic(path string, params url.Values) (json.RawMessage, error) {
u := baseURL + path
if params != nil {
u += "?" + params.Encode()
}
resp, err := c.http.Get(u)
if err != nil {
return nil, err
}
defer resp.Body.Close()
return decodeEnvelope(resp.Body)
}
func (c *Client) doPrivate(path string, params url.Values) (json.RawMessage, error) {
if c.apiKey == "" || c.apiSecret == "" {
return nil, fmt.Errorf("kraken: no API credentials configured")
}
if params == nil {
params = url.Values{}
}
nonce := strconv.FormatInt(time.Now().UnixNano()/int64(time.Millisecond), 10)
params.Set("nonce", nonce)
body := params.Encode()
sign, err := c.sign(path, nonce, body)
if err != nil {
return nil, err
}
req, err := http.NewRequest(http.MethodPost, baseURL+path, strings.NewReader(body))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("API-Key", c.apiKey)
req.Header.Set("API-Sign", sign)
resp, err := c.http.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
return decodeEnvelope(resp.Body)
}
func decodeEnvelope(r io.Reader) (json.RawMessage, error) {
var env envelope
if err := json.NewDecoder(r).Decode(&env); err != nil {
return nil, err
}
if len(env.Error) > 0 {
return nil, fmt.Errorf("kraken: %s", strings.Join(env.Error, "; "))
}
return env.Result, nil
}
// sign implements Kraken's documented API-Sign algorithm:
// HMAC-SHA512(path + SHA256(nonce + postdata), base64-decoded secret).
func (c *Client) sign(path, nonce, postData string) (string, error) {
secret, err := base64.StdEncoding.DecodeString(c.apiSecret)
if err != nil {
return "", fmt.Errorf("kraken: invalid api secret: %w", err)
}
shaSum := sha256.Sum256([]byte(nonce + postData))
mac := hmac.New(sha512.New, secret)
mac.Write([]byte(path))
mac.Write(shaSum[:])
return base64.StdEncoding.EncodeToString(mac.Sum(nil)), nil
}
// --- asset/pair metadata (loaded lazily, cached for process lifetime) ---
func (c *Client) loadPairs() error {
if c.pairs != nil {
return nil
}
raw, err := c.doPublic("/0/public/AssetPairs", nil)
if err != nil {
return err
}
var m map[string]pairInfo
if err := json.Unmarshal(raw, &m); err != nil {
return err
}
c.pairs = m
return nil
}
func (c *Client) loadAssets() error {
if c.assets != nil {
return nil
}
raw, err := c.doPublic("/0/public/Assets", nil)
if err != nil {
return err
}
var m map[string]struct {
AltName string `json:"altname"`
}
if err := json.Unmarshal(raw, &m); err != nil {
return err
}
out := map[string]string{}
for code, a := range m {
out[code] = a.AltName
}
c.assets = out
return nil
}
// AssetAltName resolves a raw Kraken asset code (e.g. "XXBT") to its
// human-friendly ticker (e.g. "XBT"). Falls back to the raw code.
func (c *Client) AssetAltName(code string) string {
if err := c.loadAssets(); err != nil {
return code
}
if alt, ok := c.assets[code]; ok {
return alt
}
return code
}
// PairAssets resolves a Kraken pair name (as returned by TradesHistory or
// stored on a purchase, e.g. "XXBTZUSD") to its base/quote altnames (e.g.
// "XBT", "USD"). Falls back to the raw pair name if it can't be resolved.
func (c *Client) PairAssets(pair string) (base, quote string) {
if err := c.loadPairs(); err != nil {
return pair, ""
}
if info, ok := c.pairs[pair]; ok {
return c.AssetAltName(info.Base), c.AssetAltName(info.Quote)
}
return pair, ""
}
// FindPairFor returns a Kraken pair (and its quote altname) trading
// baseAsset against a common fiat — useful for pricing a currency that
// wasn't acquired through a recorded buy/sell (e.g. a staking reward or
// airdrop that only shows up in the account balance). Prefers USD, then
// EUR, then GBP, then whatever pair is first found.
func (c *Client) FindPairFor(baseAsset string) (pair, quote string) {
if err := c.loadPairs(); err != nil {
return "", ""
}
preferred := []string{"USD", "EUR", "GBP"}
var fallbackPair, fallbackQuote string
for name, info := range c.pairs {
if c.AssetAltName(info.Base) != baseAsset {
continue
}
q := c.AssetAltName(info.Quote)
for _, p := range preferred {
if q == p {
return name, q
}
}
if fallbackPair == "" {
fallbackPair, fallbackQuote = name, q
}
}
return fallbackPair, fallbackQuote
}
// wsSymbolOverrides covers the handful of assets where Kraken's REST
// altname (used everywhere else in this app) differs from the ticker used
// on the WebSocket v2 API — confirmed live: XBT/USD is rejected, BTC/USD
// isn't; XDG/USD is rejected, DOGE/USD isn't. Everything else matches.
var wsSymbolOverrides = map[string]string{
"XBT": "BTC",
"XDG": "DOGE",
}
// WSSymbol returns the "BASE/QUOTE" symbol Kraken's public WebSocket v2
// ticker channel expects for a given altname pair.
func WSSymbol(currency, quote string) string {
if alt, ok := wsSymbolOverrides[currency]; ok {
currency = alt
}
return currency + "/" + quote
}
// WithCredentials returns a copy of c authenticated with the given API
// key/secret, sharing the same cached pair/asset metadata (maps are
// reference types, so the copy's cache stays warm).
func (c *Client) WithCredentials(apiKey, apiSecret string) *Client {
cp := *c
cp.apiKey = apiKey
cp.apiSecret = apiSecret
return &cp
}
// --- public: ticker ---
// Ticker returns the last traded price for one pair given as its altname
// (e.g. "XBTUSD"). Kraken keys the response by its internal pair name, not
// the altname requested, so we just take the single entry back.
func (c *Client) Ticker(altPair string) (float64, error) {
raw, err := c.doPublic("/0/public/Ticker", url.Values{"pair": {altPair}})
if err != nil {
return 0, err
}
var m map[string]struct {
C []string `json:"c"` // last trade closed [price, lot volume]
}
if err := json.Unmarshal(raw, &m); err != nil {
return 0, err
}
for _, v := range m {
if len(v.C) > 0 {
return strconv.ParseFloat(v.C[0], 64)
}
}
return 0, fmt.Errorf("kraken: no ticker data for %s", altPair)
}
// FXRate returns how many units of `to` one unit of `from` is worth, using
// Kraken's fiat-cross tickers. Tries the direct pair, then the inverse, then
// triangulates through USD if neither exists directly.
func (c *Client) FXRate(from, to string) (float64, error) {
if from == "" || to == "" || from == to {
return 1, nil
}
if r, err := c.Ticker(from + to); err == nil && r > 0 {
return r, nil
}
if r, err := c.Ticker(to + from); err == nil && r > 0 {
return 1 / r, nil
}
if from != "USD" && to != "USD" {
r1, err1 := c.FXRate(from, "USD")
r2, err2 := c.FXRate("USD", to)
if err1 == nil && err2 == nil {
return r1 * r2, nil
}
}
return 0, fmt.Errorf("kraken: no fx rate for %s->%s", from, to)
}
// --- public: OHLC ---
type Candle struct {
Time time.Time
Open, High, Low, Close float64
}
// OHLC returns candles for altPair at the given interval (minutes), only
// including data since the given time.
func (c *Client) OHLC(altPair string, intervalMinutes int, since time.Time) ([]Candle, error) {
params := url.Values{
"pair": {altPair},
"interval": {strconv.Itoa(intervalMinutes)},
"since": {strconv.FormatInt(since.Unix(), 10)},
}
raw, err := c.doPublic("/0/public/OHLC", params)
if err != nil {
return nil, err
}
var m map[string]json.RawMessage
if err := json.Unmarshal(raw, &m); err != nil {
return nil, err
}
var candles []Candle
for key, v := range m {
if key == "last" {
continue
}
var rows [][]interface{}
if err := json.Unmarshal(v, &rows); err != nil {
continue
}
for _, row := range rows {
if len(row) < 5 {
continue
}
t, _ := toFloat(row[0])
o, _ := toFloat(row[1])
h, _ := toFloat(row[2])
l, _ := toFloat(row[3])
cl, _ := toFloat(row[4])
candles = append(candles, Candle{Time: time.Unix(int64(t), 0), Open: o, High: h, Low: l, Close: cl})
}
}
sort.Slice(candles, func(i, j int) bool { return candles[i].Time.Before(candles[j].Time) })
return candles, nil
}
func toFloat(v interface{}) (float64, error) {
switch x := v.(type) {
case float64:
return x, nil
case string:
return strconv.ParseFloat(x, 64)
default:
return 0, fmt.Errorf("unexpected type %T", v)
}
}
// --- private: balance ---
type Balance struct {
Total float64 // liquid + staked combined
Staked float64 // portion of Total that's staked/bonded
}
// NormalizeStakedAsset strips Kraken's staking-variant suffix so a staked
// balance merges into its liquid currency instead of appearing as a
// separate, untracked asset — e.g. "ETH2.S" (staked ETH) and the legacy
// "ETH2" bonding token both fold into "ETH". Any other "<BASE>.<suffix>"
// variant (e.g. "DOT.S") folds into "<BASE>" the same way.
func NormalizeStakedAsset(altname string) (base string, staked bool) {
base = altname
if i := strings.Index(altname, "."); i != -1 {
base = altname[:i]
staked = true
}
if base == "ETH2" {
base = "ETH"
staked = true
}
return base, staked
}
// Balance returns current holdings keyed by asset altname (e.g. "XBT"),
// with staked/bonded variants merged into their liquid currency.
func (c *Client) Balance() (map[string]Balance, error) {
raw, err := c.doPrivate("/0/private/Balance", nil)
if err != nil {
return nil, err
}
var m map[string]string
if err := json.Unmarshal(raw, &m); err != nil {
return nil, err
}
out := map[string]Balance{}
for code, amtStr := range m {
amt, err := strconv.ParseFloat(amtStr, 64)
if err != nil {
continue
}
base, staked := NormalizeStakedAsset(c.AssetAltName(code))
b := out[base]
b.Total += amt
if staked {
b.Staked += amt
}
out[base] = b
}
return out, nil
}
// --- private: trades history ---
type Trade struct {
ID string
Pair string // Kraken pair name, e.g. XXBTZUSD
Currency string // base asset altname, e.g. XBT
Quote string // quote asset altname, e.g. USD
Type string // "buy" or "sell"
Price float64
Cost float64
Fee float64
Vol float64
Time time.Time
}
// TradesHistory returns every closed trade on the account, paginating
// through Kraken's 50-per-page limit.
func (c *Client) TradesHistory() ([]Trade, error) {
var out []Trade
offset := 0
for {
params := url.Values{"ofs": {strconv.Itoa(offset)}}
raw, err := c.doPrivate("/0/private/TradesHistory", params)
if err != nil {
return nil, err
}
var page struct {
Trades map[string]json.RawMessage `json:"trades"`
Count int `json:"count"`
}
if err := json.Unmarshal(raw, &page); err != nil {
return nil, err
}
for id, rawTrade := range page.Trades {
var m map[string]interface{}
if err := json.Unmarshal(rawTrade, &m); err != nil {
continue
}
t := Trade{ID: id}
t.Pair, _ = m["pair"].(string)
t.Type, _ = m["type"].(string)
if s, ok := m["price"].(string); ok {
t.Price, _ = strconv.ParseFloat(s, 64)
}
if s, ok := m["cost"].(string); ok {
t.Cost, _ = strconv.ParseFloat(s, 64)
}
if s, ok := m["fee"].(string); ok {
t.Fee, _ = strconv.ParseFloat(s, 64)
}
if s, ok := m["vol"].(string); ok {
t.Vol, _ = strconv.ParseFloat(s, 64)
}
if f, ok := m["time"].(float64); ok {
t.Time = time.Unix(int64(f), 0)
}
t.Currency, t.Quote = c.PairAssets(t.Pair)
out = append(out, t)
}
offset += len(page.Trades)
if len(page.Trades) == 0 || offset >= page.Count {
break
}
}
return out, nil
}
// --- private: ledger (deposits/withdrawals) ---
type LedgerRow struct {
ID string
Type string // "deposit" | "withdrawal" | (trade, staking, etc — caller filters)
Currency string // asset altname, e.g. "XBT"
Amount float64
Fee float64
Time time.Time
}
// Ledgers returns every ledger entry on the account (deposits, withdrawals,
// trades, staking, ...), paginating through Kraken's 50-per-page limit.
// Callers filter Type for what they need.
func (c *Client) Ledgers() ([]LedgerRow, error) {
var out []LedgerRow
offset := 0
for {
params := url.Values{"ofs": {strconv.Itoa(offset)}}
raw, err := c.doPrivate("/0/private/Ledgers", params)
if err != nil {
return nil, err
}
var page struct {
Ledger map[string]json.RawMessage `json:"ledger"`
Count int `json:"count"`
}
if err := json.Unmarshal(raw, &page); err != nil {
return nil, err
}
for id, rawEntry := range page.Ledger {
var m map[string]interface{}
if err := json.Unmarshal(rawEntry, &m); err != nil {
continue
}
e := LedgerRow{ID: id}
e.Type, _ = m["type"].(string)
asset, _ := m["asset"].(string)
e.Currency, _ = NormalizeStakedAsset(c.AssetAltName(asset))
if s, ok := m["amount"].(string); ok {
e.Amount, _ = strconv.ParseFloat(s, 64)
}
if s, ok := m["fee"].(string); ok {
e.Fee, _ = strconv.ParseFloat(s, 64)
}
if f, ok := m["time"].(float64); ok {
e.Time = time.Unix(int64(f), 0)
}
out = append(out, e)
}
offset += len(page.Ledger)
if len(page.Ledger) == 0 || offset >= page.Count {
break
}
}
return out, nil
}
File diff suppressed because it is too large Load Diff
+576
View File
@@ -0,0 +1,576 @@
// Package store wraps the SQLite database: the ledger of buys/sells/
// deposits/withdrawals, per-currency hide/show/favourite, cached prices/
// deltas/live balances, portfolio value snapshots for charting, and
// encrypted exchange credentials.
package store
import (
"database/sql"
"fmt"
"strings"
"time"
_ "modernc.org/sqlite"
)
const schema = `
CREATE TABLE IF NOT EXISTS credentials (
id INTEGER PRIMARY KEY CHECK (id = 1),
exchange TEXT NOT NULL,
api_key_enc TEXT NOT NULL,
api_secret_enc TEXT NOT NULL,
updated_at TIMESTAMP NOT NULL
);
CREATE TABLE IF NOT EXISTS purchases (
id INTEGER PRIMARY KEY AUTOINCREMENT,
source TEXT NOT NULL,
external_id TEXT,
pair TEXT NOT NULL,
currency TEXT NOT NULL,
amount REAL NOT NULL,
price REAL NOT NULL,
fee REAL NOT NULL DEFAULT 0,
purchased_at TIMESTAMP NOT NULL,
created_at TIMESTAMP NOT NULL,
UNIQUE(source, external_id)
);
CREATE TABLE IF NOT EXISTS currency_settings (
currency TEXT PRIMARY KEY,
hidden INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS price_cache (
pair TEXT PRIMARY KEY,
price REAL NOT NULL,
change_4h REAL,
change_1d REAL,
change_7d REAL,
change_30d REAL,
change_all REAL,
updated_at TIMESTAMP NOT NULL
);
CREATE TABLE IF NOT EXISTS portfolio_snapshots (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ts TIMESTAMP NOT NULL,
total_value REAL NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_snapshots_ts ON portfolio_snapshots(ts);
CREATE TABLE IF NOT EXISTS balance_cache (
currency TEXT PRIMARY KEY,
amount REAL NOT NULL,
updated_at TIMESTAMP NOT NULL
);
CREATE TABLE IF NOT EXISTS candle_cache (
pair TEXT NOT NULL,
interval_minutes INTEGER NOT NULL,
ts TIMESTAMP NOT NULL,
open REAL NOT NULL,
high REAL NOT NULL,
low REAL NOT NULL,
close REAL NOT NULL,
PRIMARY KEY (pair, interval_minutes, ts)
);
CREATE TABLE IF NOT EXISTS auth (
id INTEGER PRIMARY KEY CHECK (id = 1),
username TEXT NOT NULL,
password_hash TEXT NOT NULL,
must_change_password INTEGER NOT NULL DEFAULT 1,
mfa_secret_enc TEXT,
mfa_enabled INTEGER NOT NULL DEFAULT 0,
session_version INTEGER NOT NULL DEFAULT 1
);
`
// migrations adds columns to tables that may already exist from an earlier
// version of the schema. ALTER TABLE ADD COLUMN has no "IF NOT EXISTS" in
// SQLite, so each statement's "duplicate column" error is swallowed.
var migrations = []string{
`ALTER TABLE purchases ADD COLUMN quote TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE currency_settings ADD COLUMN favourite INTEGER NOT NULL DEFAULT 0`,
`ALTER TABLE purchases ADD COLUMN entry_type TEXT NOT NULL DEFAULT 'buy'`,
`ALTER TABLE balance_cache ADD COLUMN staked REAL NOT NULL DEFAULT 0`,
}
type Store struct {
db *sql.DB
}
func Open(path string) (*Store, error) {
db, err := sql.Open("sqlite", path)
if err != nil {
return nil, err
}
if _, err := db.Exec(schema); err != nil {
db.Close()
return nil, fmt.Errorf("init schema: %w", err)
}
for _, stmt := range migrations {
if _, err := db.Exec(stmt); err != nil && !strings.Contains(err.Error(), "duplicate column") {
db.Close()
return nil, fmt.Errorf("migrate: %s: %w", stmt, err)
}
}
return &Store{db: db}, nil
}
func (s *Store) Close() error { return s.db.Close() }
// --- credentials ---
func (s *Store) SaveCredentials(exchange, apiKeyEnc, apiSecretEnc string) error {
_, err := s.db.Exec(`
INSERT INTO credentials (id, exchange, api_key_enc, api_secret_enc, updated_at)
VALUES (1, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET exchange=excluded.exchange,
api_key_enc=excluded.api_key_enc, api_secret_enc=excluded.api_secret_enc,
updated_at=excluded.updated_at`,
exchange, apiKeyEnc, apiSecretEnc, time.Now())
return err
}
// GetCredentials returns ok=false if none have been saved yet.
func (s *Store) GetCredentials() (apiKeyEnc, apiSecretEnc string, ok bool, err error) {
row := s.db.QueryRow(`SELECT api_key_enc, api_secret_enc FROM credentials WHERE id = 1`)
err = row.Scan(&apiKeyEnc, &apiSecretEnc)
if err == sql.ErrNoRows {
return "", "", false, nil
}
if err != nil {
return "", "", false, err
}
return apiKeyEnc, apiSecretEnc, true, nil
}
// --- auth (single-user login: username/password + optional MFA) ---
type AuthRecord struct {
Username string
PasswordHash string
MustChangePassword bool
MFASecretEnc string // "" if MFA never set up
MFAEnabled bool
SessionVersion int // bumped on credential change/reset to invalidate old session tokens
}
// EnsureAuth seeds the row only if none exists yet — used to create the
// default admin/admin login on first run.
func (s *Store) EnsureAuth(username, passwordHash string) error {
_, err := s.db.Exec(`
INSERT INTO auth (id, username, password_hash, must_change_password, session_version)
VALUES (1, ?, ?, 1, 1)
ON CONFLICT(id) DO NOTHING`, username, passwordHash)
return err
}
// ResetAuth unconditionally restores the default login (used by -userreset)
// and bumps session_version so any existing signed-in cookie is invalidated.
func (s *Store) ResetAuth(username, passwordHash string) error {
_, err := s.db.Exec(`
INSERT INTO auth (id, username, password_hash, must_change_password, mfa_secret_enc, mfa_enabled, session_version)
VALUES (1, ?, ?, 1, NULL, 0, 1)
ON CONFLICT(id) DO UPDATE SET username=excluded.username, password_hash=excluded.password_hash,
must_change_password=1, mfa_secret_enc=NULL, mfa_enabled=0, session_version=auth.session_version+1`,
username, passwordHash)
return err
}
func (s *Store) GetAuth() (AuthRecord, error) {
var r AuthRecord
var mfaSecret sql.NullString
row := s.db.QueryRow(`SELECT username, password_hash, must_change_password, mfa_secret_enc, mfa_enabled, session_version FROM auth WHERE id = 1`)
if err := row.Scan(&r.Username, &r.PasswordHash, &r.MustChangePassword, &mfaSecret, &r.MFAEnabled, &r.SessionVersion); err != nil {
return AuthRecord{}, err
}
r.MFASecretEnc = mfaSecret.String
return r, nil
}
// SetAuthCredentials updates username/password, clears must-change, and
// bumps session_version so every other signed-in session is invalidated.
func (s *Store) SetAuthCredentials(username, passwordHash string) error {
_, err := s.db.Exec(`
UPDATE auth SET username = ?, password_hash = ?, must_change_password = 0, session_version = session_version + 1
WHERE id = 1`, username, passwordHash)
return err
}
// SetMFAPending stores a freshly generated secret, awaiting confirmation.
func (s *Store) SetMFAPending(secretEnc string) error {
_, err := s.db.Exec(`UPDATE auth SET mfa_secret_enc = ?, mfa_enabled = 0 WHERE id = 1`, secretEnc)
return err
}
func (s *Store) ConfirmMFA() error {
_, err := s.db.Exec(`UPDATE auth SET mfa_enabled = 1 WHERE id = 1`)
return err
}
func (s *Store) DisableMFA() error {
_, err := s.db.Exec(`UPDATE auth SET mfa_secret_enc = NULL, mfa_enabled = 0 WHERE id = 1`)
return err
}
// --- ledger: buys, sells, deposits, withdrawals ---
// EntryType values. Buy/sell come from Kraken trades (or manual entry);
// deposit/withdrawal come from Kraken's ledger (or manual entry) and have
// no price — they only move the balance.
const (
EntryBuy = "buy"
EntrySell = "sell"
EntryDeposit = "deposit"
EntryWithdrawal = "withdrawal"
)
type LedgerEntry struct {
ID int64
Source string // "kraken" | "manual"
ExternalID string
EntryType string
Pair string // Kraken pair, e.g. XXBTZUSD; empty for deposit/withdrawal
Quote string // quote currency altname of Pair, e.g. "USD"; empty for deposit/withdrawal
Currency string
Amount float64 // always positive; EntryType implies the sign
Price float64 // per unit, in Quote; 0 for deposit/withdrawal
Fee float64
OccurredAt time.Time
}
// UpsertKrakenEntry inserts a ledger entry imported from Kraken, ignoring it
// if external_id was already imported (dedup on re-sync).
func (s *Store) UpsertKrakenEntry(e LedgerEntry) error {
_, err := s.db.Exec(`
INSERT INTO purchases (source, external_id, entry_type, pair, quote, currency, amount, price, fee, purchased_at, created_at)
VALUES ('kraken', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(source, external_id) DO NOTHING`,
e.ExternalID, e.EntryType, e.Pair, e.Quote, e.Currency, e.Amount, e.Price, e.Fee, e.OccurredAt, time.Now())
return err
}
func (s *Store) AddManualEntry(e LedgerEntry) error {
_, err := s.db.Exec(`
INSERT INTO purchases (source, external_id, entry_type, pair, quote, currency, amount, price, fee, purchased_at, created_at)
VALUES ('manual', NULL, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
e.EntryType, e.Pair, e.Quote, e.Currency, e.Amount, e.Price, e.Fee, e.OccurredAt, time.Now())
return err
}
func (s *Store) ListEntries(currency string) ([]LedgerEntry, error) {
rows, err := s.db.Query(`
SELECT id, source, COALESCE(external_id, ''), entry_type, pair, quote, currency, amount, price, fee, purchased_at
FROM purchases WHERE currency = ? ORDER BY purchased_at DESC`, currency)
if err != nil {
return nil, err
}
defer rows.Close()
return scanEntries(rows)
}
func (s *Store) ListAllEntries() ([]LedgerEntry, error) {
rows, err := s.db.Query(`
SELECT id, source, COALESCE(external_id, ''), entry_type, pair, quote, currency, amount, price, fee, purchased_at
FROM purchases ORDER BY purchased_at DESC`)
if err != nil {
return nil, err
}
defer rows.Close()
return scanEntries(rows)
}
// EntriesMissingQuote returns buy/sell entries saved before the quote
// column existed, so it can be backfilled without a live Kraken lookup per
// request.
func (s *Store) EntriesMissingQuote() ([]LedgerEntry, error) {
rows, err := s.db.Query(`
SELECT id, source, COALESCE(external_id, ''), entry_type, pair, quote, currency, amount, price, fee, purchased_at
FROM purchases WHERE quote = '' AND pair != ''`)
if err != nil {
return nil, err
}
defer rows.Close()
return scanEntries(rows)
}
func (s *Store) UpdateEntryQuote(id int64, quote string) error {
_, err := s.db.Exec(`UPDATE purchases SET quote = ? WHERE id = ?`, quote, id)
return err
}
func scanEntries(rows *sql.Rows) ([]LedgerEntry, error) {
var out []LedgerEntry
for rows.Next() {
var e LedgerEntry
if err := rows.Scan(&e.ID, &e.Source, &e.ExternalID, &e.EntryType, &e.Pair, &e.Quote, &e.Currency, &e.Amount, &e.Price, &e.Fee, &e.OccurredAt); err != nil {
return nil, err
}
out = append(out, e)
}
return out, rows.Err()
}
// ListCurrencies returns every distinct currency with at least one ledger entry.
func (s *Store) ListCurrencies() ([]string, error) {
rows, err := s.db.Query(`SELECT DISTINCT currency FROM purchases ORDER BY currency`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []string
for rows.Next() {
var c string
if err := rows.Scan(&c); err != nil {
return nil, err
}
out = append(out, c)
}
return out, rows.Err()
}
// RenameCurrency merges every ledger entry under `from` into `to` — used to
// retroactively fix rows saved under a raw staked-asset code (e.g.
// "ETH2.S") by an older build, before Balance()/Ledgers() normalized them.
// currency_settings/balance_cache rows for `from` are dropped rather than
// merged (currency is their primary key); they get repopulated on the next
// sync/interaction anyway.
func (s *Store) RenameCurrency(from, to string) error {
if _, err := s.db.Exec(`UPDATE purchases SET currency = ? WHERE currency = ?`, to, from); err != nil {
return err
}
if _, err := s.db.Exec(`DELETE FROM currency_settings WHERE currency = ?`, from); err != nil {
return err
}
if _, err := s.db.Exec(`DELETE FROM balance_cache WHERE currency = ?`, from); err != nil {
return err
}
return nil
}
// --- currency visibility / favourites ---
type CurrencyFlags struct {
Hidden bool
Favourite bool
}
func (s *Store) SetHidden(currency string, hidden bool) error {
_, err := s.db.Exec(`
INSERT INTO currency_settings (currency, hidden, favourite) VALUES (?, ?, 0)
ON CONFLICT(currency) DO UPDATE SET hidden=excluded.hidden`, currency, hidden)
return err
}
func (s *Store) SetFavourite(currency string, favourite bool) error {
_, err := s.db.Exec(`
INSERT INTO currency_settings (currency, hidden, favourite) VALUES (?, 0, ?)
ON CONFLICT(currency) DO UPDATE SET favourite=excluded.favourite`, currency, favourite)
return err
}
func (s *Store) CurrencyFlags() (map[string]CurrencyFlags, error) {
rows, err := s.db.Query(`SELECT currency, hidden, favourite FROM currency_settings`)
if err != nil {
return nil, err
}
defer rows.Close()
out := map[string]CurrencyFlags{}
for rows.Next() {
var c string
var f CurrencyFlags
if err := rows.Scan(&c, &f.Hidden, &f.Favourite); err != nil {
return nil, err
}
out[c] = f
}
return out, rows.Err()
}
// --- price cache (also holds synthetic "FX:<CODE>" rows for FX rates) ---
type PriceCache struct {
Pair string
Price float64
Change4h sql.NullFloat64
Change1d sql.NullFloat64
Change7d sql.NullFloat64
Change30d sql.NullFloat64
ChangeAll sql.NullFloat64
UpdatedAt time.Time
}
func (s *Store) UpsertPriceCache(pc PriceCache) error {
_, err := s.db.Exec(`
INSERT INTO price_cache (pair, price, change_4h, change_1d, change_7d, change_30d, change_all, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(pair) DO UPDATE SET price=excluded.price, change_4h=excluded.change_4h,
change_1d=excluded.change_1d, change_7d=excluded.change_7d, change_30d=excluded.change_30d,
change_all=excluded.change_all, updated_at=excluded.updated_at`,
pc.Pair, pc.Price, pc.Change4h, pc.Change1d, pc.Change7d, pc.Change30d, pc.ChangeAll, pc.UpdatedAt)
return err
}
func (s *Store) GetAllPriceCache() (map[string]PriceCache, error) {
rows, err := s.db.Query(`SELECT pair, price, change_4h, change_1d, change_7d, change_30d, change_all, updated_at FROM price_cache`)
if err != nil {
return nil, err
}
defer rows.Close()
out := map[string]PriceCache{}
for rows.Next() {
var pc PriceCache
if err := rows.Scan(&pc.Pair, &pc.Price, &pc.Change4h, &pc.Change1d, &pc.Change7d, &pc.Change30d, &pc.ChangeAll, &pc.UpdatedAt); err != nil {
return nil, err
}
out[pc.Pair] = pc
}
return out, rows.Err()
}
// --- balance cache (live Kraken balance, authoritative when connected) ---
type BalanceRow struct {
Amount float64 // liquid + staked combined
Staked float64
}
func (s *Store) UpsertBalance(currency string, amount, staked float64, updatedAt time.Time) error {
_, err := s.db.Exec(`
INSERT INTO balance_cache (currency, amount, staked, updated_at) VALUES (?, ?, ?, ?)
ON CONFLICT(currency) DO UPDATE SET amount=excluded.amount, staked=excluded.staked, updated_at=excluded.updated_at`,
currency, amount, staked, updatedAt)
return err
}
func (s *Store) GetAllBalances() (map[string]BalanceRow, error) {
rows, err := s.db.Query(`SELECT currency, amount, staked FROM balance_cache`)
if err != nil {
return nil, err
}
defer rows.Close()
out := map[string]BalanceRow{}
for rows.Next() {
var c string
var b BalanceRow
if err := rows.Scan(&c, &b.Amount, &b.Staked); err != nil {
return nil, err
}
out[c] = b
}
return out, rows.Err()
}
// --- candle cache (OHLC history, so repeated chart/timeframe requests
// don't re-hit Kraken and risk its rate limit) ---
type Candle struct {
Time time.Time
Open, High, Low, Close float64
}
// UpsertCandles stores/replaces a batch of candles for one pair+interval.
func (s *Store) UpsertCandles(pair string, intervalMinutes int, candles []Candle) error {
tx, err := s.db.Begin()
if err != nil {
return err
}
stmt, err := tx.Prepare(`
INSERT INTO candle_cache (pair, interval_minutes, ts, open, high, low, close)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(pair, interval_minutes, ts) DO UPDATE SET
open=excluded.open, high=excluded.high, low=excluded.low, close=excluded.close`)
if err != nil {
tx.Rollback()
return err
}
defer stmt.Close()
for _, c := range candles {
if _, err := stmt.Exec(pair, intervalMinutes, c.Time, c.Open, c.High, c.Low, c.Close); err != nil {
tx.Rollback()
return err
}
}
return tx.Commit()
}
func (s *Store) GetCandles(pair string, intervalMinutes int, since time.Time) ([]Candle, error) {
rows, err := s.db.Query(`
SELECT ts, open, high, low, close FROM candle_cache
WHERE pair = ? AND interval_minutes = ? AND ts >= ?
ORDER BY ts ASC`, pair, intervalMinutes, since)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Candle
for rows.Next() {
var c Candle
if err := rows.Scan(&c.Time, &c.Open, &c.High, &c.Low, &c.Close); err != nil {
return nil, err
}
out = append(out, c)
}
return out, rows.Err()
}
// LatestCandleTime and EarliestCandleTime report the cached range for a
// pair+interval, so the caller can tell whether it needs to top up recent
// candles, backfill older ones, or can serve entirely from cache.
func (s *Store) LatestCandleTime(pair string, intervalMinutes int) (time.Time, bool, error) {
return candleBound(s, "DESC", pair, intervalMinutes)
}
func (s *Store) EarliestCandleTime(pair string, intervalMinutes int) (time.Time, bool, error) {
return candleBound(s, "ASC", pair, intervalMinutes)
}
// candleBound reads the newest/oldest cached ts via ORDER BY + LIMIT 1
// rather than MAX(ts)/MIN(ts) — modernc.org/sqlite doesn't give an
// aggregate result the same time.Time scan treatment as a plain column
// select, so MAX(ts) into a *time.Time fails to scan.
func candleBound(s *Store, order, pair string, intervalMinutes int) (time.Time, bool, error) {
var t time.Time
err := s.db.QueryRow(
fmt.Sprintf(`SELECT ts FROM candle_cache WHERE pair = ? AND interval_minutes = ? ORDER BY ts %s LIMIT 1`, order),
pair, intervalMinutes).Scan(&t)
if err == sql.ErrNoRows {
return time.Time{}, false, nil
}
if err != nil {
return time.Time{}, false, err
}
return t, true, nil
}
// --- portfolio value snapshots (for the "value over time" chart) ---
type Snapshot struct {
Time time.Time
Value float64
}
func (s *Store) InsertSnapshot(ts time.Time, totalValue float64) error {
_, err := s.db.Exec(`INSERT INTO portfolio_snapshots (ts, total_value) VALUES (?, ?)`, ts, totalValue)
return err
}
func (s *Store) SnapshotsSince(since time.Time) ([]Snapshot, error) {
rows, err := s.db.Query(`SELECT ts, total_value FROM portfolio_snapshots WHERE ts >= ? ORDER BY ts ASC`, since)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Snapshot
for rows.Next() {
var sn Snapshot
if err := rows.Scan(&sn.Time, &sn.Value); err != nil {
return nil, err
}
out = append(out, sn)
}
return out, rows.Err()
}
+127
View File
@@ -0,0 +1,127 @@
package web
import (
"fmt"
"strconv"
"strings"
"time"
)
var currencySymbols = map[string]string{
"USD": "$", "GBP": "£", "EUR": "€", "JPY": "¥",
}
func currencySymbol(currency string) string {
if sym, ok := currencySymbols[currency]; ok {
return sym
}
return currency + " "
}
// money formats an amount in the given currency with thousands separators,
// e.g. money(-1234.5, "GBP") -> "-£1,234.50".
func money(v float64, currency string) string {
neg := v < 0
if neg {
v = -v
}
s := strconv.FormatFloat(v, 'f', 2, 64)
intPart, decPart, _ := strings.Cut(s, ".")
var out []byte
n := len(intPart)
for i := 0; i < n; i++ {
if i > 0 && (n-i)%3 == 0 {
out = append(out, ',')
}
out = append(out, intPart[i])
}
res := currencySymbol(currency) + string(out) + "." + decPart
if neg {
res = "-" + res
}
return res
}
// amt formats a crypto quantity, trimming trailing zeros.
func amt(v float64) string {
s := strconv.FormatFloat(v, 'f', 8, 64)
s = strings.TrimRight(s, "0")
s = strings.TrimRight(s, ".")
if s == "" || s == "-" {
s = "0"
}
return s
}
func pctStr(v *float64) string {
if v == nil {
return "—"
}
sign := ""
if *v > 0 {
sign = "+"
}
return fmt.Sprintf("%s%.2f%%", sign, *v)
}
func pctClass(v *float64) string {
if v == nil {
return "flat"
}
return signClass(*v)
}
func signClass(v float64) string {
if v > 0 {
return "gain"
}
if v < 0 {
return "loss"
}
return "flat"
}
func dateStr(t time.Time) string {
return t.Format("Jan 2, 2006")
}
// capitalize upper-cases the first letter of an entry type ("withdrawal" -> "Withdrawal").
func capitalize(s string) string {
if s == "" {
return s
}
return strings.ToUpper(s[:1]) + s[1:]
}
// sortVal renders a float64 or *float64 as a plain number string for a
// data-sort-value attribute, so the client-side table sort compares numbers
// rather than formatted display text ("£1,234.56", "+3.21%", "—").
func sortVal(v interface{}) string {
switch x := v.(type) {
case float64:
return strconv.FormatFloat(x, 'f', -1, 64)
case *float64:
if x == nil {
return ""
}
return strconv.FormatFloat(*x, 'f', -1, 64)
default:
return fmt.Sprint(v)
}
}
func holdDuration(t time.Time) string {
days := int(time.Since(t).Hours() / 24)
switch {
case days < 1:
return "<1 day"
case days == 1:
return "1 day"
case days < 30:
return fmt.Sprintf("%d days", days)
case days < 365:
return fmt.Sprintf("%d mo", days/30)
default:
return fmt.Sprintf("%.1f yr", float64(days)/365)
}
}
+779
View File
@@ -0,0 +1,779 @@
(function () {
"use strict";
var body = document.body;
var tabbar = document.getElementById("tabbar");
var panels = document.getElementById("tab-panels");
var chartRangeState = {}; // targetKey -> last-picked range, survives panel refresh
var chartModeState = {}; // targetKey -> "simple" | "advanced"
var baseCurrencySymbol = body.dataset.currencySymbol || "";
var pollMs = (parseInt(body.dataset.poll, 10) || 60) * 1000;
// ---------------------------------------------------------------------
// Formatting (mirrors internal/web/format.go closely enough for live ticks)
// ---------------------------------------------------------------------
function fmtMoney(v) {
var neg = v < 0;
if (neg) v = -v;
var s = v.toFixed(2);
var parts = s.split(".");
var intPart = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ",");
return (neg ? "-" : "") + baseCurrencySymbol + intPart + "." + parts[1];
}
function signClass(v) {
return v > 0 ? "gain" : v < 0 ? "loss" : "flat";
}
function flash(el) {
el.classList.remove("flash");
void el.offsetWidth;
el.classList.add("flash");
}
// ---------------------------------------------------------------------
// Kraken public WebSocket v2 — live ticker for price/value/P&L, purely a
// between-poll smoothing layer. REST panel refresh remains the source of
// truth for deltas, charts, and everything else.
// ---------------------------------------------------------------------
var wsSocket = null;
var wsSubscribers = {}; // symbol -> [callback, ...]
var lastTickAt = {}; // symbol -> ms, throttles DOM writes to ~1/sec
function sendSubscribe(symbols) {
if (wsSocket && wsSocket.readyState === 1 && symbols.length) {
wsSocket.send(JSON.stringify({ method: "subscribe", params: { channel: "ticker", symbol: symbols } }));
}
}
function ensureWebSocket() {
if (wsSocket && (wsSocket.readyState === 0 || wsSocket.readyState === 1)) return wsSocket;
try {
wsSocket = new WebSocket("wss://ws.kraken.com/v2");
} catch (e) {
return null;
}
wsSocket.addEventListener("open", function () {
sendSubscribe(Object.keys(wsSubscribers));
});
wsSocket.addEventListener("message", function (e) {
var msg;
try { msg = JSON.parse(e.data); } catch (err) { return; }
if (msg.channel !== "ticker" || !msg.data) return;
msg.data.forEach(function (tick) {
var callbacks = wsSubscribers[tick.symbol];
if (!callbacks || typeof tick.last !== "number") return;
var now = Date.now();
if (lastTickAt[tick.symbol] && now - lastTickAt[tick.symbol] < 1000) return;
lastTickAt[tick.symbol] = now;
callbacks.forEach(function (cb) { cb(tick.last); });
});
});
wsSocket.addEventListener("close", function () {
wsSocket = null;
setTimeout(ensureWebSocket, 4000);
});
wsSocket.addEventListener("error", function () {
try { wsSocket.close(); } catch (e) { /* ignore */ }
});
return wsSocket;
}
function subscribeTicker(symbol, callback) {
if (!symbol) return;
var isNew = !wsSubscribers[symbol] || wsSubscribers[symbol].length === 0;
if (!wsSubscribers[symbol]) wsSubscribers[symbol] = [];
wsSubscribers[symbol].push(callback);
var ws = ensureWebSocket();
if (isNew && ws && ws.readyState === 1) sendSubscribe([symbol]);
}
function wireLiveTicker(root) {
// Dashboard rows: live price/value/P&L per currency.
root.querySelectorAll("tr[data-ws-symbol]").forEach(function (tr) {
var symbol = tr.dataset.wsSymbol;
var holdings = parseFloat(tr.dataset.holdings) || 0;
var cost = parseFloat(tr.dataset.cost) || 0;
var fxRate = parseFloat(tr.dataset.fxRate) || 1;
var priceCell = tr.querySelector('[data-field="price"]');
var valueCell = tr.querySelector('[data-field="value"]');
var plCell = tr.querySelector('[data-field="pl"]');
subscribeTicker(symbol, function (last) {
var price = last * fxRate;
var value = holdings * price;
if (priceCell) priceCell.textContent = fmtMoney(price);
if (valueCell) valueCell.textContent = fmtMoney(value);
if (plCell) {
var pl = value - cost;
plCell.textContent = fmtMoney(pl);
plCell.classList.remove("gain", "loss", "flat");
plCell.classList.add(signClass(pl));
}
flash(tr);
});
});
// Position page hero: single currency, live value/P&L.
var hero = root.querySelector("[data-ws-hero]");
if (hero) {
var symbol = hero.dataset.wsHero;
var holdings = parseFloat(hero.dataset.holdings) || 0;
var cost = parseFloat(hero.dataset.cost) || 0;
var fxRate = parseFloat(hero.dataset.fxRate) || 1;
var valueEl = hero.querySelector('[data-field="hero-value"]');
var plEl = hero.querySelector('[data-field="hero-pl"]');
subscribeTicker(symbol, function (last) {
var value = holdings * last * fxRate;
var pl = value - cost;
if (valueEl) valueEl.textContent = fmtMoney(value);
if (plEl) {
var pct = cost ? (pl / cost * 100) : 0;
var sign = pct >= 0 ? "+" : "";
plEl.textContent = fmtMoney(pl) + " (" + sign + pct.toFixed(2) + "%)";
plEl.classList.remove("gain", "loss", "flat");
plEl.classList.add(signClass(pl));
}
flash(hero);
});
}
}
// Topbar portfolio total: lives in base.html outside any tab panel, so it
// is wired once here (not per-panel) and stays live across every page.
// Server-rendered value is the initial paint; /api/summary refresh (every
// poll cycle) keeps holdings/cost authoritative, and Kraken WS ticks
// smooth the value in between polls.
function initTopbar() {
var valueEl = document.querySelector('[data-field="topbar-value"]');
var plEl = document.querySelector('[data-field="topbar-pl"]');
var wrap = document.querySelector('[data-topbar]') || (valueEl && valueEl.closest(".topbar-total"));
if (!valueEl) return;
var positions = []; // {symbol, holdings, cost, fxRate, value}
var subscribed = {}; // ws symbol -> true, so refresh() never double-subscribes
function render() {
var total = 0, cost = 0;
positions.forEach(function (p) { total += p.value; cost += p.cost; });
var pl = total - cost;
var pct = cost ? (pl / cost * 100) : 0;
var sign = pct >= 0 ? "+" : "";
valueEl.textContent = fmtMoney(total);
if (plEl) {
plEl.textContent = fmtMoney(pl) + " (" + sign + pct.toFixed(2) + "%)";
plEl.classList.remove("gain", "loss", "flat");
plEl.classList.add(signClass(pl));
}
}
function refresh() {
fetch("/api/summary").then(function (r) { return r.json(); }).then(function (data) {
positions = (data.positions || []).map(function (p) {
return { symbol: p.ws_symbol, holdings: p.holdings, cost: p.cost, fxRate: p.fx_rate, value: p.value };
});
render();
positions.forEach(function (p) {
if (!p.symbol || subscribed[p.symbol]) return;
subscribed[p.symbol] = true;
subscribeTicker(p.symbol, function (last) {
positions.forEach(function (q) {
if (q.symbol === p.symbol) q.value = q.holdings * last * q.fxRate;
});
render();
if (wrap) flash(wrap);
});
});
}).catch(function () { /* transient error: keep last known values */ });
}
refresh();
setInterval(refresh, pollMs);
}
function initPanel(root) {
root.querySelectorAll(".chart-controls").forEach(function (controls) {
var kind = controls.dataset.chart; // "portfolio" | "currency"
var currency = controls.dataset.currency;
var targetKey = kind === "portfolio" ? "portfolio" : "currency:" + currency;
var svg = root.querySelector('[data-chart-target="' + targetKey + '"]');
var emptyEl = root.querySelector('[data-chart-empty="' + targetKey + '"]');
var captionEl = root.querySelector('[data-chart-caption="' + targetKey + '"]');
if (!svg) return;
var symbol = svg.dataset.symbol || "";
if (emptyEl && emptyEl.dataset.defaultText === undefined) {
emptyEl.dataset.defaultText = emptyEl.textContent;
}
var load = function (range) {
chartRangeState[targetKey] = range;
var url = kind === "portfolio"
? "/api/chart/portfolio?range=" + range
: "/api/chart/currency/" + encodeURIComponent(currency) + "?range=" + range;
fetchJSON(targetKey, url).then(function (points) {
if (points === STALE) return;
renderChart(svg, emptyEl, captionEl, points, symbol);
}).catch(function (err) {
showChartError(svg, emptyEl, captionEl, err);
});
};
controls.querySelectorAll("button[data-range]").forEach(function (btn) {
btn.addEventListener("click", function () {
controls.querySelectorAll("button").forEach(function (b) { b.classList.remove("active"); });
btn.classList.add("active");
load(btn.dataset.range);
});
});
var savedRange = chartRangeState[targetKey] || "all";
controls.querySelectorAll("button[data-range]").forEach(function (b) {
b.classList.toggle("active", b.dataset.range === savedRange);
});
load(savedRange);
});
initAdvancedCharts(root);
initSortableTables(root);
wireLiveTicker(root);
}
// ---------------------------------------------------------------------
// Shared fetch helper for chart/candle data: turns a non-2xx response
// into a real error (so a rate-limit or server error surfaces instead of
// silently failing to parse as JSON), and drops out-of-order responses
// when the user switches timeframes faster than requests return.
// ---------------------------------------------------------------------
var STALE = {};
var requestToken = {};
function fetchJSON(key, url) {
var token = (requestToken[key] = (requestToken[key] || 0) + 1);
return fetch(url).then(function (r) {
if (!r.ok) return r.text().then(function (t) { throw new Error(t || ("HTTP " + r.status)); });
return r.json();
}).then(function (data) {
return requestToken[key] === token ? data : STALE;
}, function (err) {
if (requestToken[key] !== token) return STALE;
throw err;
});
}
function friendlyChartError(err) {
var msg = String((err && err.message) || err);
if (msg.indexOf("Too many requests") !== -1) {
return "Kraken rate-limited this request — it'll work again in a moment.";
}
return "Couldn't load chart data right now — try again shortly.";
}
function showChartError(svg, emptyEl, captionEl, err) {
svg.hidden = true;
if (emptyEl) {
emptyEl.hidden = false;
emptyEl.textContent = friendlyChartError(err);
}
if (captionEl) captionEl.textContent = "";
}
var svgns = "http://www.w3.org/2000/svg";
function fmtAxisDate(unixSeconds, spanSeconds) {
var d = new Date(unixSeconds * 1000);
if (spanSeconds > 400 * 24 * 3600) return d.toLocaleDateString(undefined, { month: "short", year: "2-digit" });
if (spanSeconds > 3 * 24 * 3600) return d.toLocaleDateString(undefined, { month: "short", day: "numeric" });
return d.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" });
}
function fmtAxisValue(v, symbol) {
var sign = v < 0 ? "-" : "";
var abs = Math.abs(v);
var s = abs >= 1000 ? (abs / 1000).toFixed(1) + "k" : abs.toFixed(abs < 10 ? 4 : 2);
return sign + symbol + s;
}
function svgEl(tag, attrs) {
var el = document.createElementNS(svgns, tag);
for (var k in attrs) el.setAttribute(k, attrs[k]);
return el;
}
// Picks up to targetCount tick indices, evenly spaced by TIME (not by
// array index — data can have irregular gaps, e.g. after a restart),
// then drops any tick that lands within minPxGap of the previous one so
// labels never overlap on dense data. xAtIndex(i) must return that
// point's pixel x position.
function pickTicks(points, targetCount, minPxGap, xAtIndex) {
var t0 = points[0].t, t1 = points[points.length - 1].t;
var span = t1 - t0 || 1;
var chosen = [];
var lastX = -Infinity;
for (var i = 0; i < targetCount; i++) {
var targetT = t0 + (i / (targetCount - 1 || 1)) * span;
var best = 0, bestDiff = Infinity;
for (var j = 0; j < points.length; j++) {
var diff = Math.abs(points[j].t - targetT);
if (diff < bestDiff) { bestDiff = diff; best = j; }
}
var x = xAtIndex(best);
if (x - lastX < minPxGap) continue;
chosen.push(best);
lastX = x;
}
return chosen;
}
function renderChart(svg, emptyEl, captionEl, points, symbol) {
while (svg.firstChild) svg.removeChild(svg.firstChild);
if (!points || points.length < 2) {
if (emptyEl) {
emptyEl.hidden = false;
emptyEl.textContent = emptyEl.dataset.defaultText || emptyEl.textContent;
}
if (captionEl) captionEl.textContent = "";
svg.hidden = true;
return;
}
if (emptyEl) emptyEl.hidden = true;
svg.hidden = false;
var W = 600, H = 220, ML = 68, MR = 12, MT = 12, MB = 26;
var plotW = W - ML - MR, plotH = H - MT - MB;
svg.setAttribute("viewBox", "0 0 " + W + " " + H);
var values = points.map(function (p) { return p.v; });
var min = Math.min.apply(null, values), max = Math.max.apply(null, values);
var range = (max - min) || Math.abs(max) || 1;
var t0 = points[0].t, t1 = points[points.length - 1].t;
var tSpan = (t1 - t0) || 1;
function xAt(t) { return ML + ((t - t0) / tSpan) * plotW; }
function yAt(v) { return MT + (1 - (v - min) / range) * plotH; }
// Y gridlines + value labels (min, mid, max)
[min, (min + max) / 2, max].forEach(function (v) {
var y = yAt(v);
svg.appendChild(svgEl("line", { x1: ML, x2: W - MR, y1: y, y2: y, class: "chart-grid" }));
var text = svgEl("text", { x: ML - 8, y: y, class: "chart-axis-label chart-axis-y" });
text.textContent = fmtAxisValue(v, symbol);
svg.appendChild(text);
});
// X-axis date labels: evenly spaced by time, collision-avoided.
pickTicks(points, 5, 70, function (i) { return xAt(points[i].t); }).forEach(function (idx) {
var p = points[idx];
var text = svgEl("text", { x: xAt(p.t), y: H - 6, class: "chart-axis-label chart-axis-x" });
text.textContent = fmtAxisDate(p.t, tSpan);
svg.appendChild(text);
});
// Price/value line
var coords = points.map(function (p) { return xAt(p.t).toFixed(2) + "," + yAt(p.v).toFixed(2); });
var poly = svgEl("polyline", { points: coords.join(" ") });
var trend = values[values.length - 1] === values[0] ? "flat" : (values[values.length - 1] > values[0] ? "gain" : "loss");
poly.setAttribute("class", "chart-line " + trend);
svg.appendChild(poly);
// Issue transparency: when a range shows the exact same span as "all"
// available history, a shorter timeframe looks identical — say why.
if (captionEl) {
var spanDays = tSpan / 86400;
var first = new Date(t0 * 1000).toLocaleDateString();
var last = new Date(t1 * 1000).toLocaleDateString();
captionEl.textContent = spanDays < 1
? "Showing all recorded history (tracking started " + first + ") — longer ranges will look different once more history builds up."
: "Showing " + first + " " + last;
}
}
var sortState = {}; // sortKey -> {key, dir}
function cellSortValue(row, colIndex) {
var cell = row.children[colIndex];
if (!cell) return "";
if (cell.dataset.sortValue !== undefined) {
if (cell.dataset.sortValue === "") return -Infinity;
var n = parseFloat(cell.dataset.sortValue);
return isNaN(n) ? -Infinity : n;
}
return cell.textContent.trim().toLowerCase();
}
function initSortableTables(root) {
root.querySelectorAll("table.sortable[data-sort-key]").forEach(function (table) {
var sortKey = table.dataset.sortKey;
var tbody = table.querySelector("tbody");
if (!tbody) return;
function applySort(colKey, dir) {
var colIndex = -1;
table.querySelectorAll("th[data-sort]").forEach(function (th) {
th.classList.remove("sort-asc", "sort-desc");
if (th.dataset.sort === colKey) {
colIndex = Array.prototype.indexOf.call(th.parentElement.children, th);
th.classList.add(dir === 1 ? "sort-asc" : "sort-desc");
}
});
if (colIndex === -1) return;
var rows = Array.prototype.slice.call(tbody.querySelectorAll("tr"));
rows.sort(function (a, b) {
var av = cellSortValue(a, colIndex), bv = cellSortValue(b, colIndex);
if (av < bv) return -1 * dir;
if (av > bv) return 1 * dir;
return 0;
});
rows.forEach(function (r) { tbody.appendChild(r); });
}
table.querySelectorAll("th[data-sort]").forEach(function (th) {
th.addEventListener("click", function () {
var key = th.dataset.sort;
var prev = sortState[sortKey];
var dir = prev && prev.key === key ? prev.dir * -1 : 1;
sortState[sortKey] = { key: key, dir: dir };
applySort(key, dir);
});
});
var saved = sortState[sortKey];
if (saved) applySort(saved.key, saved.dir);
});
}
// ---------------------------------------------------------------------
// Advanced chart: candlesticks + Bollinger Bands + RSI
// ---------------------------------------------------------------------
function initAdvancedCharts(root) {
root.querySelectorAll("[data-advanced-chart]").forEach(function (wrap) {
var currency = wrap.dataset.currency;
var targetKey = "currency:" + currency;
var simpleEls = wrap.querySelectorAll("[data-chart-simple]");
var advancedWrap = wrap.querySelector("[data-chart-advanced]");
var priceSvg = wrap.querySelector("[data-candle-price]");
var rsiSvg = wrap.querySelector("[data-candle-rsi]");
var candleEmptyEl = wrap.querySelector("[data-candle-empty]");
var toggle = wrap.querySelector("[data-chart-mode-toggle]");
var rangeControls = wrap.querySelector(".chart-controls");
if (!toggle || !advancedWrap) return;
function setMode(mode) {
chartModeState[targetKey] = mode;
var advanced = mode === "advanced";
advancedWrap.hidden = !advanced;
simpleEls.forEach(function (el) { el.hidden = advanced; });
toggle.textContent = advanced ? "Simple view" : "Advanced view";
if (advanced) loadCandles();
}
function currentRange() {
var active = rangeControls && rangeControls.querySelector("button.active");
return active ? active.dataset.range : "all";
}
function loadCandles() {
var candleKey = "candles:" + currency;
fetchJSON(candleKey, "/api/candles/" + encodeURIComponent(currency) + "?range=" + currentRange())
.then(function (series) {
if (series === STALE) return;
if (candleEmptyEl) candleEmptyEl.hidden = true;
priceSvg.hidden = false;
renderCandles(priceSvg, rsiSvg, series);
})
.catch(function (err) { showChartError(priceSvg, candleEmptyEl, null, err); });
}
toggle.addEventListener("click", function () {
setMode(chartModeState[targetKey] === "advanced" ? "simple" : "advanced");
});
if (rangeControls) {
rangeControls.querySelectorAll("button[data-range]").forEach(function (btn) {
btn.addEventListener("click", function () {
if (chartModeState[targetKey] === "advanced") loadCandles();
});
});
}
setMode(chartModeState[targetKey] === "advanced" ? "advanced" : "simple");
});
}
function sma(values, period, idx) {
if (idx + 1 < period) return null;
var sum = 0;
for (var i = idx + 1 - period; i <= idx; i++) sum += values[i];
return sum / period;
}
function renderCandles(priceSvg, rsiSvg, series) {
if (!priceSvg) return;
while (priceSvg.firstChild) priceSvg.removeChild(priceSvg.firstChild);
if (rsiSvg) while (rsiSvg.firstChild) rsiSvg.removeChild(rsiSvg.firstChild);
var candles = series && series.candles ? series.candles : [];
if (candles.length < 2) return;
var W = 600, PH = 260, ML = 56, MR = 12, MT = 10, MB = 4;
var plotW = W - ML - MR, plotH = PH - MT - MB;
priceSvg.setAttribute("viewBox", "0 0 " + W + " " + PH);
var lows = candles.map(function (c) { return c.l; });
var highs = candles.map(function (c) { return c.h; });
var bandVals = [];
(series.bb_upper || []).forEach(function (v) { if (v !== null) bandVals.push(v); });
(series.bb_lower || []).forEach(function (v) { if (v !== null) bandVals.push(v); });
var min = Math.min.apply(null, lows.concat(bandVals.length ? bandVals : lows));
var max = Math.max.apply(null, highs.concat(bandVals.length ? bandVals : highs));
var range = (max - min) || 1;
var t0 = candles[0].t, t1 = candles[candles.length - 1].t;
var tSpan = (t1 - t0) || 1;
var slot = plotW / candles.length;
function xAt(i) { return ML + (i + 0.5) * slot; }
function yAt(v) { return MT + (1 - (v - min) / range) * plotH; }
[min, (min + max) / 2, max].forEach(function (v) {
var y = yAt(v);
priceSvg.appendChild(svgEl("line", { x1: ML, x2: W - MR, y1: y, y2: y, class: "chart-grid" }));
var text = svgEl("text", { x: ML - 8, y: y, class: "chart-axis-label chart-axis-y" });
text.textContent = fmtAxisValue(v, "");
priceSvg.appendChild(text);
});
// Bollinger Bands band + lines. pathFor takes the y-scaler so it can be
// reused for RSI below (0-100 scale) as well as price-scale series.
function pathFor(arr, yFn) {
var pts = [];
for (var i = 0; i < arr.length; i++) {
if (arr[i] === null || arr[i] === undefined) continue;
pts.push(xAt(i).toFixed(2) + "," + yFn(arr[i]).toFixed(2));
}
return pts;
}
var upperPts = pathFor(series.bb_upper || [], yAt);
var lowerPts = pathFor(series.bb_lower || [], yAt);
if (upperPts.length && lowerPts.length) {
var band = svgEl("polygon", { points: upperPts.join(" ") + " " + lowerPts.slice().reverse().join(" "), class: "bb-band" });
priceSvg.appendChild(band);
priceSvg.appendChild(svgEl("polyline", { points: upperPts.join(" "), class: "bb-line" }));
priceSvg.appendChild(svgEl("polyline", { points: lowerPts.join(" "), class: "bb-line" }));
}
var midPts = pathFor(series.bb_middle || [], yAt);
if (midPts.length) priceSvg.appendChild(svgEl("polyline", { points: midPts.join(" "), class: "bb-mid" }));
// Candlesticks
candles.forEach(function (c, i) {
var x = xAt(i);
var up = c.c >= c.o;
var cls = up ? "candle-up" : "candle-down";
priceSvg.appendChild(svgEl("line", { x1: x, x2: x, y1: yAt(c.h), y2: yAt(c.l), class: "candle-wick " + cls }));
var bodyTop = yAt(Math.max(c.o, c.c));
var bodyH = Math.max(1, Math.abs(yAt(c.o) - yAt(c.c)));
var bw = Math.max(1, slot * 0.6);
priceSvg.appendChild(svgEl("rect", {
x: (x - bw / 2).toFixed(2), y: bodyTop.toFixed(2),
width: bw.toFixed(2), height: bodyH.toFixed(2), class: "candle-body " + cls,
}));
});
// X-axis dates (shared scale with RSI below): evenly spaced by time,
// collision-avoided.
pickTicks(candles, 5, 70, xAt).forEach(function (idx) {
var text = svgEl("text", { x: xAt(idx), y: PH - 6, class: "chart-axis-label chart-axis-x" });
text.textContent = fmtAxisDate(candles[idx].t, tSpan);
priceSvg.appendChild(text);
});
if (!rsiSvg) return;
var RH = 90;
rsiSvg.setAttribute("viewBox", "0 0 " + W + " " + RH);
var rMT = 10, rMB = 20, rPlotH = RH - rMT - rMB;
function ry(v) { return rMT + (1 - v / 100) * rPlotH; }
[30, 50, 70].forEach(function (level) {
var y = ry(level);
rsiSvg.appendChild(svgEl("line", { x1: ML, x2: W - MR, y1: y, y2: y, class: "chart-grid" }));
var text = svgEl("text", { x: ML - 8, y: y, class: "chart-axis-label chart-axis-y" });
text.textContent = level;
rsiSvg.appendChild(text);
});
var rsiPts = pathFor(series.rsi || [], ry);
if (rsiPts.length) rsiSvg.appendChild(svgEl("polyline", { points: rsiPts.join(" "), class: "rsi-line" }));
}
// Favourite star toggle works on any page (delegated), independent of tabs.
document.addEventListener("click", function (e) {
var btn = e.target.closest("[data-favourite-toggle]");
if (!btn) return;
var currency = btn.dataset.currency;
var next = btn.dataset.favourite;
fetch("/api/favourite", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: "currency=" + encodeURIComponent(currency) + "&favourite=" + next,
}).then(function () {
if (typeof refreshPanel === "function") refreshPanel("portfolio");
});
});
initTopbar();
if (!tabbar || !panels) {
// Settings page: no tab system, just wire up any charts on the page.
initPanel(document);
return;
}
var STORAGE_KEY = "basis:tabs";
var FIXED_TABS = { portfolio: "Portfolio", transfers: "Transfers" };
var page = body.dataset.page; // "portfolio" | "position" | "transfers"
var current = body.dataset.current;
function loadStoredTabs() {
try {
var raw = sessionStorage.getItem(STORAGE_KEY);
return raw ? JSON.parse(raw) : [];
} catch (e) {
return [];
}
}
function saveStoredTabs(tabs) {
try { sessionStorage.setItem(STORAGE_KEY, JSON.stringify(tabs)); } catch (e) { /* ignore */ }
}
var openTabs = loadStoredTabs().filter(function (c) { return c !== current; });
if (page === "position" && current) openTabs.push(current);
saveStoredTabs(openTabs);
var activeKey = page === "position" ? "pos:" + current : (FIXED_TABS[page] ? page : "portfolio");
function panelKey(id) { return FIXED_TABS[id] ? id : "pos:" + id; }
function panelURL(id) {
if (id === "portfolio") return "/api/panel/portfolio";
if (id === "transfers") return "/api/panel/transfers";
return "/api/panel/position/" + encodeURIComponent(id);
}
function pageURL(id) {
if (id === "portfolio") return "/";
if (id === "transfers") return "/transfers";
return "/position/" + encodeURIComponent(id);
}
function renderTabbar() {
tabbar.innerHTML = "";
function make(key, label, closable) {
var btn = document.createElement("button");
btn.type = "button";
btn.className = "tab-btn" + (key === activeKey ? " active" : "");
btn.setAttribute("role", "tab");
var span = document.createElement("span");
span.textContent = label;
btn.appendChild(span);
if (closable) {
var close = document.createElement("span");
close.className = "tab-close";
close.textContent = "×";
close.addEventListener("click", function (e) { e.stopPropagation(); closeTab(key); });
btn.appendChild(close);
}
btn.addEventListener("click", function () {
activateTab(FIXED_TABS[key] ? key : key.slice(4));
});
tabbar.appendChild(btn);
}
make("portfolio", "Portfolio", false);
make("transfers", "Transfers", false);
openTabs.forEach(function (c) { make("pos:" + c, c, true); });
}
function ensurePanel(id) {
var key = panelKey(id);
var el = panels.querySelector('[data-panel="' + key + '"]');
if (el) return Promise.resolve(el);
el = document.createElement("div");
el.className = "tab-panel";
el.dataset.panel = key;
panels.appendChild(el);
return fetch(panelURL(id)).then(function (r) { return r.text(); }).then(function (html) {
el.innerHTML = html;
initPanel(el);
return el;
});
}
function activateTab(id) {
if (!FIXED_TABS[id] && openTabs.indexOf(id) === -1) {
openTabs.push(id);
saveStoredTabs(openTabs);
}
ensurePanel(id).then(function () {
var key = panelKey(id);
panels.querySelectorAll(".tab-panel").forEach(function (p) {
p.classList.toggle("active", p.dataset.panel === key);
});
activeKey = key;
renderTabbar();
var url = pageURL(id);
if (location.pathname !== url) history.pushState({ tab: id }, "", url);
});
}
function closeTab(key) {
var currency = key.slice(4);
openTabs = openTabs.filter(function (c) { return c !== currency; });
saveStoredTabs(openTabs);
var el = panels.querySelector('[data-panel="' + key + '"]');
if (el) el.remove();
if (activeKey === key) {
activateTab("portfolio");
} else {
renderTabbar();
}
}
window.addEventListener("popstate", function () {
var path = location.pathname;
if (path === "/") { activateTab("portfolio"); return; }
if (path === "/transfers") { activateTab("transfers"); return; }
var m = path.match(/^\/position\/([^/]+)/);
if (m) activateTab(decodeURIComponent(m[1]));
});
document.addEventListener("click", function (e) {
var link = e.target.closest("[data-open-tab]");
if (!link) return;
e.preventDefault();
activateTab(link.dataset.openTab);
});
function refreshPanel(id) {
if (!panels) return;
var key = panelKey(id);
var el = panels.querySelector('[data-panel="' + key + '"]');
if (!el) return;
fetch(panelURL(id)).then(function (r) { return r.text(); }).then(function (html) {
el.innerHTML = html;
initPanel(el);
});
}
setInterval(function () {
var id = FIXED_TABS[activeKey] ? activeKey : activeKey.slice(4);
refreshPanel(id);
}, pollMs);
// --- initial render: wire up what the server already sent down. Other
// previously-open tabs are NOT pre-created here — ensurePanel() lazily
// fetches them the first time they're actually activated, whether by
// clicking their tab button or a currency link. (A prior version created
// empty placeholder panels here, which made ensurePanel() think they were
// already loaded and skip fetching — that's the "only the last tab has
// data after refresh" bug.) ---
renderTabbar();
var initialPanel = panels.querySelector(".tab-panel.active");
if (initialPanel) initPanel(initialPanel);
})();
+563
View File
@@ -0,0 +1,563 @@
:root {
--bg: #0b0e11;
--surface: #14181d;
--surface-alt: #1b2027;
--border: #232830;
--text: #e6e8eb;
--text-dim: #8b93a1;
--gain: #3ddc84;
--loss: #ff6b5e;
--accent: #e8b34c;
--font-body: "Inter", system-ui, -apple-system, sans-serif;
--font-mono: "JetBrains Mono", ui-monospace, SFMono-Regular, monospace;
}
* { box-sizing: border-box; }
body {
margin: 0;
background: var(--bg);
color: var(--text);
font-family: var(--font-body);
line-height: 1.5;
}
a { color: inherit; }
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1.25rem 2rem;
border-bottom: 1px solid var(--border);
}
.topbar-total {
display: flex;
align-items: baseline;
gap: 0.6rem;
text-decoration: none;
font-family: var(--font-mono);
font-variant-numeric: tabular-nums;
}
.topbar-value {
font-weight: 700;
font-size: 1.05rem;
color: var(--text);
}
.topbar-pl {
font-size: 0.85rem;
color: var(--text-dim);
}
.topbar nav a {
text-decoration: none;
color: var(--text-dim);
margin-left: 1.5rem;
font-size: 0.9rem;
transition: color 0.15s;
}
.topbar nav a:hover, .topbar nav a:focus-visible {
color: var(--text);
}
main {
max-width: 1200px;
margin: 0 auto;
padding: 2rem;
}
.topbar nav a.active { color: var(--text); }
.tabbar {
display: flex;
gap: 0.35rem;
margin-bottom: 1.25rem;
overflow-x: auto;
}
.tab-btn {
display: flex;
align-items: center;
gap: 0.5rem;
background: transparent;
color: var(--text-dim);
border: 1px solid var(--border);
border-radius: 8px 8px 0 0;
border-bottom: none;
padding: 0.5rem 0.9rem;
font-family: var(--font-mono);
font-size: 0.82rem;
font-weight: 500;
white-space: nowrap;
cursor: pointer;
}
.tab-btn.active {
color: var(--text);
background: var(--surface);
border-color: var(--border);
}
.tab-btn .tab-close {
color: var(--text-dim);
font-size: 0.75rem;
line-height: 1;
padding: 0.1rem 0.25rem;
border-radius: 4px;
}
.tab-btn .tab-close:hover { color: var(--loss); background: var(--surface-alt); }
.tab-panel { display: none; }
.tab-panel.active { display: block; }
.hero {
padding: 2rem 0 2.5rem;
}
.hero-label {
color: var(--text-dim);
font-size: 0.85rem;
text-transform: uppercase;
letter-spacing: 0.08em;
margin-bottom: 0.5rem;
}
.hero-value {
font-family: var(--font-mono);
font-size: clamp(2rem, 6vw, 3.5rem);
font-weight: 600;
font-variant-numeric: tabular-nums;
}
.hero-pl, .hero-sub {
margin-top: 0.5rem;
font-family: var(--font-mono);
font-size: 1rem;
color: var(--text-dim);
}
.back {
text-decoration: none;
color: var(--text-dim);
}
.back:hover { color: var(--accent); }
.notice, .hint {
color: var(--text-dim);
font-size: 0.85rem;
margin-top: 1rem;
}
.notice a, .hint a { color: var(--accent); }
.notice.error { color: var(--loss); }
.qr-code {
display: block;
width: 200px;
height: 200px;
margin: 0.75rem 0;
border-radius: 8px;
background: #fff;
padding: 12px;
}
.secret-key {
font-size: 1.1rem;
letter-spacing: 0.08em;
background: var(--surface-alt);
border: 1px solid var(--border);
border-radius: 8px;
padding: 0.75rem 1rem;
display: inline-block;
word-break: break-all;
}
.logout-form { display: inline; margin-left: 1.5rem; }
.logout-btn {
background: none;
border: none;
padding: 0;
color: var(--text-dim);
font-size: 0.9rem;
font-family: var(--font-body);
cursor: pointer;
transition: color 0.15s;
}
.logout-btn:hover { color: var(--text); }
.login-body {
margin: 0;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg);
}
.login-main { width: 100%; max-width: 380px; padding: 1.5rem; }
.login-panel { padding: 2rem; }
.login-title {
font-family: var(--font-mono);
font-weight: 700;
letter-spacing: 0.15em;
color: var(--accent);
font-size: 1.3rem;
margin: 0 0 1.5rem;
text-align: center;
}
.login-panel .form { flex-direction: column; align-items: stretch; }
.login-panel .form label { flex: none; }
.login-panel button[type="submit"] { margin-top: 0.5rem; }
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
padding: 1.5rem;
}
.modal-overlay[hidden] { display: none; }
.modal {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 10px;
padding: 2rem;
width: 100%;
max-width: 340px;
}
.modal h2 { margin-top: 0; font-size: 1.05rem; }
.modal .form { flex-direction: column; align-items: stretch; }
.modal .form label { flex: none; }
.modal button[type="submit"] { margin-top: 0.5rem; }
.empty { color: var(--text-dim); }
.table-scroll {
overflow-x: auto;
border: 1px solid var(--border);
border-radius: 10px;
}
table.ledger {
width: 100%;
border-collapse: collapse;
background: var(--surface);
}
.star-cell { width: 2rem; text-align: center !important; padding-left: 0.5rem !important; padding-right: 0.25rem !important; }
.star-btn {
background: none;
border: none;
color: var(--border);
font-size: 1rem;
cursor: pointer;
padding: 0.15rem;
line-height: 1;
}
.star-btn:hover { color: var(--accent); }
.star-btn.active { color: var(--accent); }
tr.is-favourite { background: rgba(232, 179, 76, 0.05); }
.ledger th, .ledger td {
padding: 0.65rem 0.5rem;
text-align: right;
white-space: nowrap;
border-bottom: 1px solid var(--border);
}
.ledger th:first-of-type, .ledger td:first-of-type {
padding-left: 0.75rem;
}
.ledger th:first-child, .ledger td:first-child {
text-align: left;
}
.ledger th {
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text-dim);
font-weight: 600;
position: sticky;
top: 0;
background: var(--surface);
}
.ledger th[data-sort] {
cursor: pointer;
user-select: none;
}
.ledger th[data-sort]:hover { color: var(--text); }
.ledger th[data-sort]::after {
content: "";
display: inline-block;
width: 0.6em;
margin-left: 0.2em;
opacity: 0.5;
}
.ledger th[data-sort].sort-asc::after { content: "▲"; opacity: 1; color: var(--accent); }
.ledger th[data-sort].sort-desc::after { content: "▼"; opacity: 1; color: var(--accent); }
.badge {
display: inline-block;
font-size: 0.72rem;
font-weight: 600;
padding: 0.15rem 0.5rem;
border-radius: 5px;
background: var(--surface-alt);
color: var(--text-dim);
}
.badge.buy { color: var(--gain); background: rgba(61, 220, 132, 0.12); }
.badge.sell { color: var(--loss); background: rgba(255, 107, 94, 0.12); }
.badge.deposit { color: var(--accent); background: rgba(232, 179, 76, 0.12); }
.badge.withdrawal { color: var(--text-dim); background: var(--surface-alt); }
.ledger tbody tr:hover { background: var(--surface-alt); }
.ledger tbody tr:last-child td { border-bottom: none; }
.mono { font-family: var(--font-mono); font-variant-numeric: tabular-nums; }
.chip {
display: inline-block;
font-family: var(--font-mono);
font-size: 0.78rem;
font-weight: 600;
padding: 0.2rem 0.5rem;
border-radius: 6px;
background: var(--surface-alt);
color: var(--text-dim);
}
.chip.gain { color: var(--gain); background: rgba(61, 220, 132, 0.12); }
.chip.loss { color: var(--loss); background: rgba(255, 107, 94, 0.12); }
.gain { color: var(--gain); }
.loss { color: var(--loss); }
.flat { color: var(--text-dim); }
#rows.pulse { animation: pulse 0.6s ease-out; }
@keyframes pulse { from { opacity: 0.55; } to { opacity: 1; } }
.panel {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 10px;
padding: 1.5rem 1.75rem;
margin-bottom: 1.5rem;
}
.panel h2 {
margin-top: 0;
font-size: 1.05rem;
}
.status { color: var(--text-dim); font-size: 0.9rem; }
.status.ok { color: var(--gain); }
.form {
display: flex;
flex-wrap: wrap;
gap: 1rem;
align-items: end;
}
.form.grid label { min-width: 140px; }
.form label {
display: flex;
flex-direction: column;
gap: 0.35rem;
font-size: 0.8rem;
color: var(--text-dim);
flex: 1 1 160px;
}
.form input, .form select {
background: var(--surface-alt);
border: 1px solid var(--border);
border-radius: 6px;
padding: 0.55rem 0.65rem;
color: var(--text);
font-family: var(--font-mono);
font-size: 0.9rem;
}
.form input:focus-visible, .form select:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 1px;
}
button {
background: var(--accent);
color: #14181d;
border: none;
border-radius: 6px;
padding: 0.6rem 1.1rem;
font-weight: 600;
font-size: 0.85rem;
cursor: pointer;
}
button:hover { filter: brightness(1.08); }
button:focus-visible { outline: 2px solid var(--text); outline-offset: 2px; }
.toggle-list {
list-style: none;
padding: 0;
margin: 0;
}
.toggle-list li {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.6rem 0;
border-bottom: 1px solid var(--border);
}
.toggle-list li:last-child { border-bottom: none; }
.toggle {
background: var(--surface-alt);
color: var(--text);
font-weight: 500;
font-size: 0.78rem;
padding: 0.35rem 0.7rem;
}
.toggle.is-hidden { color: var(--text-dim); }
.chart-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 10px;
padding: 1rem 1.25rem 0.5rem;
margin-bottom: 1.5rem;
}
.chart-controls {
display: flex;
gap: 0.35rem;
margin-bottom: 0.5rem;
}
.chart-controls button {
background: var(--surface-alt);
color: var(--text-dim);
font-size: 0.72rem;
font-weight: 600;
padding: 0.3rem 0.6rem;
}
.chart-controls button.active { background: var(--accent); color: #14181d; }
.chart-controls button.mode-toggle { margin-left: auto; }
.chart {
width: 100%;
aspect-ratio: 600 / 220;
height: auto;
display: block;
}
.chart[hidden] { display: none; }
.chart-line {
fill: none;
stroke-width: 1.6;
vector-effect: non-scaling-stroke;
}
.chart-line.gain { stroke: var(--gain); }
.chart-line.loss { stroke: var(--loss); }
.chart-line.flat { stroke: var(--text-dim); }
.chart-grid { stroke: var(--border); stroke-width: 1; stroke-dasharray: 2 3; }
.chart-axis-label {
font-family: var(--font-mono);
font-size: 11px;
fill: var(--text-dim);
}
.chart-axis-y { text-anchor: end; dominant-baseline: middle; }
.chart-axis-x { text-anchor: middle; }
.chart-empty { color: var(--text-dim); font-size: 0.85rem; padding: 2.5rem 0; text-align: center; }
.chart-caption { color: var(--text-dim); font-size: 0.78rem; margin: 0.4rem 0 0.75rem; }
.chart-sublabel { color: var(--text-dim); font-size: 0.72rem; text-transform: uppercase; letter-spacing: 0.06em; margin: 0.75rem 0 0.25rem; }
.candle-chart { aspect-ratio: 600 / 260; }
.rsi-chart { aspect-ratio: 600 / 90; margin-bottom: 0.5rem; }
.candle-wick { stroke-width: 1; vector-effect: non-scaling-stroke; }
.candle-wick.candle-up { stroke: var(--gain); }
.candle-wick.candle-down { stroke: var(--loss); }
.candle-body.candle-up { fill: var(--gain); }
.candle-body.candle-down { fill: var(--loss); }
.bb-band { fill: rgba(139, 147, 161, 0.08); stroke: none; }
.bb-line { fill: none; stroke: var(--text-dim); stroke-width: 1; stroke-dasharray: 3 3; vector-effect: non-scaling-stroke; }
.bb-mid { fill: none; stroke: var(--accent); stroke-width: 1; vector-effect: non-scaling-stroke; }
.rsi-line { fill: none; stroke: var(--accent); stroke-width: 1.4; vector-effect: non-scaling-stroke; }
.staked-note { color: var(--text-dim); font-size: 0.78rem; }
@keyframes live-flash { from { background: rgba(232, 179, 76, 0.18); } to { background: transparent; } }
.flash { animation: live-flash 0.8s ease-out; }
tr.flash { animation: none; } /* row background flash would fight is-favourite tint; cells still flash individually via inherited rule below */
tr[data-ws-symbol].flash td { animation: live-flash 0.8s ease-out; }
@media (max-width: 860px) {
table.ledger thead { display: none; }
table.ledger, table.ledger tbody, table.ledger tr, table.ledger td { display: block; width: 100%; }
.table-scroll { overflow-x: visible; }
table.ledger tr {
border: 1px solid var(--border);
border-radius: 8px;
margin: 0.6rem;
padding: 0.4rem 0.75rem;
}
table.ledger td {
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 1px dashed var(--border);
padding: 0.45rem 0;
text-align: right;
white-space: normal;
}
table.ledger td:last-child { border-bottom: none; }
table.ledger td::before {
content: attr(data-label);
color: var(--text-dim);
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.04em;
text-align: left;
padding-right: 1rem;
}
table.ledger td.star-cell { justify-content: flex-end; padding: 0.3rem 0 !important; }
table.ledger td.star-cell::before { content: ""; }
}
@media (max-width: 640px) {
.topbar { padding: 1rem 1.25rem; }
main { padding: 1.25rem; }
.form { flex-direction: column; align-items: stretch; }
}
@media (prefers-reduced-motion: reduce) {
#rows.pulse { animation: none; }
}
+46
View File
@@ -0,0 +1,46 @@
{{define "content"}}
<section class="panel">
<h2>Account</h2>
{{if .MustChangePassword}}
<p class="notice">You're signed in with the default admin/admin login — set your own username and password below to continue.</p>
{{end}}
<p class="status">Signed in as <strong>{{.Username}}</strong>.</p>
{{if .Error}}<p class="notice error">{{.Error}}</p>{{end}}
<form method="post" action="/account/credentials" class="form">
<label>Current Password <input type="password" name="current_password" autocomplete="current-password" required></label>
<label>New Username <input type="text" name="new_username" value="{{.Username}}" maxlength="32" required></label>
<label>New Password <input type="password" name="new_password" autocomplete="new-password" minlength="8" required></label>
<label>Confirm New Password <input type="password" name="confirm_password" autocomplete="new-password" minlength="8" required></label>
<button type="submit">Save</button>
</form>
<p class="hint">Username: 3-32 characters (letters, numbers, dot, underscore, hyphen). Password: at least 8 characters.</p>
</section>
<section class="panel">
<h2>Two-Factor Authentication (MFA)</h2>
{{if .MFAError}}<p class="notice error">{{.MFAError}}</p>{{end}}
{{if .MFAEnabled}}
<p class="status ok">MFA is enabled — an authenticator code is required at sign-in.</p>
<form method="post" action="/account/mfa/disable">
<button type="submit" class="toggle is-hidden">Disable MFA</button>
</form>
{{else if .MFAPendingSecret}}
<p class="status">Scan this with your authenticator app (Google Authenticator, Authy, 1Password, etc.), or enter the key manually, then enter the 6-digit code it shows to confirm:</p>
{{if .MFAQRDataURI}}<img src="{{.MFAQRDataURI}}" alt="MFA setup QR code" class="qr-code">{{end}}
<p class="mono secret-key">{{.MFAPendingSecret}}</p>
<p class="hint">otpauth URI: <span class="mono">{{.MFAOtpauthURI}}</span></p>
<form method="post" action="/account/mfa/confirm" class="form">
<label>6-digit code <input type="text" name="code" inputmode="numeric" pattern="[0-9]{6}" maxlength="6" autocomplete="one-time-code" required></label>
<button type="submit">Confirm</button>
</form>
<form method="post" action="/account/mfa/disable">
<button type="submit" class="toggle">Cancel setup</button>
</form>
{{else}}
<p class="status">MFA is not enabled. 1 user account is currently supported.</p>
<form method="post" action="/account/mfa/enable">
<button type="submit">Enable MFA</button>
</form>
{{end}}
</section>
{{end}}
+40
View File
@@ -0,0 +1,40 @@
{{define "base"}}<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Basis — Crypto Portfolio</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/static/style.css">
</head>
<body data-page="{{.Page}}" data-current="{{.CurrentTab}}" data-poll="{{.PollSeconds}}" data-currency-symbol="{{curSymbol}}">
<header class="topbar">
<a href="/" class="topbar-total tab-link" data-topbar data-open-tab="portfolio">
<span class="topbar-value" data-field="topbar-value">{{money .TotalValue}}</span>
<span class="topbar-pl {{signClass .TotalPL}}" data-field="topbar-pl">{{money .TotalPL}} ({{printf "%+.2f" .TotalPLPercent}}%)</span>
</a>
<nav>
<a href="/" class="{{if or (eq .Page "settings") (eq .Page "account")}}{{else}}active{{end}}">Dashboard</a>
<a href="/settings" class="{{if eq .Page "settings"}}active{{end}}">Settings</a>
<a href="/account" class="{{if eq .Page "account"}}active{{end}}">Account</a>
<form method="post" action="/logout" class="logout-form"><button type="submit" class="logout-btn">Sign out</button></form>
</nav>
</header>
<main>
{{if or (eq .Page "settings") (eq .Page "account")}}
{{template "content" .}}
{{else}}
<div class="tabbar" id="tabbar" role="tablist"></div>
<div id="tab-panels">
<div class="tab-panel active" data-panel="{{if eq .Page "position"}}pos:{{.CurrentTab}}{{else if eq .Page "transfers"}}transfers{{else}}portfolio{{end}}">
{{template "content" .}}
</div>
</div>
{{end}}
</main>
<script src="/static/app.js"></script>
</body>
</html>
{{end}}
+44
View File
@@ -0,0 +1,44 @@
{{define "content"}}
{{if not .HasCredentials}}
<p class="notice">No Kraken account connected yet — <a href="/settings">add your API key</a> to auto-import trades, or add purchases manually.</p>
{{end}}
<div class="chart-card">
<div class="chart-controls" data-chart="portfolio">
<button type="button" data-range="24h">24H</button>
<button type="button" data-range="7d">7D</button>
<button type="button" data-range="30d">30D</button>
<button type="button" data-range="1y">1Y</button>
<button type="button" data-range="all" class="active">All</button>
</div>
<svg class="chart" data-chart-target="portfolio" data-symbol="{{curSymbol}}" viewBox="0 0 600 220" aria-hidden="true"></svg>
<p class="chart-empty" data-chart-empty="portfolio" hidden>Not enough history yet — check back after a few polling cycles.</p>
<p class="chart-caption" data-chart-caption="portfolio"></p>
</div>
{{if .Rows}}
<div class="table-scroll">
<table class="ledger sortable" data-poll="{{.PollSeconds}}" data-sort-key="dashboard">
<thead>
<tr>
<th class="star-cell"></th>
<th data-sort="currency">Currency</th>
<th data-sort="holdings">Holdings</th>
<th data-sort="avgcost">Avg Cost</th>
<th data-sort="price">Price</th>
<th data-sort="value">Value</th>
<th data-sort="4h">4H</th>
<th data-sort="24h">24H</th>
<th data-sort="7d">7D</th>
<th data-sort="30d">30D</th>
<th data-sort="all">All-time</th>
<th data-sort="pl">P/L</th>
</tr>
</thead>
<tbody id="rows">{{template "rows" .}}</tbody>
</table>
</div>
{{else}}
<p class="empty">No purchases tracked yet. <a href="/settings">Add one</a> or connect Kraken.</p>
{{end}}
{{end}}
@@ -0,0 +1,23 @@
{{define "rows"}}{{range .Rows}}<tr class="{{if .Favourite}}is-favourite{{end}}"
data-currency="{{.Currency}}"
data-ws-symbol="{{if .Pair}}{{wsSymbol .Currency .Quote}}{{end}}"
data-holdings="{{sortVal .TotalAmount}}"
data-cost="{{sortVal .TotalCost}}"
data-fx-rate="{{sortVal .FXRate}}"
data-value="{{sortVal .CurrentValue}}">
<td class="star-cell" data-label="">
<button type="button" class="star-btn {{if .Favourite}}active{{end}}" data-favourite-toggle data-currency="{{.Currency}}" data-favourite="{{if .Favourite}}0{{else}}1{{end}}" aria-label="Toggle favourite" aria-pressed="{{if .Favourite}}true{{else}}false{{end}}">&#9733;</button>
</td>
<td data-label="Currency"><a href="/position/{{.Currency}}" class="tab-link" data-open-tab="{{.Currency}}">{{.Currency}}</a></td>
<!-- {{if .Staked}} <span class="staked-note">({{amt .Staked}} staked)</span>{{end}} -->
<td class="mono" data-label="Holdings" data-sort-value="{{sortVal .TotalAmount}}">{{amt .TotalAmount}}</td>
<td class="mono" data-label="Avg Cost" data-sort-value="{{sortVal .AvgCost}}">{{money .AvgCost}}</td>
<td class="mono" data-label="Price" data-field="price" data-sort-value="{{sortVal .CurrentPrice}}">{{money .CurrentPrice}}</td>
<td class="mono" data-label="Value" data-field="value" data-sort-value="{{sortVal .CurrentValue}}">{{money .CurrentValue}}</td>
<td data-label="4H" data-sort-value="{{sortVal .Change4h}}"><span class="chip {{pctClass .Change4h}}">{{pctStr .Change4h}}</span></td>
<td data-label="24H" data-sort-value="{{sortVal .Change1d}}"><span class="chip {{pctClass .Change1d}}">{{pctStr .Change1d}}</span></td>
<td data-label="7D" data-sort-value="{{sortVal .Change7d}}"><span class="chip {{pctClass .Change7d}}">{{pctStr .Change7d}}</span></td>
<td data-label="30D" data-sort-value="{{sortVal .Change30d}}"><span class="chip {{pctClass .Change30d}}">{{pctStr .Change30d}}</span></td>
<td data-label="All-time" data-sort-value="{{sortVal .ChangeAll}}"><span class="chip {{pctClass .ChangeAll}}">{{pctStr .ChangeAll}}</span></td>
<td class="mono {{signClass .PLDollar}}" data-label="P/L" data-field="pl" data-sort-value="{{sortVal .PLDollar}}">{{money .PLDollar}}</td>
</tr>{{end}}{{end}}
+111
View File
@@ -0,0 +1,111 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Sign in — Basis</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/static/style.css">
</head>
<body class="login-body">
<main class="login-main">
<section class="panel login-panel">
<h1 class="login-title">Basis</h1>
<p class="notice error" id="login-error" hidden></p>
<form id="login-form" class="form">
<label>Username <input type="text" name="username" autocomplete="username" required autofocus></label>
<label>Password <input type="password" name="password" autocomplete="current-password" required></label>
<button type="submit">Sign in</button>
</form>
</section>
</main>
<div class="modal-overlay" id="mfa-overlay" hidden>
<div class="modal">
<h2>Enter authenticator code</h2>
<p class="hint">Your account has MFA enabled — enter the 6-digit code from your authenticator app.</p>
<p class="notice error" id="mfa-error" hidden></p>
<form id="mfa-form" class="form">
<label>Code <input type="text" name="code" inputmode="numeric" pattern="[0-9]{6}" maxlength="6" autocomplete="one-time-code" required></label>
<button type="submit">Verify</button>
</form>
</div>
</div>
<script>
(function () {
"use strict";
var loginForm = document.getElementById("login-form");
var loginError = document.getElementById("login-error");
var overlay = document.getElementById("mfa-overlay");
var mfaForm = document.getElementById("mfa-form");
var mfaError = document.getElementById("mfa-error");
var pendingToken = "";
function postForm(url, fields) {
var body = Object.keys(fields).map(function (k) {
return encodeURIComponent(k) + "=" + encodeURIComponent(fields[k]);
}).join("&");
return fetch(url, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: body,
}).then(function (r) { return r.json(); });
}
function showError(el, msg) {
el.textContent = msg;
el.hidden = false;
}
loginForm.addEventListener("submit", function (e) {
e.preventDefault();
loginError.hidden = true;
var fd = new FormData(loginForm);
postForm("/login", { username: fd.get("username"), password: fd.get("password") }).then(function (res) {
if (res.error) {
showError(loginError, res.error);
return;
}
if (res.mfa_required) {
pendingToken = res.pending_token;
overlay.hidden = false;
mfaForm.querySelector('input[name="code"]').focus();
return;
}
window.location.href = res.redirect || "/";
}).catch(function () {
showError(loginError, "Something went wrong — try again.");
});
});
mfaForm.addEventListener("submit", function (e) {
e.preventDefault();
mfaError.hidden = true;
var fd = new FormData(mfaForm);
postForm("/login/mfa", { pending_token: pendingToken, code: fd.get("code") }).then(function (res) {
if (res.error) {
showError(mfaError, res.error);
return;
}
window.location.href = res.redirect || "/";
}).catch(function () {
showError(mfaError, "Something went wrong — try again.");
});
});
overlay.addEventListener("click", function (e) {
if (e.target === overlay) {
overlay.hidden = true;
pendingToken = "";
mfaForm.reset();
mfaError.hidden = true;
}
});
})();
</script>
</body>
</html>
+74
View File
@@ -0,0 +1,74 @@
{{define "content"}}
<section class="hero" data-ws-hero="{{if .Pair}}{{wsSymbol .Currency .Quote}}{{end}}" data-holdings="{{sortVal .Amount}}" data-cost="{{sortVal .Cost}}" data-fx-rate="{{sortVal .FXRate}}">
<div class="hero-label"><a href="/" class="back tab-link" data-open-tab="portfolio">&larr; Dashboard</a></div>
<div class="hero-value">{{.Currency}}</div>
<div class="hero-sub">
<span class="mono">{{amt .Amount}} held{{if .Staked}} ({{amt .Staked}} staked){{end}}</span> &middot;
<span class="mono" data-field="hero-value">{{money .Value}} value</span> &middot;
<span class="mono {{signClass .PLDollar}}" data-field="hero-pl">{{money .PLDollar}} ({{printf "%+.2f" .PLPercent}}%)</span>
</div>
</section>
<div class="chart-card" data-advanced-chart data-currency="{{.Currency}}">
<div class="chart-controls" data-chart="currency" data-currency="{{.Currency}}">
<button type="button" data-range="24h">24H</button>
<button type="button" data-range="7d">7D</button>
<button type="button" data-range="30d">30D</button>
<button type="button" data-range="1y">1Y</button>
<button type="button" data-range="all" class="active">All</button>
<button type="button" class="mode-toggle" data-chart-mode-toggle>Advanced view</button>
</div>
<div data-chart-simple>
<svg class="chart" data-chart-target="currency:{{.Currency}}" data-symbol="{{curSymbol}}" viewBox="0 0 600 220" aria-hidden="true"></svg>
<p class="chart-empty" data-chart-empty="currency:{{.Currency}}" hidden>Not enough price history yet for this range.</p>
<p class="chart-caption" data-chart-caption="currency:{{.Currency}}"></p>
</div>
<div data-chart-advanced hidden>
<svg class="chart candle-chart" data-candle-price viewBox="0 0 600 260" aria-hidden="true"></svg>
<p class="chart-empty" data-candle-empty hidden></p>
<p class="chart-sublabel">RSI (14)</p>
<svg class="chart rsi-chart" data-candle-rsi viewBox="0 0 600 90" aria-hidden="true"></svg>
<p class="hint">Bollinger Bands (20, 2) and RSI computed from Kraken's own OHLC candles, in the pair's native price.</p>
</div>
</div>
{{if .Entries}}
<div class="table-scroll">
<table class="ledger sortable" data-sort-key="position:{{.Currency}}">
<thead>
<tr>
<th data-sort="date">Date</th>
<th data-sort="type">Type</th>
<th data-sort="source">Source</th>
<th data-sort="amount">Amount</th>
<th data-sort="price">Price</th>
<th data-sort="fee">Fee</th>
<th data-sort="cost">Cost / Proceeds</th>
<th data-sort="value">Current Value</th>
<th data-sort="pl">P/L</th>
</tr>
</thead>
<tbody>
{{range .Entries}}
<tr>
<td data-label="Date" data-sort-value="{{.OccurredAt.Unix}}">{{dateStr .OccurredAt}}</td>
<td data-label="Type"><span class="badge {{.EntryType}}">{{capitalize .EntryType}}</span></td>
<td class="source" data-label="Source">{{.Source}}</td>
<td class="mono" data-label="Amount" data-sort-value="{{sortVal .Amount}}">{{amt .Amount}}</td>
<td class="mono" data-label="Price" data-sort-value="{{sortVal .Price}}">{{if .Price}}{{money .Price}}{{else}}&mdash;{{end}}</td>
<td class="mono" data-label="Fee" data-sort-value="{{sortVal .Fee}}">{{money .Fee}}</td>
<td class="mono" data-label="Cost / Proceeds" data-sort-value="{{sortVal .Cost}}">{{if .Cost}}{{money .Cost}}{{else}}&mdash;{{end}}</td>
<td class="mono" data-label="Current Value" data-sort-value="{{sortVal .CurrentValue}}">{{money .CurrentValue}}</td>
<td class="mono {{if eq .EntryType "buy"}}{{signClass .PLDollar}}{{end}}" data-label="P/L" data-sort-value="{{if eq .EntryType "buy"}}{{sortVal .PLDollar}}{{end}}">
{{if eq .EntryType "buy"}}{{money .PLDollar}} ({{printf "%+.1f" .PLPercent}}%){{else}}&mdash;{{end}}
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
<p class="hint">A buy row's P/L assumes that specific purchase is still fully held — the total above (which matches your live Kraken balance when connected) is the accurate figure once you've sold or moved coins around.</p>
{{else}}
<p class="empty">No activity recorded for {{.Currency}} yet.</p>
{{end}}
{{end}}
+74
View File
@@ -0,0 +1,74 @@
{{define "content"}}
<section class="panel">
<h2>Base Currency</h2>
<p class="status">All values are shown in <strong>{{.BaseCurrency}}</strong>.</p>
<form method="post" action="/settings/base-currency" class="form">
<label>Base Currency
<input type="text" name="base_currency" list="currency-options" value="{{.BaseCurrency}}" maxlength="3" required>
</label>
<datalist id="currency-options">
<option value="GBP"><option value="USD"><option value="EUR"><option value="JPY">
<option value="CHF"><option value="CAD"><option value="AUD">
</datalist>
<button type="submit">Save</button>
</form>
<p class="hint">Prices convert to this currency using Kraken's own FX crosses, refreshed on the same cycle as prices.</p>
</section>
<section class="panel">
<h2>Kraken Connection</h2>
{{if .HasCredentials}}
<p class="status ok">Connected &mdash; credentials encrypted at rest.</p>
{{else}}
<p class="status">Not connected.</p>
{{end}}
<form method="post" action="/settings/credentials" class="form">
<label>API Key <input type="text" name="api_key" autocomplete="off" placeholder="{{if .HasCredentials}}leave blank to keep current{{else}}Kraken API key{{end}}"></label>
<label>API Secret <input type="password" name="api_secret" autocomplete="off" placeholder="{{if .HasCredentials}}leave blank to keep current{{else}}Kraken API secret{{end}}"></label>
<button type="submit">Save</button>
</form>
<p class="hint">Use a Kraken API key with only "Query Funds" and "Query Closed Orders &amp; Trades" permissions &mdash; no withdrawal or trading rights are needed for read-only monitoring.</p>
</section>
<section class="panel">
<h2>Add a Ledger Entry</h2>
<form method="post" action="/settings/purchase" class="form grid">
<label>Type
<select name="entry_type">
<option value="buy">Buy</option>
<option value="sell">Sell</option>
<option value="deposit">Deposit</option>
<option value="withdrawal">Withdrawal</option>
</select>
</label>
<label>Currency <input type="text" name="currency" placeholder="BTC" required></label>
<label>Kraken Pair <input type="text" name="pair" placeholder="XBTUSD" required></label>
<label>Amount <input type="text" name="amount" placeholder="0.5" required></label>
<label>Price (buy/sell only) <input type="text" name="price" placeholder="42000"></label>
<label>Fee <input type="text" name="fee" placeholder="0"></label>
<label>Date <input type="date" name="occurred_at" required></label>
<button type="submit">Add Entry</button>
</form>
<p class="hint">The pair must be a market Kraken lists (used to fetch price data), even for a crypto deposit/withdrawal or a coin bought elsewhere. Price is only needed for buy/sell. Leave Pair blank for a real-money (GBP/USD/EUR) deposit or withdrawal — that shows on the Transfers tab instead.</p>
</section>
<section class="panel">
<h2>Visible Currencies</h2>
{{if .Currencies}}
<ul class="toggle-list">
{{range .Currencies}}
<li>
<span>{{.Name}}</span>
<form method="post" action="/settings/hidden">
<input type="hidden" name="currency" value="{{.Name}}">
<input type="hidden" name="hidden" value="{{if .Hidden}}0{{else}}1{{end}}">
<button type="submit" class="toggle {{if .Hidden}}is-hidden{{end}}">{{if .Hidden}}Hidden &mdash; show{{else}}Visible &mdash; hide{{end}}</button>
</form>
</li>
{{end}}
</ul>
{{else}}
<p class="empty">No currencies tracked yet.</p>
{{end}}
</section>
{{end}}
+37
View File
@@ -0,0 +1,37 @@
{{define "content"}}
<section class="hero">
<div class="hero-label">Account Funding</div>
<div class="hero-sub">Real-money deposits and withdrawals to/from Kraken (GBP, USD, EUR, ...) — how much cash you've put in or taken out, separate from what your crypto is worth. Crypto deposits/withdrawals show on that coin's own page.</div>
</section>
{{if .Transfers}}
<div class="table-scroll">
<table class="ledger sortable" data-sort-key="transfers">
<thead>
<tr>
<th data-sort="date">Date</th>
<th data-sort="currency">Currency</th>
<th data-sort="type">Type</th>
<th data-sort="source">Source</th>
<th data-sort="amount">Amount</th>
<th data-sort="value">Value</th>
</tr>
</thead>
<tbody>
{{range .Transfers}}
<tr>
<td data-label="Date" data-sort-value="{{.OccurredAt.Unix}}">{{dateStr .OccurredAt}}</td>
<td data-label="Currency">{{.Currency}}</td>
<td data-label="Type"><span class="badge {{.EntryType}}">{{capitalize .EntryType}}</span></td>
<td class="source" data-label="Source">{{.Source}}</td>
<td class="mono" data-label="Amount" data-sort-value="{{sortVal .Amount}}">{{amt .Amount}}</td>
<td class="mono" data-label="Value" data-sort-value="{{sortVal .CurrentValue}}">{{money .CurrentValue}}</td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{else}}
<p class="empty">No account funding recorded yet.</p>
{{end}}
{{end}}
+777
View File
@@ -0,0 +1,777 @@
// Package web serves the dashboard, position-detail, and settings pages.
// The dashboard and position pages also expose "panel" fragment endpoints
// (just the inner content, no page chrome) that the client-side tab system
// and auto-refresh use to swap content without a full page load.
package web
import (
"embed"
"encoding/base64"
"encoding/json"
"html/template"
"log/slog"
"net/http"
"strconv"
"strings"
"time"
qrcode "github.com/skip2/go-qrcode"
"cryptomon/internal/authsvc"
"cryptomon/internal/kraken"
"cryptomon/internal/portfolio"
)
//go:embed templates/*.html
var templatesFS embed.FS
//go:embed static
var staticFS embed.FS
const sessionCookieName = "basis_session"
type Server struct {
svc *portfolio.Service
auth *authsvc.Service
pollSeconds int
dashboard *template.Template
position *template.Template
transfers *template.Template
settings *template.Template
account *template.Template
login *template.Template
}
func NewServer(svc *portfolio.Service, authSvc *authsvc.Service, pollSeconds int) *Server {
funcs := template.FuncMap{
"money": func(v float64) string { return money(v, svc.BaseCurrency()) },
"curSymbol": func() string { return currencySymbol(svc.BaseCurrency()) },
"amt": amt,
"pctStr": pctStr,
"pctClass": pctClass,
"signClass": signClass,
"dateStr": dateStr,
"holdDuration": holdDuration,
"capitalize": capitalize,
"sortVal": sortVal,
"wsSymbol": kraken.WSSymbol,
}
parse := func(files ...string) *template.Template {
all := append([]string{"templates/base.html"}, files...)
return template.Must(template.New("base").Funcs(funcs).ParseFS(templatesFS, all...))
}
return &Server{
svc: svc,
auth: authSvc,
pollSeconds: pollSeconds,
dashboard: parse("templates/dashboard.html", "templates/dashboard_rows.html"),
position: parse("templates/position.html"),
transfers: parse("templates/transfers.html"),
settings: parse("templates/settings.html"),
account: parse("templates/account.html"),
login: template.Must(template.New("login.html").ParseFS(templatesFS, "templates/login.html")),
}
}
func (s *Server) Handler() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /{$}", s.handleDashboard)
mux.HandleFunc("GET /position/{currency}", s.handlePosition)
mux.HandleFunc("GET /transfers", s.handleTransfers)
mux.HandleFunc("GET /settings", s.handleSettingsGet)
mux.HandleFunc("GET /api/panel/portfolio", s.handlePanelPortfolio)
mux.HandleFunc("GET /api/panel/position/{currency}", s.handlePanelPosition)
mux.HandleFunc("GET /api/panel/transfers", s.handlePanelTransfers)
mux.HandleFunc("GET /api/chart/portfolio", s.handleChartPortfolio)
mux.HandleFunc("GET /api/chart/currency/{currency}", s.handleChartCurrency)
mux.HandleFunc("GET /api/candles/{currency}", s.handleCandles)
mux.HandleFunc("GET /api/summary", s.handleSummary)
mux.HandleFunc("POST /api/favourite", s.handleFavourite)
mux.HandleFunc("POST /settings/credentials", s.handleSaveCredentials)
mux.HandleFunc("POST /settings/purchase", s.handleAddEntry)
mux.HandleFunc("POST /settings/hidden", s.handleSetHidden)
mux.HandleFunc("POST /settings/base-currency", s.handleSetBaseCurrency)
mux.HandleFunc("GET /login", s.handleLoginGet)
mux.HandleFunc("POST /login", s.handleLoginPost)
mux.HandleFunc("POST /login/mfa", s.handleLoginMFA)
mux.HandleFunc("POST /logout", s.handleLogout)
mux.HandleFunc("GET /account", s.handleAccountGet)
mux.HandleFunc("POST /account/credentials", s.handleAccountCredentials)
mux.HandleFunc("POST /account/mfa/enable", s.handleMFAEnable)
mux.HandleFunc("POST /account/mfa/confirm", s.handleMFAConfirm)
mux.HandleFunc("POST /account/mfa/disable", s.handleMFADisable)
mux.Handle("GET /static/", http.FileServerFS(staticFS))
return s.authMiddleware(mux)
}
// authMiddleware gates every route except /login and /static/*: no valid
// session cookie redirects to /login (or 401s for /api/* so fetch() calls
// fail loudly instead of getting an HTML login page as "JSON"), and a valid
// session with a pending forced password change is confined to /account
// until it's resolved.
func (s *Server) authMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
path := r.URL.Path
if path == "/login" || path == "/login/mfa" || strings.HasPrefix(path, "/static/") {
next.ServeHTTP(w, r)
return
}
valid, mustChange := false, false
if cookie, err := r.Cookie(sessionCookieName); err == nil {
valid, mustChange, _ = s.auth.CheckSession(cookie.Value)
}
if !valid {
if strings.HasPrefix(path, "/api/") {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
if mustChange && path != "/account" && path != "/logout" && !strings.HasPrefix(path, "/account/") {
http.Redirect(w, r, "/account", http.StatusSeeOther)
return
}
next.ServeHTTP(w, r)
})
}
func (s *Server) setSessionCookie(w http.ResponseWriter, username string, version int) {
http.SetCookie(w, &http.Cookie{
Name: sessionCookieName,
Value: s.auth.NewSession(username, version),
Path: "/",
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
MaxAge: int(authsvc.SessionTTL.Seconds()),
})
}
func (s *Server) handleLoginGet(w http.ResponseWriter, r *http.Request) {
if cookie, err := r.Cookie(sessionCookieName); err == nil {
if valid, _, _ := s.auth.CheckSession(cookie.Value); valid {
http.Redirect(w, r, "/", http.StatusSeeOther)
return
}
}
s.renderLogin(w)
}
// loginStepResponse backs both /login and /login/mfa: the page is a single
// form (username+password only) whose JS drives a second step — an in-page
// modal asking for the authenticator code — only when the server says MFA
// is required, rather than always showing that field up front.
type loginStepResponse struct {
Error string `json:"error,omitempty"`
MFARequired bool `json:"mfa_required,omitempty"`
PendingToken string `json:"pending_token,omitempty"`
Redirect string `json:"redirect,omitempty"`
}
func loginRedirect(mustChange bool) string {
if mustChange {
return "/account"
}
return "/"
}
func (s *Server) handleLoginPost(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
s.fail(w, err)
return
}
username := r.FormValue("username")
ok, err := s.auth.VerifyPassword(username, r.FormValue("password"))
if err != nil {
s.fail(w, err)
return
}
if !ok {
s.writeJSON(w, loginStepResponse{Error: "Invalid username or password."})
return
}
mustChange, version, mfaEnabled, err := s.auth.LoginStatus()
if err != nil {
s.fail(w, err)
return
}
if mfaEnabled {
s.writeJSON(w, loginStepResponse{MFARequired: true, PendingToken: s.auth.NewPendingMFAToken(username)})
return
}
s.setSessionCookie(w, username, version)
s.writeJSON(w, loginStepResponse{Redirect: loginRedirect(mustChange)})
}
func (s *Server) handleLoginMFA(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
s.fail(w, err)
return
}
ok, username, mustChange, version, err := s.auth.CompleteMFALogin(r.FormValue("pending_token"), r.FormValue("code"))
if err != nil {
s.fail(w, err)
return
}
if !ok {
s.writeJSON(w, loginStepResponse{Error: "Invalid authenticator code."})
return
}
s.setSessionCookie(w, username, version)
s.writeJSON(w, loginStepResponse{Redirect: loginRedirect(mustChange)})
}
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
http.SetCookie(w, &http.Cookie{Name: sessionCookieName, Value: "", Path: "/", MaxAge: -1})
http.Redirect(w, r, "/login", http.StatusSeeOther)
}
type accountData struct {
pageMeta
Username string
MustChangePassword bool
Error string
MFAEnabled bool
MFAPendingSecret string
MFAOtpauthURI string
MFAQRDataURI template.URL
MFAError string
}
func (s *Server) loadAccount() (accountData, error) {
meta, _, err := s.newPageMeta("account", "")
if err != nil {
return accountData{}, err
}
username, err := s.auth.Username()
if err != nil {
return accountData{}, err
}
mustChange, err := s.auth.MustChangePassword()
if err != nil {
return accountData{}, err
}
mfaEnabled, err := s.auth.MFAEnabled()
if err != nil {
return accountData{}, err
}
d := accountData{pageMeta: meta, Username: username, MustChangePassword: mustChange, MFAEnabled: mfaEnabled}
if !mfaEnabled {
secret, uri, ok, err := s.auth.PendingMFASecret()
if err != nil {
return accountData{}, err
}
if ok {
d.MFAPendingSecret = secret
d.MFAOtpauthURI = uri
if qr, err := qrDataURI(uri); err == nil {
d.MFAQRDataURI = template.URL(qr)
} else {
slog.Error("generate MFA QR code", "err", err)
}
}
}
return d, nil
}
func (s *Server) handleAccountGet(w http.ResponseWriter, r *http.Request) {
d, err := s.loadAccount()
if err != nil {
s.fail(w, err)
return
}
s.render(w, s.account, d)
}
func (s *Server) renderAccountError(w http.ResponseWriter, msg string) {
d, err := s.loadAccount()
if err != nil {
s.fail(w, err)
return
}
d.Error = msg
s.render(w, s.account, d)
}
func (s *Server) renderAccountMFAError(w http.ResponseWriter, msg string) {
d, err := s.loadAccount()
if err != nil {
s.fail(w, err)
return
}
d.MFAError = msg
s.render(w, s.account, d)
}
func (s *Server) handleAccountCredentials(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
s.fail(w, err)
return
}
currentUsername, err := s.auth.Username()
if err != nil {
s.fail(w, err)
return
}
ok, err := s.auth.VerifyPassword(currentUsername, r.FormValue("current_password"))
if err != nil {
s.fail(w, err)
return
}
if !ok {
s.renderAccountError(w, "Current password is incorrect.")
return
}
newUsername := r.FormValue("new_username")
version, err := s.auth.SetCredentials(newUsername, r.FormValue("new_password"), r.FormValue("confirm_password"))
if err != nil {
s.renderAccountError(w, err.Error())
return
}
s.setSessionCookie(w, newUsername, version)
http.Redirect(w, r, "/account", http.StatusSeeOther)
}
func (s *Server) handleMFAEnable(w http.ResponseWriter, r *http.Request) {
if _, _, err := s.auth.BeginMFA(); err != nil {
s.fail(w, err)
return
}
http.Redirect(w, r, "/account", http.StatusSeeOther)
}
func (s *Server) handleMFAConfirm(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
s.fail(w, err)
return
}
if err := s.auth.ConfirmMFA(r.FormValue("code")); err != nil {
s.renderAccountMFAError(w, err.Error())
return
}
http.Redirect(w, r, "/account", http.StatusSeeOther)
}
func (s *Server) handleMFADisable(w http.ResponseWriter, r *http.Request) {
if err := s.auth.DisableMFA(); err != nil {
s.fail(w, err)
return
}
http.Redirect(w, r, "/account", http.StatusSeeOther)
}
// qrDataURI renders content as a PNG QR code, returned as a data: URI ready
// for an <img src>, so no extra route/asset is needed to serve it.
func qrDataURI(content string) (string, error) {
png, err := qrcode.Encode(content, qrcode.Medium, 240)
if err != nil {
return "", err
}
return "data:image/png;base64," + base64.StdEncoding.EncodeToString(png), nil
}
func (s *Server) renderLogin(w http.ResponseWriter) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := s.login.Execute(w, nil); err != nil {
slog.Error("render login", "err", err)
}
}
// pageMeta is embedded in every page's data struct so base.html can read
// .Page/.CurrentTab/.PollSeconds via Go's promoted-field lookup regardless
// of which concrete struct it's rendering.
type pageMeta struct {
Page string // "portfolio" | "position" | "transfers" | "settings"
CurrentTab string // currency code, when Page == "position"
PollSeconds int
// Portfolio totals shown live in the topbar on every page.
TotalValue float64
TotalPL float64
TotalPLPercent float64
}
// newPageMeta builds the pageMeta shared by every page, and returns the
// dashboard rows it computed along the way so callers that also need them
// (the dashboard page itself) don't run the aggregation twice.
func (s *Server) newPageMeta(page, currentTab string) (pageMeta, []portfolio.DashboardRow, error) {
rows, err := s.svc.Dashboard(false)
if err != nil {
return pageMeta{}, nil, err
}
m := pageMeta{Page: page, CurrentTab: currentTab, PollSeconds: s.pollSeconds}
var totalCost float64
for _, r := range rows {
m.TotalValue += r.CurrentValue
totalCost += r.TotalCost
}
m.TotalPL = m.TotalValue - totalCost
if totalCost != 0 {
m.TotalPLPercent = m.TotalPL / totalCost * 100
}
return m, rows, nil
}
type dashboardData struct {
pageMeta
Rows []portfolio.DashboardRow
HasCredentials bool
}
func (s *Server) loadDashboard() (dashboardData, error) {
meta, rows, err := s.newPageMeta("portfolio", "")
if err != nil {
return dashboardData{}, err
}
return dashboardData{
pageMeta: meta,
Rows: rows,
HasCredentials: s.svc.HasCredentials(),
}, nil
}
func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
d, err := s.loadDashboard()
if err != nil {
s.fail(w, err)
return
}
s.render(w, s.dashboard, d)
}
func (s *Server) handlePanelPortfolio(w http.ResponseWriter, r *http.Request) {
d, err := s.loadDashboard()
if err != nil {
s.fail(w, err)
return
}
s.renderContent(w, s.dashboard, d)
}
type positionData struct {
pageMeta
Currency string
Entries []portfolio.LedgerRow
portfolio.CurrencySummary
}
func (s *Server) loadPosition(currency string) (positionData, error) {
entries, err := s.svc.Position(currency)
if err != nil {
return positionData{}, err
}
summary, err := s.svc.CurrencySummary(currency)
if err != nil {
return positionData{}, err
}
meta, _, err := s.newPageMeta("position", currency)
if err != nil {
return positionData{}, err
}
return positionData{
pageMeta: meta,
Currency: currency,
Entries: entries,
CurrencySummary: summary,
}, nil
}
func (s *Server) handlePosition(w http.ResponseWriter, r *http.Request) {
d, err := s.loadPosition(r.PathValue("currency"))
if err != nil {
s.fail(w, err)
return
}
s.render(w, s.position, d)
}
func (s *Server) handlePanelPosition(w http.ResponseWriter, r *http.Request) {
d, err := s.loadPosition(r.PathValue("currency"))
if err != nil {
s.fail(w, err)
return
}
s.renderContent(w, s.position, d)
}
type transfersData struct {
pageMeta
Transfers []portfolio.TransferRow
}
func (s *Server) loadTransfers() (transfersData, error) {
rows, err := s.svc.Transfers()
if err != nil {
return transfersData{}, err
}
meta, _, err := s.newPageMeta("transfers", "")
if err != nil {
return transfersData{}, err
}
return transfersData{
pageMeta: meta,
Transfers: rows,
}, nil
}
func (s *Server) handleTransfers(w http.ResponseWriter, r *http.Request) {
d, err := s.loadTransfers()
if err != nil {
s.fail(w, err)
return
}
s.render(w, s.transfers, d)
}
func (s *Server) handlePanelTransfers(w http.ResponseWriter, r *http.Request) {
d, err := s.loadTransfers()
if err != nil {
s.fail(w, err)
return
}
s.renderContent(w, s.transfers, d)
}
type currencyRow struct {
Name string
Hidden bool
}
type settingsData struct {
pageMeta
HasCredentials bool
BaseCurrency string
Currencies []currencyRow
}
func (s *Server) loadSettings() (settingsData, error) {
rows, err := s.svc.Dashboard(true)
if err != nil {
return settingsData{}, err
}
meta, _, err := s.newPageMeta("settings", "")
if err != nil {
return settingsData{}, err
}
d := settingsData{
pageMeta: meta,
HasCredentials: s.svc.HasCredentials(),
BaseCurrency: s.svc.BaseCurrency(),
}
for _, r := range rows {
d.Currencies = append(d.Currencies, currencyRow{Name: r.Currency, Hidden: r.Hidden})
}
return d, nil
}
func (s *Server) handleSettingsGet(w http.ResponseWriter, r *http.Request) {
d, err := s.loadSettings()
if err != nil {
s.fail(w, err)
return
}
s.render(w, s.settings, d)
}
func (s *Server) handleSaveCredentials(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
s.fail(w, err)
return
}
apiKey := r.FormValue("api_key")
apiSecret := r.FormValue("api_secret")
if apiKey != "" && apiSecret != "" {
if err := s.svc.SaveCredentials(apiKey, apiSecret); err != nil {
s.fail(w, err)
return
}
}
http.Redirect(w, r, "/settings", http.StatusSeeOther)
}
var validEntryTypes = map[string]bool{"buy": true, "sell": true, "deposit": true, "withdrawal": true}
func (s *Server) handleAddEntry(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
s.fail(w, err)
return
}
entryType := r.FormValue("entry_type")
currency := r.FormValue("currency")
pair := r.FormValue("pair")
amount, _ := strconv.ParseFloat(r.FormValue("amount"), 64)
price, _ := strconv.ParseFloat(r.FormValue("price"), 64)
fee, _ := strconv.ParseFloat(r.FormValue("fee"), 64)
occurredAt, err := time.Parse("2006-01-02", r.FormValue("occurred_at"))
if err != nil {
occurredAt = time.Now()
}
needsPrice := entryType == "buy" || entryType == "sell"
needsPair := !kraken.IsFiat(currency) // a real-money (fiat) deposit/withdrawal has no market to price against
if validEntryTypes[entryType] && currency != "" && (!needsPair || pair != "") && amount > 0 && (!needsPrice || price > 0) {
if !needsPair {
pair = ""
}
if err := s.svc.AddManualEntry(entryType, currency, pair, amount, price, fee, occurredAt); err != nil {
s.fail(w, err)
return
}
}
http.Redirect(w, r, "/settings", http.StatusSeeOther)
}
func (s *Server) handleSetHidden(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
s.fail(w, err)
return
}
currency := r.FormValue("currency")
hidden := r.FormValue("hidden") == "1"
if currency != "" {
if err := s.svc.SetHidden(currency, hidden); err != nil {
s.fail(w, err)
return
}
}
http.Redirect(w, r, "/settings", http.StatusSeeOther)
}
func (s *Server) handleSetBaseCurrency(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
s.fail(w, err)
return
}
if v := r.FormValue("base_currency"); v != "" {
if err := s.svc.SetBaseCurrency(v); err != nil {
s.fail(w, err)
return
}
}
http.Redirect(w, r, "/settings", http.StatusSeeOther)
}
// handleFavourite is called via fetch() from inside the tab panels, so it
// responds with a status code only rather than redirecting.
func (s *Server) handleFavourite(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
s.fail(w, err)
return
}
currency := r.FormValue("currency")
if currency == "" {
http.Error(w, "currency required", http.StatusBadRequest)
return
}
if err := s.svc.SetFavourite(currency, r.FormValue("favourite") == "1"); err != nil {
s.fail(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
type summaryPosition struct {
WSSymbol string `json:"ws_symbol"`
Holdings float64 `json:"holdings"`
Cost float64 `json:"cost"`
Value float64 `json:"value"`
FXRate float64 `json:"fx_rate"`
}
type summaryResponse struct {
TotalValue float64 `json:"total_value"`
TotalPL float64 `json:"total_pl"`
TotalPLPercent float64 `json:"total_pl_percent"`
Positions []summaryPosition `json:"positions"`
}
// handleSummary backs the topbar's live portfolio total, which is shown on
// every page independent of which tab/panel is open.
func (s *Server) handleSummary(w http.ResponseWriter, r *http.Request) {
meta, rows, err := s.newPageMeta("", "")
if err != nil {
s.fail(w, err)
return
}
resp := summaryResponse{
TotalValue: meta.TotalValue,
TotalPL: meta.TotalPL,
TotalPLPercent: meta.TotalPLPercent,
Positions: make([]summaryPosition, 0, len(rows)),
}
for _, row := range rows {
resp.Positions = append(resp.Positions, summaryPosition{
WSSymbol: kraken.WSSymbol(row.Currency, row.Quote),
Holdings: row.TotalAmount,
Cost: row.TotalCost,
Value: row.CurrentValue,
FXRate: row.FXRate,
})
}
s.writeJSON(w, resp)
}
func (s *Server) handleChartPortfolio(w http.ResponseWriter, r *http.Request) {
points, err := s.svc.PortfolioHistory(chartRange(r))
if err != nil {
s.fail(w, err)
return
}
if points == nil {
points = []portfolio.ChartPoint{}
}
s.writeJSON(w, points)
}
func (s *Server) handleChartCurrency(w http.ResponseWriter, r *http.Request) {
points, err := s.svc.CurrencyHistory(r.PathValue("currency"), chartRange(r))
if err != nil {
s.fail(w, err)
return
}
if points == nil {
points = []portfolio.ChartPoint{}
}
s.writeJSON(w, points)
}
func (s *Server) handleCandles(w http.ResponseWriter, r *http.Request) {
series, err := s.svc.CandleSeries(r.PathValue("currency"), chartRange(r))
if err != nil {
s.fail(w, err)
return
}
s.writeJSON(w, series)
}
func chartRange(r *http.Request) string {
if v := r.URL.Query().Get("range"); v != "" {
return v
}
return "all"
}
func (s *Server) writeJSON(w http.ResponseWriter, v any) {
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(v); err != nil {
slog.Error("encode json", "err", err)
}
}
func (s *Server) render(w http.ResponseWriter, t *template.Template, data any) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := t.ExecuteTemplate(w, "base", data); err != nil {
slog.Error("render template", "err", err)
}
}
func (s *Server) renderContent(w http.ResponseWriter, t *template.Template, data any) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := t.ExecuteTemplate(w, "content", data); err != nil {
slog.Error("render content", "err", err)
}
}
func (s *Server) fail(w http.ResponseWriter, err error) {
slog.Error("handler error", "err", err)
http.Error(w, "something went wrong: "+err.Error(), http.StatusInternalServerError)
}
+125
View File
@@ -0,0 +1,125 @@
// cryptomon: read-only crypto portfolio dashboard backed by Kraken.
// See data/settings.conf (auto-created next to the executable) for config.
package main
import (
"flag"
"fmt"
"log/slog"
"net/http"
"os"
"path/filepath"
"time"
"cryptomon/internal/authsvc"
"cryptomon/internal/config"
"cryptomon/internal/portfolio"
"cryptomon/internal/store"
"cryptomon/internal/web"
)
func main() {
userReset := flag.Bool("userreset", false, "reset login to the default admin/admin and disable MFA, then exit")
flag.Parse()
exe, err := os.Executable()
if err != nil {
fatal("locate executable", err)
}
baseDir := filepath.Dir(exe)
dataDir := filepath.Join(baseDir, "data")
if err := os.MkdirAll(dataDir, 0700); err != nil {
fatal("create data dir", err)
}
cfg, err := config.Load(filepath.Join(dataDir, "settings.conf"))
if err != nil {
fatal("load config", err)
}
dbPath := cfg.DBPath
if !filepath.IsAbs(dbPath) {
dbPath = filepath.Join(baseDir, dbPath)
}
st, err := store.Open(dbPath)
if err != nil {
fatal("open database", err)
}
defer st.Close()
authSvc := authsvc.New(st, cfg)
if *userReset {
if err := authSvc.ResetToDefault(); err != nil {
fatal("reset login", err)
}
fmt.Printf("Login reset to %s/%s — you'll be asked to set a new username and password on next sign-in.\n",
authsvc.DefaultUsername, authsvc.DefaultPassword)
return
}
if err := authSvc.EnsureDefault(); err != nil {
fatal("seed default login", err)
}
svc := portfolio.New(st, cfg)
if err := svc.BackfillQuotes(); err != nil {
slog.Warn("backfill purchase quote currencies", "err", err)
}
if err := svc.NormalizeStakedCurrencies(); err != nil {
slog.Warn("normalize staked currencies", "err", err)
}
go runPoller(svc, cfg)
srv := web.NewServer(svc, authSvc, cfg.PollIntervalSeconds)
addr := fmt.Sprintf(":%d", cfg.Port)
slog.Info("cryptomon starting", "addr", addr, "data_dir", dataDir)
if err := http.ListenAndServe(addr, srv.Handler()); err != nil {
fatal("serve", err)
}
}
func runPoller(svc *portfolio.Service, cfg *config.Config) {
syncFast := func() {
if err := svc.SyncTrades(); err != nil {
slog.Error("sync trades", "err", err)
}
if err := svc.SyncTransfers(); err != nil {
slog.Error("sync transfers", "err", err)
}
if err := svc.SyncBalance(); err != nil {
slog.Error("sync balance", "err", err)
}
if err := svc.SyncPrices(); err != nil {
slog.Error("sync prices", "err", err)
}
}
syncSlow := func() {
if err := svc.SyncHistory(); err != nil {
slog.Error("sync history", "err", err)
}
}
syncFast()
syncSlow()
fastTicker := time.NewTicker(time.Duration(cfg.PollIntervalSeconds) * time.Second)
defer fastTicker.Stop()
slowTicker := time.NewTicker(time.Duration(cfg.HistoryRefreshSeconds) * time.Second)
defer slowTicker.Stop()
for {
select {
case <-fastTicker.C:
syncFast()
case <-slowTicker.C:
syncSlow()
}
}
}
func fatal(msg string, err error) {
slog.Error(msg, "err", err)
os.Exit(1)
}