commit 0971b1f13f7924b4074f127ffb44803968ca8e1e Author: 1e99 Date: Sun Jun 29 11:20:23 2025 +0200 first commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2621632 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +*.sqlite +*.db diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..6a479b6 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,9 @@ +FROM docker.io/golang:1.24.2 AS builder +WORKDIR /src +COPY . ./ +RUN CGO_ENABLED=1 go build -tags "sqlite_math_functions" -o /bin/2b2t . + +FROM docker.io/debian:latest AS runner +WORKDIR /run +COPY --from=builder /bin/2b2t /bin/2b2t +CMD ["/bin/2b2t"] diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..3759302 --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,356 @@ +Mozilla Public License Version 2.0 +================================== + +### 1. Definitions + +**1.1. “Contributor”** + means each individual or legal entity that creates, contributes to + the creation of, or owns Covered Software. + +**1.2. “Contributor Version”** + means the combination of the Contributions of others (if any) used + by a Contributor and that particular Contributor's Contribution. + +**1.3. “Contribution”** + means Covered Software of a particular Contributor. + +**1.4. “Covered Software”** + means Source Code Form to which the initial Contributor has attached + the notice in Exhibit A, the Executable Form of such Source Code + Form, and Modifications of such Source Code Form, in each case + including portions thereof. + +**1.5. “Incompatible With Secondary Licenses”** + means + +* **(a)** that the initial Contributor has attached the notice described + in Exhibit B to the Covered Software; or +* **(b)** that the Covered Software was made available under the terms of + version 1.1 or earlier of the License, but not also under the + terms of a Secondary License. + +**1.6. “Executable Form”** + means any form of the work other than Source Code Form. + +**1.7. “Larger Work”** + means a work that combines Covered Software with other material, in + a separate file or files, that is not Covered Software. + +**1.8. “License”** + means this document. + +**1.9. “Licensable”** + means having the right to grant, to the maximum extent possible, + whether at the time of the initial grant or subsequently, any and + all of the rights conveyed by this License. + +**1.10. “Modifications”** + means any of the following: + +* **(a)** any file in Source Code Form that results from an addition to, + deletion from, or modification of the contents of Covered + Software; or +* **(b)** any new file in Source Code Form that contains any Covered + Software. + +**1.11. “Patent Claims” of a Contributor** + means any patent claim(s), including without limitation, method, + process, and apparatus claims, in any patent Licensable by such + Contributor that would be infringed, but for the grant of the + License, by the making, using, selling, offering for sale, having + made, import, or transfer of either its Contributions or its + Contributor Version. + +**1.12. “Secondary License”** + means either the GNU General Public License, Version 2.0, the GNU + Lesser General Public License, Version 2.1, the GNU Affero General + Public License, Version 3.0, or any later versions of those + licenses. + +**1.13. “Source Code Form”** + means the form of the work preferred for making modifications. + +**1.14. “You” (or “Your”)** + means an individual or a legal entity exercising rights under this + License. For legal entities, “You” includes any entity that + controls, is controlled by, or is under common control with You. For + purposes of this definition, “control” means **(a)** the power, direct + or indirect, to cause the direction or management of such entity, + whether by contract or otherwise, or **(b)** ownership of more than + fifty percent (50%) of the outstanding shares or beneficial + ownership of such entity. + + +### 2. License Grants and Conditions + +#### 2.1. Grants + +Each Contributor hereby grants You a world-wide, royalty-free, +non-exclusive license: + +* **(a)** under intellectual property rights (other than patent or trademark) + Licensable by such Contributor to use, reproduce, make available, + modify, display, perform, distribute, and otherwise exploit its + Contributions, either on an unmodified basis, with Modifications, or + as part of a Larger Work; and +* **(b)** under Patent Claims of such Contributor to make, use, sell, offer + for sale, have made, import, and otherwise transfer either its + Contributions or its Contributor Version. + +#### 2.2. Effective Date + +The licenses granted in Section 2.1 with respect to any Contribution +become effective for each Contribution on the date the Contributor first +distributes such Contribution. + +#### 2.3. Limitations on Grant Scope + +The licenses granted in this Section 2 are the only rights granted under +this License. No additional rights or licenses will be implied from the +distribution or licensing of Covered Software under this License. +Notwithstanding Section 2.1(b) above, no patent license is granted by a +Contributor: + +* **(a)** for any code that a Contributor has removed from Covered Software; + or +* **(b)** for infringements caused by: **(i)** Your and any other third party's + modifications of Covered Software, or **(ii)** the combination of its + Contributions with other software (except as part of its Contributor + Version); or +* **(c)** under Patent Claims infringed by Covered Software in the absence of + its Contributions. + +This License does not grant any rights in the trademarks, service marks, +or logos of any Contributor (except as may be necessary to comply with +the notice requirements in Section 3.4). + +#### 2.4. Subsequent Licenses + +No Contributor makes additional grants as a result of Your choice to +distribute the Covered Software under a subsequent version of this +License (see Section 10.2) or under the terms of a Secondary License (if +permitted under the terms of Section 3.3). + +#### 2.5. Representation + +Each Contributor represents that the Contributor believes its +Contributions are its original creation(s) or it has sufficient rights +to grant the rights to its Contributions conveyed by this License. + +#### 2.6. Fair Use + +This License is not intended to limit any rights You have under +applicable copyright doctrines of fair use, fair dealing, or other +equivalents. + +#### 2.7. Conditions + +Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted +in Section 2.1. + + +### 3. Responsibilities + +#### 3.1. Distribution of Source Form + +All distribution of Covered Software in Source Code Form, including any +Modifications that You create or to which You contribute, must be under +the terms of this License. You must inform recipients that the Source +Code Form of the Covered Software is governed by the terms of this +License, and how they can obtain a copy of this License. You may not +attempt to alter or restrict the recipients' rights in the Source Code +Form. + +#### 3.2. Distribution of Executable Form + +If You distribute Covered Software in Executable Form then: + +* **(a)** such Covered Software must also be made available in Source Code + Form, as described in Section 3.1, and You must inform recipients of + the Executable Form how they can obtain a copy of such Source Code + Form by reasonable means in a timely manner, at a charge no more + than the cost of distribution to the recipient; and + +* **(b)** You may distribute such Executable Form under the terms of this + License, or sublicense it under different terms, provided that the + license for the Executable Form does not attempt to limit or alter + the recipients' rights in the Source Code Form under this License. + +#### 3.3. Distribution of a Larger Work + +You may create and distribute a Larger Work under terms of Your choice, +provided that You also comply with the requirements of this License for +the Covered Software. If the Larger Work is a combination of Covered +Software with a work governed by one or more Secondary Licenses, and the +Covered Software is not Incompatible With Secondary Licenses, this +License permits You to additionally distribute such Covered Software +under the terms of such Secondary License(s), so that the recipient of +the Larger Work may, at their option, further distribute the Covered +Software under the terms of either this License or such Secondary +License(s). + +#### 3.4. Notices + +You may not remove or alter the substance of any license notices +(including copyright notices, patent notices, disclaimers of warranty, +or limitations of liability) contained within the Source Code Form of +the Covered Software, except that You may alter any license notices to +the extent required to remedy known factual inaccuracies. + +#### 3.5. Application of Additional Terms + +You may choose to offer, and to charge a fee for, warranty, support, +indemnity or liability obligations to one or more recipients of Covered +Software. However, You may do so only on Your own behalf, and not on +behalf of any Contributor. You must make it absolutely clear that any +such warranty, support, indemnity, or liability obligation is offered by +You alone, and You hereby agree to indemnify every Contributor for any +liability incurred by such Contributor as a result of warranty, support, +indemnity or liability terms You offer. You may include additional +disclaimers of warranty and limitations of liability specific to any +jurisdiction. + + +### 4. Inability to Comply Due to Statute or Regulation + +If it is impossible for You to comply with any of the terms of this +License with respect to some or all of the Covered Software due to +statute, judicial order, or regulation then You must: **(a)** comply with +the terms of this License to the maximum extent possible; and **(b)** +describe the limitations and the code they affect. Such description must +be placed in a text file included with all distributions of the Covered +Software under this License. Except to the extent prohibited by statute +or regulation, such description must be sufficiently detailed for a +recipient of ordinary skill to be able to understand it. + + +### 5. Termination + +**5.1.** The rights granted under this License will terminate automatically +if You fail to comply with any of its terms. However, if You become +compliant, then the rights granted under this License from a particular +Contributor are reinstated **(a)** provisionally, unless and until such +Contributor explicitly and finally terminates Your grants, and **(b)** on an +ongoing basis, if such Contributor fails to notify You of the +non-compliance by some reasonable means prior to 60 days after You have +come back into compliance. Moreover, Your grants from a particular +Contributor are reinstated on an ongoing basis if such Contributor +notifies You of the non-compliance by some reasonable means, this is the +first time You have received notice of non-compliance with this License +from such Contributor, and You become compliant prior to 30 days after +Your receipt of the notice. + +**5.2.** If You initiate litigation against any entity by asserting a patent +infringement claim (excluding declaratory judgment actions, +counter-claims, and cross-claims) alleging that a Contributor Version +directly or indirectly infringes any patent, then the rights granted to +You by any and all Contributors for the Covered Software under Section +2.1 of this License shall terminate. + +**5.3.** In the event of termination under Sections 5.1 or 5.2 above, all +end user license agreements (excluding distributors and resellers) which +have been validly granted by You or Your distributors under this License +prior to termination shall survive termination. + + +### 6. Disclaimer of Warranty + +> Covered Software is provided under this License on an “as is” +> basis, without warranty of any kind, either expressed, implied, or +> statutory, including, without limitation, warranties that the +> Covered Software is free of defects, merchantable, fit for a +> particular purpose or non-infringing. The entire risk as to the +> quality and performance of the Covered Software is with You. +> Should any Covered Software prove defective in any respect, You +> (not any Contributor) assume the cost of any necessary servicing, +> repair, or correction. This disclaimer of warranty constitutes an +> essential part of this License. No use of any Covered Software is +> authorized under this License except under this disclaimer. + +### 7. Limitation of Liability + +> Under no circumstances and under no legal theory, whether tort +> (including negligence), contract, or otherwise, shall any +> Contributor, or anyone who distributes Covered Software as +> permitted above, be liable to You for any direct, indirect, +> special, incidental, or consequential damages of any character +> including, without limitation, damages for lost profits, loss of +> goodwill, work stoppage, computer failure or malfunction, or any +> and all other commercial damages or losses, even if such party +> shall have been informed of the possibility of such damages. This +> limitation of liability shall not apply to liability for death or +> personal injury resulting from such party's negligence to the +> extent applicable law prohibits such limitation. Some +> jurisdictions do not allow the exclusion or limitation of +> incidental or consequential damages, so this exclusion and +> limitation may not apply to You. + + +### 8. Litigation + +Any litigation relating to this License may be brought only in the +courts of a jurisdiction where the defendant maintains its principal +place of business and such litigation shall be governed by laws of that +jurisdiction, without reference to its conflict-of-law provisions. +Nothing in this Section shall prevent a party's ability to bring +cross-claims or counter-claims. + + +### 9. Miscellaneous + +This License represents the complete agreement concerning the subject +matter hereof. If any provision of this License is held to be +unenforceable, such provision shall be reformed only to the extent +necessary to make it enforceable. Any law or regulation which provides +that the language of a contract shall be construed against the drafter +shall not be used to construe this License against a Contributor. + + +### 10. Versions of the License + +#### 10.1. New Versions + +Mozilla Foundation is the license steward. Except as provided in Section +10.3, no one other than the license steward has the right to modify or +publish new versions of this License. Each version will be given a +distinguishing version number. + +#### 10.2. Effect of New Versions + +You may distribute the Covered Software under the terms of the version +of the License under which You originally received the Covered Software, +or under the terms of any subsequent version published by the license +steward. + +#### 10.3. Modified Versions + +If you create software not governed by this License, and you want to +create a new license for such software, you may create and use a +modified version of this License if you rename the license and remove +any references to the name of the license steward (except to note that +such modified license differs from this License). + +#### 10.4. Distributing Source Code Form that is Incompatible With Secondary Licenses + +If You choose to distribute Source Code Form that is Incompatible With +Secondary Licenses under the terms of this version of the License, the +notice described in Exhibit B of this License must be attached. + +## Exhibit A - Source Code Form License Notice + + This Source Code Form is subject to the terms of the Mozilla Public + License, v. 2.0. If a copy of the MPL was not distributed with this + file, You can obtain one at http://mozilla.org/MPL/2.0/. + +If it is not possible or desirable to put the notice in a particular +file, then You may include the notice in a location (such as a LICENSE +file in a relevant directory) where a recipient would be likely to look +for such a notice. + +You may add additional accurate notices of copyright ownership. + +## Exhibit B - “Incompatible With Secondary Licenses” Notice + + This Source Code Form is "Incompatible With Secondary Licenses", as + defined by the Mozilla Public License, v. 2.0. + diff --git a/README.md b/README.md new file mode 100644 index 0000000..d1206fa --- /dev/null +++ b/README.md @@ -0,0 +1,2 @@ +# 2b2t +A 2b2t queue tracker. diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..7617326 --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module git.1e99.eu/1e99/2b2t + +go 1.24.2 + +require github.com/mattn/go-sqlite3 v1.14.28 // indirect diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..42e5bac --- /dev/null +++ b/go.sum @@ -0,0 +1,2 @@ +github.com/mattn/go-sqlite3 v1.14.28 h1:ThEiQrnbtumT+QMknw63Befp/ce/nUPgBPMlRFEum7A= +github.com/mattn/go-sqlite3 v1.14.28/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= diff --git a/main.go b/main.go new file mode 100644 index 0000000..58e3928 --- /dev/null +++ b/main.go @@ -0,0 +1,113 @@ +package main + +import ( + "database/sql" + "embed" + "fmt" + "log" + "net/http" + "os" + + "git.1e99.eu/1e99/2b2t/routes" + _ "github.com/mattn/go-sqlite3" +) + +var migrations = []string{ + `CREATE TABLE player_count ( + time INTEGER PRIMARY KEY, + playing INTEGER, + normal_queue INTEGER, + priority_queue INTEGER + ); + + CREATE TABLE worker ( + id INTEGER PRIMARY KEY, + name TEXT, + token TEXT + );`, +} + +//go:embed static/* +var embedFS embed.FS + +func main() { + logger := log.New(os.Stdout, "", log.Flags()) + logger.Printf(` ___ ____ ___ _______ __ __ _ `) + logger.Printf(` |__ \| _ \__ \__ __| | \/ | | | `) + logger.Printf(` ) | |_) | ) | | | | \ / | __ _ ___| |_ ___ _ __ `) + logger.Printf(` / /| _ < / / | | | |\/| |/ _' / __| __/ _ \ '__|`) + logger.Printf(` / /_| |_) / /_ | | | | | | (_| \__ \ || __/ | `) + logger.Printf(` |____|____/____| |_| |_| |_|\__,_|___/\__\___|_| `) + logger.Printf(` `) + logger.Printf(` `) + + dsn, found := os.LookupEnv("MASTER_DATABASE") + if !found { + logger.Printf("No \"MASTER_DATABASE\", defaulting to \"./data.sqlite\"") + dsn = "./data.sqlite" + } + + address, found := os.LookupEnv("MASTER_ADDRESS") + if !found { + logger.Printf("No \"MASTER_ADDRESS\", defaulting to \":3000\"") + address = ":3000" + } + + // Requires tags "sqlite_math_functions" + db, err := sql.Open("sqlite3", dsn) + if err != nil { + logger.Printf("Failed to open database: %s", err) + os.Exit(1) + } + defer db.Close() + + err = migrateDB(db, migrations) + if err != nil { + logger.Printf("Failed to migrate database: %s", err) + os.Exit(1) + } + + mux := http.NewServeMux() + mux.Handle("GET /api/player-count", routes.GetPlayerCounts(db, logger)) + mux.Handle("PUT /api/player-count", routes.SubmitPlayerCounts(db, logger)) + mux.Handle("GET /", routes.FileServerSubFS(embedFS, "static")) + + logger.Printf("Listening on %s", address) + err = http.ListenAndServe(address, routes.Logger(mux, logger)) + if err != nil { + logger.Printf("Failed to listen: %s", err) + os.Exit(1) + } +} + +func migrateDB(db *sql.DB, migrations []string) error { + row := db.QueryRow(`PRAGMA user_version`) + + var version int + err := row.Scan(&version) + if err != nil { + return fmt.Errorf("get user_version: %w", err) + } + + for migrationVer, sql := range migrations { + // Add 1 due to 0-based array indexing + if version >= (migrationVer + 1) { + continue + } + + _, err = db.Exec(sql) + if err != nil { + return fmt.Errorf("migrate to v%d: %s", migrationVer+1, err) + } + + version = migrationVer + 1 + } + + sql := fmt.Sprintf(`PRAGMA user_version = %d;`, version) + _, err = db.Exec(sql) + if err != nil { + return fmt.Errorf("set user_version: %w", err) + } + + return nil +} diff --git a/models/player-count.go b/models/player-count.go new file mode 100644 index 0000000..74557eb --- /dev/null +++ b/models/player-count.go @@ -0,0 +1,9 @@ +package models + +type PlayerCount struct { + // The number of minutes elapsed since January 1, 1970 UTC. + Time int64 `json:"time"` + Playing int `json:"playing"` + NormalQueue int `json:"normal_queue"` + PriorityQueue int `json:"priority_queue"` +} diff --git a/models/worker.go b/models/worker.go new file mode 100644 index 0000000..bc85238 --- /dev/null +++ b/models/worker.go @@ -0,0 +1,7 @@ +package models + +type Worker struct { + Id int64 + Name string + Token string +} diff --git a/routes/api.go b/routes/api.go new file mode 100644 index 0000000..9619e3f --- /dev/null +++ b/routes/api.go @@ -0,0 +1,126 @@ +package routes + +import ( + "database/sql" + "encoding/json" + "log" + "net/http" + "time" + + "git.1e99.eu/1e99/2b2t/models" +) + +func GetPlayerCounts(db *sql.DB, logger *log.Logger) http.HandlerFunc { + // TODO: Better logging + return func(res http.ResponseWriter, req *http.Request) { + query := req.URL.Query() + + // In minutes + var now = time.Now().Unix() / 60 + var timeRange, precision int64 + switch query.Get("range") { + case "1d": + timeRange = 60 * 24 + precision = 1 + + case "7d": + timeRange = 7 * 60 * 24 + precision = 1 + + case "30d": + timeRange = 30 * 60 * 24 + precision = 5 + + case "356d": + timeRange = 356 * 60 * 24 + precision = 60 * 24 + + default: + http.Error(res, "invalid time range", http.StatusBadRequest) + return + } + + rows, err := db.Query(` + SELECT c.time, c.playing, c.normal_queue, c.priority_queue + FROM player_count c + WHERE + c.time BETWEEN ? AND ? AND + MOD(c.time, ?) = 0;`, + (now - timeRange), now, precision, + ) + if err != nil { + http.Error(res, "", http.StatusInternalServerError) + logger.Printf("Failed to query for player count: %s", err) + return + } + defer rows.Close() + + counts := []models.PlayerCount{} + for rows.Next() { + var count models.PlayerCount + err = rows.Scan(&count.Time, &count.Playing, &count.NormalQueue, &count.PriorityQueue) + if err != nil { + http.Error(res, "", http.StatusInternalServerError) + logger.Printf("Failed to scan player count row: %s", err) + return + } + + counts = append(counts, count) + } + + err = rows.Err() + if err != nil { + http.Error(res, "", http.StatusInternalServerError) + logger.Printf("Failed to query for player count: %s", err) + return + } + + json.NewEncoder(res).Encode(&counts) + } +} + +func SubmitPlayerCounts(db *sql.DB, logger *log.Logger) http.HandlerFunc { + return func(res http.ResponseWriter, req *http.Request) { + row := db.QueryRow(` + SELECT w.name + FROM worker w + WHERE w.token = ?;`, + req.Header.Get("X-Token"), + ) + + var name string + err := row.Scan(&name) + switch { + case err == sql.ErrNoRows: + http.Error(res, "", http.StatusUnauthorized) + return + + case err != nil: + http.Error(res, "", http.StatusInternalServerError) + logger.Printf("Failed to query for workers: %s", err) + return + } + + var count models.PlayerCount + err = json.NewDecoder(req.Body).Decode(&count) + if err != nil { + http.Error(res, "malformed json", http.StatusBadRequest) + return + } + + _, err = db.Exec(` + INSERT INTO + player_count (time, playing, normal_queue, priority_queue) + VALUES (?, ?, ?, ?) + ON CONFLICT DO NOTHING;`, + count.Time, count.Playing, count.NormalQueue, count.PriorityQueue, + ) + if err != nil { + http.Error(res, "", http.StatusInternalServerError) + logger.Printf("Failed to insert player count data: %s", err) + return + } + + logger.Printf("Worker \"%s\" submitted player count %+v", name, count) + } +} diff --git a/routes/fs.go b/routes/fs.go new file mode 100644 index 0000000..6a44e3f --- /dev/null +++ b/routes/fs.go @@ -0,0 +1,15 @@ +package routes + +import ( + "io/fs" + "net/http" +) + +func FileServerSubFS(fileSystem fs.FS, pathInFs string) http.Handler { + newFs, err := fs.Sub(fileSystem, pathInFs) + if err != nil { + panic(err) + } + + return http.FileServerFS(newFs) +} diff --git a/routes/logger.go b/routes/logger.go new file mode 100644 index 0000000..969e1a5 --- /dev/null +++ b/routes/logger.go @@ -0,0 +1,13 @@ +package routes + +import ( + "log" + "net/http" +) + +func Logger(next http.Handler, logger *log.Logger) http.HandlerFunc { + return func(res http.ResponseWriter, req *http.Request) { + logger.Printf("%-35s %-10s %s", req.RemoteAddr, req.Method, req.URL.String()) + next.ServeHTTP(res, req) + } +} diff --git a/static/index.html b/static/index.html new file mode 100644 index 0000000..3b5fe61 --- /dev/null +++ b/static/index.html @@ -0,0 +1,32 @@ + + + + + + + 2b2t + + + + + + + + + + + + +

2b2t's queue

+ + + + + + + diff --git a/static/js/api.js b/static/js/api.js new file mode 100644 index 0000000..b3806b2 --- /dev/null +++ b/static/js/api.js @@ -0,0 +1,9 @@ +async function getPlayerCounts(range) { + const res = await fetch(`/api/player-count?range=${range}`) + if (!res.ok) { + throw new Error(`Failed to get player counts, got status ${res.status}`) + } + + const json = await res.json() + return json +} diff --git a/static/js/main.js b/static/js/main.js new file mode 100644 index 0000000..e3ca0eb --- /dev/null +++ b/static/js/main.js @@ -0,0 +1,54 @@ +async function initChart() { + const datasets = ["Playing", "Normal queue", "Priority queue"] + .map(label => ({ label, data: [] })) + + const chart = new Chart(document.querySelector("#player-counts"), { + type: "line", + data: { + datasets: datasets, + }, + options: { + scales: { + x: { + type: "time", + time: { + unit: "minute" + }, + }, + y: { + beginAtZero: true, + }, + }, + }, + }) + + const range = document.querySelector("select#range") + + const updateChart = async () => { + const data = await getPlayerCounts(range.value) + + datasets[0].data = data.map(point => ({ + x: point["time"] * 60 * 1_000, + y: point["playing"], + })) + + datasets[1].data = data.map(point => ({ + x: point["time"] * 60 * 1_000, + y: point["normal_queue"], + })) + + datasets[2].data = data.map(point => ({ + x: point["time"] * 60 * 1_000, + y: point["priority_queue"], + })) + + chart.update() + } + + range.addEventListener("change", e => updateChart()) + updateChart() +} + +document.addEventListener("DOMContentLoaded", () => { + initChart() +}) diff --git a/worker/Dockerfile b/worker/Dockerfile new file mode 100644 index 0000000..cbcc1f0 --- /dev/null +++ b/worker/Dockerfile @@ -0,0 +1,2 @@ +FROM docker.io/golang:1.24.2-alpine AS builder +WORKDIR /app \ No newline at end of file diff --git a/worker/main.go b/worker/main.go new file mode 100644 index 0000000..b674c83 --- /dev/null +++ b/worker/main.go @@ -0,0 +1,79 @@ +package main + +import ( + "log" + "os" + "os/signal" + "sync" + + "git.1e99.eu/1e99/2b2t/models" + _ "github.com/mattn/go-sqlite3" +) + +func main() { + logger := log.New(os.Stdout, "", log.Flags()) + logger.Printf(` ___ ____ ___ _______ __ __ _ `) + logger.Printf(` |__ \| _ \__ \__ __| \ \ / / | | `) + logger.Printf(` ) | |_) | ) | | | \ \ /\ / /__ _ __| | _____ _ __ `) + logger.Printf(` / /| _ < / / | | \ \/ \/ / _ \| '__| |/ / _ \ '__|`) + logger.Printf(` / /_| |_) / /_ | | \ /\ / (_) | | | < __/ | `) + logger.Printf(` |____|____/____| |_| \/ \/ \___/|_| |_|\_\___|_| `) + logger.Printf(` `) + logger.Printf(` `) + + submitUrl, found := os.LookupEnv("WORKER_SUBMIT_URL") + if !found { + logger.Printf("No \"WORKER_SUBMIT_URL\", exiting") + os.Exit(1) + } + + token, found := os.LookupEnv("WORKER_TOKEN") + if !found { + logger.Printf("No \"WORKER_TOKEN\", exiting") + os.Exit(1) + } + + poller := makePoller(logger) + logger.Printf("Submitting data to %s", submitUrl) + logger.Printf("Using poller %s", poller) + logger.Printf("") + + var wg sync.WaitGroup + stop := make(chan bool) + queue := make(chan models.PlayerCount, 1024) + + go SubmitLoop(stop, &wg, queue, logger, submitUrl, token) + go PollLoop(stop, &wg, queue, logger, poller) + + notify := make(chan os.Signal, 1) + signal.Notify(notify, os.Interrupt) + <-notify + + close(stop) + wg.Wait() +} + +func makePoller(logger *log.Logger) (poller Poller) { + pollerType, found := os.LookupEnv("WORKER_POLLER") + if !found { + logger.Printf("No \"WORKER_POLLER\", defaulting to \"mcapi\"") + pollerType = "mcapi" + } + + switch pollerType { + case "dummy": + poller = &dummyPoller{} + return + + case "mcapi": + poller = &mcApiPoller{ + Address: "2b2t.org", + } + return + + default: + logger.Printf("Invalid poller type") + os.Exit(1) + return + } +} diff --git a/worker/poller-direct.go b/worker/poller-direct.go new file mode 100644 index 0000000..e07a4b1 --- /dev/null +++ b/worker/poller-direct.go @@ -0,0 +1,6 @@ +package main + +// TOOD: +// - Reimplement the Minecraft Protocol and reach out to 2b2t that way. I want to make this the default later on +// - https://minecraft.wiki/w/Java_Edition_protocol/Packets +// - https://minecraft.wiki/w/Java_Edition_protocol/FAQ#What_does_the_normal_status_ping_sequence_look_like? diff --git a/worker/poller-dummy.go b/worker/poller-dummy.go new file mode 100644 index 0000000..7cb0dc8 --- /dev/null +++ b/worker/poller-dummy.go @@ -0,0 +1,19 @@ +package main + +import "git.1e99.eu/1e99/2b2t/models" + +type dummyPoller struct { +} + +func (p *dummyPoller) PollPlayerCount() (models.PlayerCount, error) { + count := models.PlayerCount{ + Playing: 0, + NormalQueue: 0, + PriorityQueue: 0, + } + return count, nil +} + +func (p *dummyPoller) String() string { + return "dummy[]" +} diff --git a/worker/poller-mcapi.go b/worker/poller-mcapi.go new file mode 100644 index 0000000..cdf5a96 --- /dev/null +++ b/worker/poller-mcapi.go @@ -0,0 +1,76 @@ +package main + +import ( + "encoding/json" + "fmt" + "net/http" + "net/url" + "strconv" + + "git.1e99.eu/1e99/2b2t/models" +) + +type mcApiPoller struct { + Address string +} + +func (p *mcApiPoller) PollPlayerCount() (models.PlayerCount, error) { + url, _ := url.Parse("https://mcapi.us/server/status") + query := url.Query() + query.Set("ip", p.Address) + url.RawQuery = query.Encode() + + res, err := http.Get(url.String()) + if err != nil { + return models.PlayerCount{}, fmt.Errorf("http request: %w", err) + } + defer res.Body.Close() + + var data struct { + Players struct { + Max int `json:"max"` + Now int `json:"now"` + Sample []struct { + Name string `json:"name"` + Id string `json:"id"` + } `json:"sample"` + } `json:"players"` + } + err = json.NewDecoder(res.Body).Decode(&data) + if err != nil { + return models.PlayerCount{}, fmt.Errorf("invalid json response: %w", err) + } + + var count models.PlayerCount + for _, player := range data.Players.Sample { + matched := playerRegex.FindStringSubmatch(player.Name) + if matched == nil || len(matched) != 3 { + return models.PlayerCount{}, fmt.Errorf("match player regex against \"%s\"", player.Name) + } + + value, err := strconv.Atoi(matched[2]) + if err != nil { + return models.PlayerCount{}, fmt.Errorf("parse number: %w", err) + } + + switch matched[1] { + case "§6In-game": + count.Playing = value + + case "§6Queue": + count.NormalQueue = value + + case "§6Priority queue": + count.PriorityQueue = value + + default: + return models.PlayerCount{}, fmt.Errorf("unknown player \"%s\"", matched[1]) + } + } + + return count, nil +} + +func (p *mcApiPoller) String() string { + return fmt.Sprintf("mcapi[address=%s]", p.Address) +} diff --git a/worker/poller.go b/worker/poller.go new file mode 100644 index 0000000..36bb548 --- /dev/null +++ b/worker/poller.go @@ -0,0 +1,53 @@ +package main + +import ( + "log" + "regexp" + "sync" + "time" + + "git.1e99.eu/1e99/2b2t/models" +) + +var ( + playerRegex = regexp.MustCompile(`^(.+): (\d+)$`) +) + +type Poller interface { + PollPlayerCount() (models.PlayerCount, error) + String() string +} + +func PollLoop(stop chan bool, wg *sync.WaitGroup, queue chan models.PlayerCount, logger *log.Logger, poller Poller) { + defer wg.Done() + defer logger.Printf("Poll loop exited") + wg.Add(1) + logger.Printf("Poll loop started") + + var lastPoll int64 = 0 + for { + select { + case <-stop: + return + + default: + now := time.Now().Unix() / 60 + if lastPoll == now { + continue + } + + count, err := poller.PollPlayerCount() + if err != nil { + logger.Printf("Failed polling player count: %s", err) + continue + } + + // Convert seconds into minutes + count.Time = now + lastPoll = now + logger.Printf("Polled player count: %+v", count) + + queue <- count + } + } +} diff --git a/worker/submit.go b/worker/submit.go new file mode 100644 index 0000000..28e5387 --- /dev/null +++ b/worker/submit.go @@ -0,0 +1,52 @@ +package main + +import ( + "bytes" + "encoding/json" + "log" + "net/http" + "sync" + "time" + + "git.1e99.eu/1e99/2b2t/models" +) + +func SubmitLoop(stop chan bool, wg *sync.WaitGroup, queue chan models.PlayerCount, logger *log.Logger, url string, token string) { + defer wg.Done() + defer logger.Printf("Submit loop exited") + wg.Add(1) + logger.Printf("Submit loop started") + + for { + select { + case <-stop: + return + + case count := <-queue: + logger.Printf("Submitting player count: %+v", count) + + data, err := json.Marshal(&count) + if err != nil { + logger.Printf("Failed marshalling player count: %s", err) + continue + } + + req, err := http.NewRequest(http.MethodPut, url, bytes.NewReader(data)) + if err != nil { + logger.Printf("Failed to create submit request: %s", err) + continue + } + + req.Header.Set("X-Token", token) + + res, err := http.DefaultClient.Do(req) + if err != nil { + logger.Printf("Failed to execute submit request: %s", err) + time.Sleep(10 * time.Second) + queue <- count // Put up for retrying + continue + } + res.Body.Close() + } + } +}