diff --git a/.gitignore b/.gitignore index 6df1f31..c9d1ce1 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ .env data/ dist/ +users.txt diff --git a/compose.dev.yaml b/compose.dev.yaml index e5839d2..b43f3e9 100644 --- a/compose.dev.yaml +++ b/compose.dev.yaml @@ -1,15 +1,22 @@ services: ncore-stats: build: . - container_name: ncore-stats + container_name: ncore-stats-dev restart: unless-stopped + user: "1000:1000" ports: - "3000:3000" volumes: - - data:/app/data + - ./data:/app/data + configs: + - source: users_config + target: /app/users.txt environment: - NICK=${NICK} - PASS=${PASS} -volumes: - data: +configs: + users_config: + content: | + alice:123 + bob:456 diff --git a/compose.yaml b/compose.yaml index 7460dc4..067719a 100644 --- a/compose.yaml +++ b/compose.yaml @@ -3,13 +3,23 @@ services: image: ghcr.io/skidoodle/ncore-stats:latest container_name: ncore-stats restart: unless-stopped + user: "1000:1000" ports: - "3000:3000" volumes: - data:/app/data + configs: + - source: users_config + target: /app/users.txt environment: - NICK=${NICK} - - PASSWORD=${PASS} + - PASS=${PASS} + +configs: + users_config: + content: | + alice:123 + bob:456 volumes: data: diff --git a/config.go b/config.go index 8bfacc2..a862405 100644 --- a/config.go +++ b/config.go @@ -31,6 +31,11 @@ func loadConfig() *Configuration { cfg.DatabasePath = defaultDbFolder } + cfg.UsersPath = os.Getenv("USERS_PATH") + if cfg.UsersPath == "" { + cfg.UsersPath = "./users.txt" + } + lvl, _ := logrus.ParseLevel(os.Getenv("LOG_LEVEL")) if lvl == 0 { lvl = logrus.InfoLevel diff --git a/database.go b/database.go index c06c1ff..0638bf2 100644 --- a/database.go +++ b/database.go @@ -4,6 +4,7 @@ import ( "database/sql" "fmt" "os" + "strings" "github.com/sirupsen/logrus" ) @@ -18,7 +19,12 @@ func initDB(cfg *Configuration) *sql.DB { db.SetMaxOpenConns(1) schemas := []string{ - `CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY AUTOINCREMENT, display_name TEXT UNIQUE, profile_id TEXT);`, + `CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + display_name TEXT UNIQUE, + profile_id TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + );`, `CREATE TABLE IF NOT EXISTS profile_history ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER, @@ -46,26 +52,19 @@ func initDB(cfg *Configuration) *sql.DB { } func migrate(db *sql.DB) { - var columnExists bool - _ = db.QueryRow("SELECT COUNT(*) FROM pragma_table_info('profile_history') WHERE name='upload_bytes'").Scan(&columnExists) - if !columnExists { + var ubExists bool + _ = db.QueryRow("SELECT COUNT(*) FROM pragma_table_info('profile_history') WHERE name='upload_bytes'").Scan(&ubExists) + if !ubExists { logrus.Info("Migrating: Adding upload_bytes column...") - _, err := db.Exec("ALTER TABLE profile_history ADD COLUMN upload_bytes INTEGER") - if err != nil { - logrus.Errorf("Migration failed (add column): %v", err) - return - } - - logrus.Info("Migrating: Backfilling upload_bytes from existing strings...") + _, _ = db.Exec("ALTER TABLE profile_history ADD COLUMN upload_bytes INTEGER") type updateRow struct { id int64 bytes int64 } var updates []updateRow - - rows, err := db.Query("SELECT id, upload FROM profile_history WHERE upload_bytes IS NULL") - if err == nil { + rows, _ := db.Query("SELECT id, upload FROM profile_history WHERE upload_bytes IS NULL") + if rows != nil { for rows.Next() { var id int64 var upload string @@ -75,24 +74,96 @@ func migrate(db *sql.DB) { } rows.Close() } - if len(updates) > 0 { - tx, err := db.Begin() - if err != nil { - logrus.Errorf("Transaction start failed: %v", err) - return - } + tx, _ := db.Begin() stmt, _ := tx.Prepare("UPDATE profile_history SET upload_bytes = ? WHERE id = ?") for _, up := range updates { _, _ = stmt.Exec(up.bytes, up.id) } stmt.Close() - if err := tx.Commit(); err != nil { - logrus.Errorf("Transaction commit failed: %v", err) + _ = tx.Commit() + } + } + var caExists bool + _ = db.QueryRow("SELECT COUNT(*) FROM pragma_table_info('users') WHERE name='created_at'").Scan(&caExists) + if !caExists { + logrus.Info("Migrating: Adding created_at column to users...") + _, err := db.Exec("ALTER TABLE users ADD COLUMN created_at DATETIME") + if err != nil { + logrus.Errorf("User migration failed (add column): %v", err) + } else { + _, _ = db.Exec("UPDATE users SET created_at = CURRENT_TIMESTAMP WHERE created_at IS NULL") + logrus.Info("User migration complete.") + } + } +} + +func (s *State) syncUsers() { + if _, err := os.Stat(s.config.UsersPath); os.IsNotExist(err) { + logrus.Warnf("Users config file not found at %s, skipping sync", s.config.UsersPath) + return + } + + content, err := os.ReadFile(s.config.UsersPath) + if err != nil { + logrus.Errorf("Failed to read users file: %v", err) + return + } + + type userEntry struct { + Name string + ID string + } + var users []userEntry + + lines := strings.Split(string(content), "\n") + for _, line := range lines { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + parts := strings.SplitN(line, ":", 2) + if len(parts) == 2 { + users = append(users, userEntry{ + Name: strings.TrimSpace(parts[0]), + ID: strings.TrimSpace(parts[1]), + }) + } + } + + for _, u := range users { + _, err := s.db.Exec(` + INSERT INTO users (display_name, profile_id) + VALUES (?, ?) + ON CONFLICT(display_name) DO UPDATE SET profile_id = excluded.profile_id`, + u.Name, u.ID) + if err != nil { + logrus.Errorf("Sync failed for %s: %v", u.Name, err) + } + } + + rows, _ := s.db.Query("SELECT display_name FROM users") + if rows != nil { + defer rows.Close() + for rows.Next() { + var name string + rows.Scan(&name) + + found := false + for _, u := range users { + if u.Name == name { + found = true + break + } + } + + if !found { + logrus.Infof("Sync: Removing %s (not in config)", name) + _, _ = s.db.Exec("DELETE FROM users WHERE display_name = ?", name) } } - logrus.Info("Migration complete.") } + logrus.Infof("User synchronization complete (%d users)", len(users)) } func (s *State) getLatest() ([]ProfileData, error) { diff --git a/handlers.go b/handlers.go index ce615fa..cf3283f 100644 --- a/handlers.go +++ b/handlers.go @@ -94,6 +94,7 @@ func (s *State) historyModalHandler(w http.ResponseWriter, r *http.Request) { x-init='renderChart(%s)'> `, string(dataJSON)) } + func (s *State) rootHandler(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/" { http.NotFound(w, r) diff --git a/main.go b/main.go index e0d93e4..8f828c1 100644 --- a/main.go +++ b/main.go @@ -34,6 +34,8 @@ func main() { client: &http.Client{Timeout: 45 * time.Second}, } + state.syncUsers() + if handleFlags(state) { return } diff --git a/models.go b/models.go index 706a322..5f5da18 100644 --- a/models.go +++ b/models.go @@ -12,6 +12,7 @@ import ( type Configuration struct { ServerPort string DatabasePath string + UsersPath string LogLevel logrus.Level Ncore struct { Nick string diff --git a/readme.md b/readme.md index e1dec42..48c6176 100644 --- a/readme.md +++ b/readme.md @@ -1,98 +1,53 @@ -# nCore Profile Tracker +# nCore Stats -A simple Go project to scrape and track profile statistics (rank, upload, download, points) on nCore, the largest Hungarian BitTorrent tracker. The stats are stored in a SQLite database and displayed on a basic web interface. +A tracker for nCore profile statistics. -![image](https://github.com/user-attachments/assets/1496548c-0bb4-4f50-aecd-ead79759ceb0) +![nCore Stats Dashboard](https://github.com/user-attachments/assets/1496548c-0bb4-4f50-aecd-ead79759ceb0) -## Features +## Quick Start -- Scrapes and logs profile stats from nCore for multiple users. -- Serves a simple HTML dashboard to display the latest data and historical charts. -- Provides a JSON API to fetch historical profile data. -- Stores all data persistently in a SQLite database. -- Automatically updates data every 24 hours. +1. Create a `.env` file with your credentials: -## Setup + ```ini + NICK=your_nick + PASS=your_password + ``` -1. **Clone the repo:** +2. Save this `compose.yaml`: - ```bash - git clone https://github.com/skidoodle/ncore-stats - cd ncore-stats - ``` + ```yaml + services: + ncore-stats: + image: ghcr.io/skidoodle/ncore-stats:latest + container_name: ncore-stats + restart: unless-stopped + user: "1000:1000" + ports: + - "3000:3000" + volumes: + - data:/app/data + configs: + - source: users_config + target: /app/users.txt + environment: + - NICK=${NICK} + - PASS=${PASS} -2. **Create a `.env` file with your nCore credentials:** + configs: + users_config: + content: | + alice:123 + bob:456 - ```ini - NICK=your_nick - PASS=your_password - ``` + volumes: + data: + ``` -3. **Add Users to Track** +3. Run `docker compose up -d`. - You can add users via the `--add-user` command-line flag. Run this command for each user you want to track. +### How to get NICK and PASS - The format is a single string: `'DisplayName,ProfileID'`. - - ```bash - # Example for running from source - go run . --add-user 'Alice,69' - go run . --add-user 'Bob,420' - ``` - - > **How to find a Profile ID?** - > Navigate to a user's profile on nCore. The URL will be `https://ncore.pro/profile.php?id=12345`. The `ProfileID` is the number at the end. - -### How to obtain `NICK` and `PASS` - -- Open the developer tools in your browser (F12), go to the "Network" tab. -- Log in to nCore using "lower security" mode. -- Find the `login.php` request in the network activity. -- In the response headers, locate the `Set-Cookie` header, which will contain `nick=` and `pass=` values. -- Copy those values and add them to your `.env` file. - -## Running with Docker Compose - -1. **Create the following `docker-compose.yml` file:** - - ```yaml - services: - ncore-stats: - image: ghcr.io/skidoodle/ncore-stats:latest - container_name: ncore-stats - restart: unless-stopped - ports: - - "3000:3000" - volumes: - - ./data:/app/data - environment: - - NICK=${NICK} - - PASS=${PASS} - ``` - -2. **Add Users using Docker** - - When the container is already running, you can use `docker exec`: - ```bash - # The executable inside the container is named 'ncore-stats' - docker exec ncore-stats ./ncore-stats --add-user 'Charlie,1337' - ``` - -3. **Run the Docker Compose setup:** - - Once you have added your users, start the service. - - ```bash - docker compose up -d - ``` - -4. Open `http://localhost:3000` to view your stats. - -### Updating - -To pull the latest image and restart the service: - -```bash -docker compose pull -docker compose up -d -``` +1. Log in to nCore in your browser using **"lower security"** mode. +2. Open Developer Tools (F12) and go to the **Network** tab. +3. Refresh, find any request to `ncore.pro`. +4. Check the **Cookie** request header for `nick=...; pass=...`. diff --git a/web/index.html b/web/index.html index 5b324f5..3d8ac96 100644 --- a/web/index.html +++ b/web/index.html @@ -33,7 +33,7 @@

{{.Owner}}

- RANK #{{.Rank}} + #{{.Rank}}
@@ -66,9 +66,8 @@
{{else}} -
- No profiles found. Use the CLI to add users. +
+ No profiles tracked. Mount users.txt to start.
{{end}}
diff --git a/web/style.css b/web/style.css index e594f8b..a470cf4 100644 --- a/web/style.css +++ b/web/style.css @@ -73,7 +73,17 @@ main { gap: 1.5rem; } +.empty-state { + grid-column: 1 / -1; + padding: 4rem; + text-align: center; + color: var(--muted); + border: 1px dashed var(--border); + border-radius: 12px; +} + .card { + position: relative; background-color: var(--card-bg); border: 1px solid var(--border); border-radius: 12px; @@ -168,23 +178,6 @@ main { border-color: var(--accent); } -.btn-range { - background: var(--bg); - border: 1px solid var(--border); - color: var(--muted); - padding: 0.4rem 0.8rem; - font-size: 0.7rem; - font-weight: 700; - border-radius: 6px; - cursor: pointer; - transition: all 0.2s ease; -} - -.btn-range:hover { - color: #fff; - border-color: var(--muted); -} - .spinner-container { display: flex; justify-content: center;