Compare commits

...

10 Commits

Author SHA1 Message Date
riwiwa
ad455a36e8 update readme 2026-02-08 04:54:52 -08:00
riwiwa
c1e1243151 update go version in workflow to 1.25 2026-02-08 04:53:24 -08:00
e4425bc1e2 Merge remote-tracking branch 'refs/remotes/origin/main' 2026-02-08 04:49:11 -08:00
f7f6b132dc remove patch version for github action 2026-02-08 04:12:49 -08:00
riwiwa
ef5b3ec571 go build 2026-02-08 04:12:09 -08:00
1849aae43f add error check to rand in generateID 2026-02-08 03:02:00 -08:00
1d273248ab replace single pgx conn with pool 2026-02-08 02:34:18 -08:00
22f7f3cf46 better password validation 2026-02-08 00:51:15 -08:00
9e21d0d5c7 added http timeouts when calling lastfm api 2026-02-08 00:24:48 -08:00
77796e79a4 defer close on files after opening 2026-02-08 00:14:40 -08:00
8 changed files with 101 additions and 42 deletions

25
.github/workflows/go.yml vendored Normal file
View File

@@ -0,0 +1,25 @@
# This workflow will build a golang project
# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-go
name: Go
on:
push:
branches: [ "main" ]
pull_request:
branches: [ "main" ]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v4
with:
go-version: '1.25'
- name: Build
run: go build -v ./...

View File

@@ -2,7 +2,7 @@
## Self-hosted music listening statistics ## Self-hosted music listening statistics
### Requirements ### Requirements
- Go 1.25.4+ - Go 1.25+
- PostgreSQL - PostgreSQL
### Roadmap: ### Roadmap:

2
go.mod
View File

@@ -1,6 +1,6 @@
module muzi module muzi
go 1.25.4 go 1.25
require ( require (
github.com/go-chi/chi/v5 v5.2.3 github.com/go-chi/chi/v5 v5.2.3

View File

@@ -11,7 +11,7 @@ import (
"sync" "sync"
"time" "time"
"github.com/jackc/pgx/v5" "muzi/db"
) )
type LastFMTrack struct { type LastFMTrack struct {
@@ -66,22 +66,13 @@ func ImportLastFM(
userId int, userId int,
progressChan chan<- ProgressUpdate, progressChan chan<- ProgressUpdate,
) error { ) error {
conn, err := pgx.Connect(
context.Background(),
"postgres://postgres:postgres@localhost:5432/muzi",
)
if err != nil {
fmt.Fprintf(os.Stderr, "Cannot connect to muzi database: %v\n", err)
if progressChan != nil {
progressChan <- ProgressUpdate{Status: "error", Error: err.Error()}
}
return err
}
defer conn.Close(context.Background())
totalImported := 0 totalImported := 0
resp, err := http.Get( client := &http.Client{
Timeout: 30 * time.Second,
}
resp, err := client.Get(
"https://ws.audioscrobbler.com/2.0/?method=user.getrecenttracks&user=" + "https://ws.audioscrobbler.com/2.0/?method=user.getrecenttracks&user=" +
username + "&api_key=" + apiKey + "&format=json&limit=100", username + "&api_key=" + apiKey + "&format=json&limit=100",
) )
@@ -124,7 +115,7 @@ func ImportLastFM(
go func(workerID int) { go func(workerID int) {
defer wg.Done() defer wg.Done()
for page := workerID + 1; page <= totalPages; page += 10 { for page := workerID + 1; page <= totalPages; page += 10 {
resp, err := http.Get( resp, err := client.Get(
"https://ws.audioscrobbler.com/2.0/?method=user.getrecenttracks&user=" + "https://ws.audioscrobbler.com/2.0/?method=user.getrecenttracks&user=" +
username + "&api_key=" + apiKey + "&format=json&limit=100&page=" + strconv.Itoa(page), username + "&api_key=" + apiKey + "&format=json&limit=100&page=" + strconv.Itoa(page),
) )
@@ -180,7 +171,7 @@ func ImportLastFM(
for len(trackBatch) >= batchSize { for len(trackBatch) >= batchSize {
batch := trackBatch[:batchSize] batch := trackBatch[:batchSize]
trackBatch = trackBatch[batchSize:] trackBatch = trackBatch[batchSize:]
err := insertBatch(conn, batch, &totalImported, batchSize) err := insertBatch(batch, &totalImported, batchSize)
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "Batch insert failed: %v\n", err) fmt.Fprintf(os.Stderr, "Batch insert failed: %v\n", err)
} }
@@ -206,7 +197,7 @@ func ImportLastFM(
} }
if len(trackBatch) > 0 { if len(trackBatch) > 0 {
err := insertBatch(conn, trackBatch, &totalImported, batchSize) err := insertBatch(trackBatch, &totalImported, batchSize)
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "Final batch insert failed: %v\n", err) fmt.Fprintf(os.Stderr, "Final batch insert failed: %v\n", err)
} }
@@ -227,8 +218,8 @@ func ImportLastFM(
return nil return nil
} }
func insertBatch(conn *pgx.Conn, tracks []LastFMTrack, totalImported *int, batchSize int) error { func insertBatch(tracks []LastFMTrack, totalImported *int, batchSize int) error {
tx, err := conn.Begin(context.Background()) tx, err := db.Pool.Begin(context.Background())
if err != nil { if err != nil {
return err return err
} }
@@ -283,6 +274,7 @@ func insertBatch(conn *pgx.Conn, tracks []LastFMTrack, totalImported *int, batch
} }
if err := tx.Commit(context.Background()); err != nil { if err := tx.Commit(context.Background()); err != nil {
tx.Rollback(context.Background())
return err return err
} }

View File

@@ -107,7 +107,12 @@ func getExistingTracks(conn *pgx.Conn, userId int, tracks []SpotifyTrack) (map[s
diff = -diff diff = -diff
} }
if diff < 20*time.Second { if diff < 20*time.Second {
key := fmt.Sprintf("%s|%s|%s", newTrack.Artist, newTrack.Name, newTrack.Timestamp) key := fmt.Sprintf(
"%s|%s|%s",
newTrack.Artist,
newTrack.Name,
newTrack.Timestamp,
)
existing[key] = true existing[key] = true
break break
} }
@@ -269,12 +274,13 @@ func ImportSpotify(userId int) error {
return err return err
} }
for _, f := range entries { for _, f := range entries {
_, err := zip.OpenReader(filepath.Join(path, f.Name())) reader, err := zip.OpenReader(filepath.Join(path, f.Name()))
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "Error opening zip: %s: %v\n", fmt.Fprintf(os.Stderr, "Error opening zip: %s: %v\n",
filepath.Join(path, f.Name()), err) filepath.Join(path, f.Name()), err)
continue continue
} }
defer reader.Close()
fileName := f.Name() fileName := f.Name()
fileFullPath := filepath.Join(path, fileName) fileFullPath := filepath.Join(path, fileName)
fileBaseName := fileName[:(strings.LastIndex(fileName, "."))] fileBaseName := fileName[:(strings.LastIndex(fileName, "."))]
@@ -339,11 +345,13 @@ func Extract(path string, target string) error {
fmt.Fprintf(os.Stderr, "Error opening file: %s: %v\n", filePath, err) fmt.Fprintf(os.Stderr, "Error opening file: %s: %v\n", filePath, err)
return err return err
} }
defer fileToExtract.Close()
extractedFile, err := f.Open() extractedFile, err := f.Open()
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "Error opening file: %s: %v\n", f.Name, err) fmt.Fprintf(os.Stderr, "Error opening file: %s: %v\n", f.Name, err)
return err return err
} }
defer extractedFile.Close()
if _, err := io.Copy(fileToExtract, extractedFile); err != nil { if _, err := io.Copy(fileToExtract, extractedFile); err != nil {
fmt.Fprintf( fmt.Fprintf(
os.Stderr, os.Stderr,
@@ -354,8 +362,6 @@ func Extract(path string, target string) error {
) )
return err return err
} }
fileToExtract.Close()
extractedFile.Close()
} }
return nil return nil
} }

View File

@@ -14,6 +14,16 @@
<label for="pass">Password:</label> <label for="pass">Password:</label>
<input type="password" id="pass" name="pass"> <br> <br> <input type="password" id="pass" name="pass"> <br> <br>
<input type="submit" value="Create Account"> <input type="submit" value="Create Account">
{{if eq .Error "length"}}
<div class="login-error">
Password must be 8-64 chars (inclusive).
</div>
{{end}}
{{if eq .Error "session"}}
<div class="login-error">
Unable to create session. Please try again.
</div>
{{end}}
</form> </form>
</div> </div>
</body> </body>

View File

@@ -14,12 +14,12 @@
<label for="pass">Password:</label> <label for="pass">Password:</label>
<input type="password" id="pass" name="pass"> <br> <br> <input type="password" id="pass" name="pass"> <br> <br>
<input type="submit" value="Login"> <input type="submit" value="Login">
{{if eq .Error "1"}} {{if eq .Error "invalid-creds"}}
<div class="login-error"> <div class="login-error">
Invalid credentials. Please try again. Invalid credentials. Please try again.
</div> </div>
{{end}} {{end}}
{{if eq .Error "2"}} {{if eq .Error "session"}}
<div class="login-error"> <div class="login-error">
Unable to create session. Please try again. Unable to create session. Please try again.
</div> </div>

View File

@@ -5,6 +5,7 @@ import (
"crypto/rand" "crypto/rand"
"encoding/hex" "encoding/hex"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"html/template" "html/template"
"net/http" "net/http"
@@ -42,15 +43,22 @@ func init() {
templates = template.Must(template.New("").Funcs(funcMap).ParseGlob("./templates/*.gohtml")) templates = template.Must(template.New("").Funcs(funcMap).ParseGlob("./templates/*.gohtml"))
} }
func generateID() string { func generateID() (string, error) {
b := make([]byte, 16) b := make([]byte, 16)
rand.Read(b) _, err := rand.Read(b)
return hex.EncodeToString(b) if err != nil {
return "", err
}
return hex.EncodeToString(b), nil
} }
func createSession(username string) string { func createSession(username string) string {
sessionID := generateID() sessionID, err := generateID()
_, err := db.Pool.Exec( if err != nil {
fmt.Fprintf(os.Stderr, "Error generating sessionID: %v\n", err)
return ""
}
_, err = db.Pool.Exec(
context.Background(), context.Background(),
"INSERT INTO sessions (session_id, username, expires_at) VALUES ($1, $2, NOW() + INTERVAL '30 days');", "INSERT INTO sessions (session_id, username, expires_at) VALUES ($1, $2, NOW() + INTERVAL '30 days');",
sessionID, sessionID,
@@ -136,12 +144,16 @@ func getUserIdByUsername(ctx context.Context, username string) (int, error) {
return userId, err return userId, err
} }
func hashPassword(pass []byte) string { func hashPassword(pass []byte) (string, error) {
if len(pass) < 8 || len(pass) > 64 {
return "", errors.New("Error: Password must be greater than 8 chars.")
}
hashedPassword, err := bcrypt.GenerateFromPassword(pass, bcrypt.DefaultCost) hashedPassword, err := bcrypt.GenerateFromPassword(pass, bcrypt.DefaultCost)
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "Couldn't hash password: %v\n", err) fmt.Fprintf(os.Stderr, "Couldn't hash password: %v\n", err)
return "", err
} }
return string(hashedPassword) return string(hashedPassword), nil
} }
func verifyPassword(hashedPassword string, enteredPassword []byte) bool { func verifyPassword(hashedPassword string, enteredPassword []byte) bool {
@@ -158,9 +170,14 @@ func createAccount(w http.ResponseWriter, r *http.Request) {
r.ParseForm() r.ParseForm()
username := r.FormValue("uname") username := r.FormValue("uname")
hashedPassword := hashPassword([]byte(r.FormValue("pass"))) hashedPassword, err := hashPassword([]byte(r.FormValue("pass")))
if err != nil {
fmt.Fprintf(os.Stderr, "Error hashing password: %v\n", err)
http.Redirect(w, r, "/createaccount?error=length", http.StatusSeeOther)
return
}
err := db.CreateUsersTable() err = db.CreateUsersTable()
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "Error ensuring users table exists: %v\n", err) fmt.Fprintf(os.Stderr, "Error ensuring users table exists: %v\n", err)
http.Redirect(w, r, "/createaccount", http.StatusSeeOther) http.Redirect(w, r, "/createaccount", http.StatusSeeOther)
@@ -179,7 +196,7 @@ func createAccount(w http.ResponseWriter, r *http.Request) {
} else { } else {
sessionID := createSession(username) sessionID := createSession(username)
if sessionID == "" { if sessionID == "" {
http.Redirect(w, r, "/login?error=2", http.StatusSeeOther) http.Redirect(w, r, "/login?error=session", http.StatusSeeOther)
return return
} }
http.SetCookie(w, &http.Cookie{ http.SetCookie(w, &http.Cookie{
@@ -196,7 +213,11 @@ func createAccount(w http.ResponseWriter, r *http.Request) {
func createAccountPageHandler() http.HandlerFunc { func createAccountPageHandler() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
err := templates.ExecuteTemplate(w, "create_account.gohtml", nil) type data struct {
Error string
}
d := data{Error: "len"}
err := templates.ExecuteTemplate(w, "create_account.gohtml", d)
if err != nil { if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError) http.Error(w, err.Error(), http.StatusInternalServerError)
} }
@@ -219,7 +240,7 @@ func loginSubmit(w http.ResponseWriter, r *http.Request) {
if verifyPassword(storedPassword, []byte(password)) { if verifyPassword(storedPassword, []byte(password)) {
sessionID := createSession(username) sessionID := createSession(username)
if sessionID == "" { if sessionID == "" {
http.Redirect(w, r, "/login?error=2", http.StatusSeeOther) http.Redirect(w, r, "/login?error=session", http.StatusSeeOther)
return return
} }
http.SetCookie(w, &http.Cookie{ http.SetCookie(w, &http.Cookie{
@@ -231,7 +252,7 @@ func loginSubmit(w http.ResponseWriter, r *http.Request) {
}) })
http.Redirect(w, r, "/profile/"+username, http.StatusSeeOther) http.Redirect(w, r, "/profile/"+username, http.StatusSeeOther)
} else { } else {
http.Redirect(w, r, "/login?error=1", http.StatusSeeOther) http.Redirect(w, r, "/login?error=invalid-creds", http.StatusSeeOther)
} }
} }
} }
@@ -395,7 +416,12 @@ func importLastFMHandler(w http.ResponseWriter, r *http.Request) {
return return
} }
jobID := generateID() jobID, err := generateID()
if err != nil {
fmt.Fprintf(os.Stderr, "Error generating jobID: %v\n", err)
http.Error(w, "Error generating jobID", http.StatusBadRequest)
return
}
progressChan := make(chan migrate.ProgressUpdate, 100) progressChan := make(chan migrate.ProgressUpdate, 100)
jobsMu.Lock() jobsMu.Lock()