Feature/session token api #25
+17
-11
@@ -1,26 +1,35 @@
|
||||
# Stage 1: Build frontend
|
||||
FROM node:18-alpine AS frontend-builder
|
||||
RUN apk add --no-cache git
|
||||
WORKDIR /app
|
||||
RUN git clone https://gitea.sanplex.xyz/Sansan/MusicFrontend.git
|
||||
WORKDIR /app/MusicFrontend
|
||||
RUN npm install
|
||||
RUN npm run build
|
||||
# Generate config.js with empty API_HOSTNAME (relative paths)
|
||||
RUN echo "window.__RUNTIME_CONFIG__ = { API_HOSTNAME: '' };" > dist/config.js
|
||||
|
||||
# Stage 2: Build backend
|
||||
FROM golang:1.25-alpine as build_go
|
||||
RUN apk add --no-cache curl
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN go install github.com/a-h/templ/cmd/templ@latest
|
||||
|
||||
RUN templ generate
|
||||
|
||||
RUN go build -o main cmd/main.go
|
||||
|
||||
# Stage 2, distribution container
|
||||
# Stage 3: Final image
|
||||
FROM golang:1.25-alpine
|
||||
EXPOSE 8080
|
||||
VOLUME /sorted
|
||||
VOLUME /frontend
|
||||
VOLUME /characters
|
||||
|
||||
COPY --from=build_go /app/main .
|
||||
COPY --from=frontend-builder /app/MusicFrontend/dist /frontend
|
||||
COPY ./songs/ ./songs/
|
||||
|
||||
ENV PORT 8080
|
||||
ENV DB_HOST ""
|
||||
ENV DB_PORT ""
|
||||
@@ -30,7 +39,4 @@ ENV DB_NAME ""
|
||||
ENV MUSIC_PATH ""
|
||||
ENV CHARACTERS_PATH ""
|
||||
|
||||
COPY --from=build_go /app/main .
|
||||
COPY ./songs/ ./songs/
|
||||
|
||||
CMD ./main
|
||||
|
||||
+218
-32
@@ -23,6 +23,160 @@ var doc = `{
|
||||
"host": "{{.Host}}",
|
||||
"basePath": "{{.BasePath}}",
|
||||
"paths": {
|
||||
"/api/v1/token": {
|
||||
"post": {
|
||||
"description": "Returns a new session token for API access",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"auth"
|
||||
],
|
||||
"summary": "Create session token",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "Client type",
|
||||
"name": "request",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/server.TokenRequest"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/server.TokenResponse"
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"delete": {
|
||||
"description": "Deletes the current session token",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"auth"
|
||||
],
|
||||
"summary": "Invalidate session token",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Bearer token",
|
||||
"name": "Authorization",
|
||||
"in": "header",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/token/cleanup": {
|
||||
"post": {
|
||||
"description": "Removes all expired session tokens from the database",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"auth"
|
||||
],
|
||||
"summary": "Cleanup expired sessions",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Bearer token",
|
||||
"name": "Authorization",
|
||||
"in": "header",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/character": {
|
||||
"get": {
|
||||
"description": "Returns the image for a specific character",
|
||||
@@ -81,29 +235,6 @@ var doc = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"/dbtest": {
|
||||
"get": {
|
||||
"description": "Tests the database connection",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"database"
|
||||
],
|
||||
"summary": "Test database connection",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "TestedDB",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/download": {
|
||||
"get": {
|
||||
"description": "Checks for the latest version of the application",
|
||||
@@ -798,7 +929,7 @@ var doc = `{
|
||||
},
|
||||
"/version": {
|
||||
"get": {
|
||||
"description": "get string by ID",
|
||||
"description": "get latest version info",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
@@ -806,9 +937,9 @@ var doc = `{
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"accounts"
|
||||
"version"
|
||||
],
|
||||
"summary": "Getting the version of the backend",
|
||||
"summary": "Getting the latest version of the backend",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
@@ -824,6 +955,38 @@ var doc = `{
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/version/history": {
|
||||
"get": {
|
||||
"description": "get version history",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"version"
|
||||
],
|
||||
"summary": "Getting the version history of the backend",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/backend.VersionData"
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Not Found",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"definitions": {
|
||||
@@ -831,20 +994,43 @@ var doc = `{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"changelog": {
|
||||
"type": "string",
|
||||
"example": "account name"
|
||||
},
|
||||
"history": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/backend.VersionData"
|
||||
}
|
||||
"type": "string"
|
||||
},
|
||||
"example": [
|
||||
"[\"Initial release\"",
|
||||
"\"Bug fixes\"]"
|
||||
]
|
||||
},
|
||||
"version": {
|
||||
"type": "string",
|
||||
"example": "1.0.0"
|
||||
}
|
||||
}
|
||||
},
|
||||
"server.TokenRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"client_type": {
|
||||
"description": "Optional: \"web\", \"mobile\", \"api\"",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"server.TokenResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"client_type": {
|
||||
"type": "string"
|
||||
},
|
||||
"expires_at": {
|
||||
"type": "string"
|
||||
},
|
||||
"token": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}`
|
||||
|
||||
+218
-32
@@ -4,6 +4,160 @@
|
||||
"contact": {}
|
||||
},
|
||||
"paths": {
|
||||
"/api/v1/token": {
|
||||
"post": {
|
||||
"description": "Returns a new session token for API access",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"auth"
|
||||
],
|
||||
"summary": "Create session token",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "Client type",
|
||||
"name": "request",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/server.TokenRequest"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/server.TokenResponse"
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"delete": {
|
||||
"description": "Deletes the current session token",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"auth"
|
||||
],
|
||||
"summary": "Invalidate session token",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Bearer token",
|
||||
"name": "Authorization",
|
||||
"in": "header",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/token/cleanup": {
|
||||
"post": {
|
||||
"description": "Removes all expired session tokens from the database",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"auth"
|
||||
],
|
||||
"summary": "Cleanup expired sessions",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Bearer token",
|
||||
"name": "Authorization",
|
||||
"in": "header",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/character": {
|
||||
"get": {
|
||||
"description": "Returns the image for a specific character",
|
||||
@@ -62,29 +216,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/dbtest": {
|
||||
"get": {
|
||||
"description": "Tests the database connection",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"database"
|
||||
],
|
||||
"summary": "Test database connection",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "TestedDB",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/download": {
|
||||
"get": {
|
||||
"description": "Checks for the latest version of the application",
|
||||
@@ -779,7 +910,7 @@
|
||||
},
|
||||
"/version": {
|
||||
"get": {
|
||||
"description": "get string by ID",
|
||||
"description": "get latest version info",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
@@ -787,9 +918,9 @@
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"accounts"
|
||||
"version"
|
||||
],
|
||||
"summary": "Getting the version of the backend",
|
||||
"summary": "Getting the latest version of the backend",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
@@ -805,6 +936,38 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/version/history": {
|
||||
"get": {
|
||||
"description": "get version history",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"version"
|
||||
],
|
||||
"summary": "Getting the version history of the backend",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/backend.VersionData"
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Not Found",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"definitions": {
|
||||
@@ -812,20 +975,43 @@
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"changelog": {
|
||||
"type": "string",
|
||||
"example": "account name"
|
||||
},
|
||||
"history": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/backend.VersionData"
|
||||
}
|
||||
"type": "string"
|
||||
},
|
||||
"example": [
|
||||
"[\"Initial release\"",
|
||||
"\"Bug fixes\"]"
|
||||
]
|
||||
},
|
||||
"version": {
|
||||
"type": "string",
|
||||
"example": "1.0.0"
|
||||
}
|
||||
}
|
||||
},
|
||||
"server.TokenRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"client_type": {
|
||||
"description": "Optional: \"web\", \"mobile\", \"api\"",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"server.TokenResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"client_type": {
|
||||
"type": "string"
|
||||
},
|
||||
"expires_at": {
|
||||
"type": "string"
|
||||
},
|
||||
"token": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+145
-22
@@ -2,19 +2,136 @@ definitions:
|
||||
backend.VersionData:
|
||||
properties:
|
||||
changelog:
|
||||
example: account name
|
||||
type: string
|
||||
history:
|
||||
example:
|
||||
- '["Initial release"'
|
||||
- '"Bug fixes"]'
|
||||
items:
|
||||
$ref: '#/definitions/backend.VersionData'
|
||||
type: string
|
||||
type: array
|
||||
version:
|
||||
example: 1.0.0
|
||||
type: string
|
||||
type: object
|
||||
server.TokenRequest:
|
||||
properties:
|
||||
client_type:
|
||||
description: 'Optional: "web", "mobile", "api"'
|
||||
type: string
|
||||
type: object
|
||||
server.TokenResponse:
|
||||
properties:
|
||||
client_type:
|
||||
type: string
|
||||
expires_at:
|
||||
type: string
|
||||
token:
|
||||
type: string
|
||||
type: object
|
||||
info:
|
||||
contact: {}
|
||||
paths:
|
||||
/api/v1/token:
|
||||
delete:
|
||||
consumes:
|
||||
- application/json
|
||||
description: Deletes the current session token
|
||||
parameters:
|
||||
- description: Bearer token
|
||||
in: header
|
||||
name: Authorization
|
||||
required: true
|
||||
type: string
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
"401":
|
||||
description: Unauthorized
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
"500":
|
||||
description: Internal Server Error
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
summary: Invalidate session token
|
||||
tags:
|
||||
- auth
|
||||
post:
|
||||
consumes:
|
||||
- application/json
|
||||
description: Returns a new session token for API access
|
||||
parameters:
|
||||
- description: Client type
|
||||
in: body
|
||||
name: request
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/definitions/server.TokenRequest'
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
$ref: '#/definitions/server.TokenResponse'
|
||||
"400":
|
||||
description: Bad Request
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
"500":
|
||||
description: Internal Server Error
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
summary: Create session token
|
||||
tags:
|
||||
- auth
|
||||
/api/v1/token/cleanup:
|
||||
post:
|
||||
consumes:
|
||||
- application/json
|
||||
description: Removes all expired session tokens from the database
|
||||
parameters:
|
||||
- description: Bearer token
|
||||
in: header
|
||||
name: Authorization
|
||||
required: true
|
||||
type: string
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
additionalProperties: true
|
||||
type: object
|
||||
"401":
|
||||
description: Unauthorized
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
"500":
|
||||
description: Internal Server Error
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
summary: Cleanup expired sessions
|
||||
tags:
|
||||
- auth
|
||||
/character:
|
||||
get:
|
||||
consumes:
|
||||
@@ -53,21 +170,6 @@ paths:
|
||||
summary: Get list of characters
|
||||
tags:
|
||||
- characters
|
||||
/dbtest:
|
||||
get:
|
||||
consumes:
|
||||
- application/json
|
||||
description: Tests the database connection
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: TestedDB
|
||||
schema:
|
||||
type: string
|
||||
summary: Test database connection
|
||||
tags:
|
||||
- database
|
||||
/download:
|
||||
get:
|
||||
consumes:
|
||||
@@ -527,7 +629,7 @@ paths:
|
||||
get:
|
||||
consumes:
|
||||
- application/json
|
||||
description: get string by ID
|
||||
description: get latest version info
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
@@ -539,7 +641,28 @@ paths:
|
||||
description: Not Found
|
||||
schema:
|
||||
type: string
|
||||
summary: Getting the version of the backend
|
||||
summary: Getting the latest version of the backend
|
||||
tags:
|
||||
- accounts
|
||||
- version
|
||||
/version/history:
|
||||
get:
|
||||
consumes:
|
||||
- application/json
|
||||
description: get version history
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
items:
|
||||
$ref: '#/definitions/backend.VersionData'
|
||||
type: array
|
||||
"404":
|
||||
description: Not Found
|
||||
schema:
|
||||
type: string
|
||||
summary: Getting the version history of the backend
|
||||
tags:
|
||||
- version
|
||||
swagger: "2.0"
|
||||
|
||||
+14
-8
@@ -2,7 +2,6 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"music-server/internal/db"
|
||||
"music-server/internal/logging"
|
||||
"music-server/internal/server"
|
||||
"net/http"
|
||||
@@ -19,9 +18,11 @@ import (
|
||||
// @description This is a sample server Petstore server.
|
||||
// @termsOfService http://swagger.io/terms/
|
||||
|
||||
//
|
||||
// @contact.name Sebastian Olsson
|
||||
// @contact.email zarnor91@gmail.com
|
||||
|
||||
//
|
||||
// @license.name Apache 2.0
|
||||
// @license.url http://www.apache.org/licenses/LICENSE-2.0.html
|
||||
|
||||
@@ -34,16 +35,17 @@ func main() {
|
||||
pprof.StartCPUProfile(f)
|
||||
defer pprof.StopCPUProfile()*/
|
||||
|
||||
server := server.NewServer()
|
||||
appServer := server.NewServerInstance()
|
||||
httpServer := appServer.HTTPServer()
|
||||
|
||||
// Create a done channel to signal when the shutdown is complete
|
||||
done := make(chan bool, 1)
|
||||
|
||||
// Run graceful shutdown in a separate goroutine
|
||||
go gracefulShutdown(server, done)
|
||||
go gracefulShutdown(appServer, httpServer, done)
|
||||
|
||||
logging.GetLogger().Info("Server starting", zap.String("address", server.Addr))
|
||||
err := server.ListenAndServe()
|
||||
logging.GetLogger().Info("Server starting", zap.String("address", httpServer.Addr))
|
||||
err := httpServer.ListenAndServe()
|
||||
if err != nil && err != http.ErrServerClosed {
|
||||
logging.GetLogger().Fatal("HTTP server error", zap.String("error", err.Error()))
|
||||
}
|
||||
@@ -53,7 +55,7 @@ func main() {
|
||||
logging.GetLogger().Info("Graceful shutdown complete")
|
||||
}
|
||||
|
||||
func gracefulShutdown(apiServer *http.Server, done chan bool) {
|
||||
func gracefulShutdown(appServer *server.Server, httpServer *http.Server, done chan bool) {
|
||||
// Create context that listens for the interrupt signal from the OS.
|
||||
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||
defer stop()
|
||||
@@ -62,13 +64,17 @@ func gracefulShutdown(apiServer *http.Server, done chan bool) {
|
||||
<-ctx.Done()
|
||||
|
||||
logging.GetLogger().Info("Shutting down gracefully, press Ctrl+C again to force")
|
||||
db.CloseDb()
|
||||
|
||||
// Close database connection
|
||||
if appServer != nil && appServer.DB() != nil {
|
||||
appServer.DB().Close()
|
||||
}
|
||||
|
||||
// The context is used to inform the server it has 5 seconds to finish
|
||||
// the request it is currently handling
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
if err := apiServer.Shutdown(ctx); err != nil {
|
||||
if err := httpServer.Shutdown(ctx); err != nil {
|
||||
logging.GetLogger().Error("Server forced to shutdown with error", zap.String("error", err.Error()))
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"context"
|
||||
"music-server/internal/db/repository"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// Global variables - these are initialized by InitBackend
|
||||
var (
|
||||
backendPool *pgxpool.Pool
|
||||
repo *repository.Queries
|
||||
backendCtx context.Context = context.Background()
|
||||
)
|
||||
|
||||
// InitBackend initializes the backend package with the database pool.
|
||||
// This should be called once at application startup.
|
||||
func InitBackend(pool *pgxpool.Pool) {
|
||||
backendPool = pool
|
||||
repo = repository.New(pool)
|
||||
backendCtx = context.Background()
|
||||
}
|
||||
|
||||
// BackendCtx returns the context used by backend operations.
|
||||
// This is exposed for use by the backend functions.
|
||||
func BackendCtx() context.Context {
|
||||
return backendCtx
|
||||
}
|
||||
|
||||
// BackendRepo returns the repository queries instance.
|
||||
// This is exposed for use by the backend functions.
|
||||
func BackendRepo() *repository.Queries {
|
||||
return repo
|
||||
}
|
||||
|
||||
// BackendPool returns the underlying database pool.
|
||||
// This is exposed for test utilities that need direct pool access.
|
||||
func BackendPool() *pgxpool.Pool {
|
||||
return backendPool
|
||||
}
|
||||
+17
-16
@@ -2,7 +2,6 @@ package backend
|
||||
|
||||
import (
|
||||
"math/rand"
|
||||
"music-server/internal/db"
|
||||
"music-server/internal/db/repository"
|
||||
"music-server/internal/logging"
|
||||
"os"
|
||||
@@ -28,18 +27,20 @@ var gamesNew []repository.Game
|
||||
var songQueNew []repository.Song
|
||||
|
||||
var lastFetchedNew repository.Song
|
||||
var repo *repository.Queries
|
||||
|
||||
func initRepo() {
|
||||
if repo == nil {
|
||||
repo = repository.New(db.Dbpool)
|
||||
// This function is kept for backward compatibility
|
||||
// but now uses the backend package's initialized repo
|
||||
// If not initialized, this will panic intentionally
|
||||
if BackendRepo() == nil {
|
||||
panic("backend not initialized - call backend.InitBackend() first")
|
||||
}
|
||||
}
|
||||
|
||||
func getAllGames() []repository.Game {
|
||||
if len(gamesNew) == 0 {
|
||||
initRepo()
|
||||
gamesNew, _ = repo.FindAllGames(db.Ctx)
|
||||
gamesNew, _ = BackendRepo().FindAllGames(BackendCtx())
|
||||
}
|
||||
return gamesNew
|
||||
|
||||
@@ -58,7 +59,7 @@ func Reset() {
|
||||
songQueNew = nil
|
||||
currentSong = -1
|
||||
initRepo()
|
||||
gamesNew, _ = repo.FindAllGames(db.Ctx)
|
||||
gamesNew, _ = BackendRepo().FindAllGames(BackendCtx())
|
||||
}
|
||||
|
||||
func AddLatestToQue() {
|
||||
@@ -76,8 +77,8 @@ func AddLatestPlayed() {
|
||||
currentSongData := songQueNew[currentSong]
|
||||
|
||||
initRepo()
|
||||
repo.AddGamePlayed(db.Ctx, currentSongData.GameID)
|
||||
repo.AddSongPlayed(db.Ctx, repository.AddSongPlayedParams{GameID: currentSongData.GameID, SongName: currentSongData.SongName})
|
||||
BackendRepo().AddGamePlayed(BackendCtx(), currentSongData.GameID)
|
||||
BackendRepo().AddSongPlayed(BackendCtx(), repository.AddSongPlayedParams{GameID: currentSongData.GameID, SongName: currentSongData.SongName})
|
||||
}
|
||||
|
||||
func SetPlayed(songNumber int) {
|
||||
@@ -86,8 +87,8 @@ func SetPlayed(songNumber int) {
|
||||
}
|
||||
songData := songQueNew[songNumber]
|
||||
initRepo()
|
||||
repo.AddGamePlayed(db.Ctx, songData.GameID)
|
||||
repo.AddSongPlayed(db.Ctx, repository.AddSongPlayedParams{GameID: songData.GameID, SongName: songData.SongName})
|
||||
BackendRepo().AddGamePlayed(BackendCtx(), songData.GameID)
|
||||
BackendRepo().AddSongPlayed(BackendCtx(), repository.AddSongPlayedParams{GameID: songData.GameID, SongName: songData.SongName})
|
||||
}
|
||||
|
||||
func GetRandomSong() string {
|
||||
@@ -130,7 +131,7 @@ func GetRandomSongClassic() string {
|
||||
|
||||
var listOfAllSongs []repository.Song
|
||||
for _, game := range gamesNew {
|
||||
songList, _ := repo.FindSongsFromGame(db.Ctx, game.ID)
|
||||
songList, _ := BackendRepo().FindSongsFromGame(BackendCtx(), game.ID)
|
||||
listOfAllSongs = append(listOfAllSongs, songList...)
|
||||
}
|
||||
|
||||
@@ -138,10 +139,10 @@ func GetRandomSongClassic() string {
|
||||
var song repository.Song
|
||||
for !songFound {
|
||||
song = listOfAllSongs[rand.Intn(len(listOfAllSongs))]
|
||||
gameData, err := repo.GetGameById(db.Ctx, song.GameID)
|
||||
gameData, err := BackendRepo().GetGameById(BackendCtx(), song.GameID)
|
||||
|
||||
if err != nil {
|
||||
repo.RemoveBrokenSong(db.Ctx, song.Path)
|
||||
BackendRepo().RemoveBrokenSong(BackendCtx(), song.Path)
|
||||
logging.GetLogger().Warn("Song not found, removed from database",
|
||||
zap.String("song", song.SongName),
|
||||
zap.String("game", gameData.GameName),
|
||||
@@ -153,7 +154,7 @@ func GetRandomSongClassic() string {
|
||||
openFile, err := os.Open(song.Path)
|
||||
if err != nil || (song.FileName != nil && gameData.Path+*song.FileName != song.Path) {
|
||||
//File not found
|
||||
repo.RemoveBrokenSong(db.Ctx, song.Path)
|
||||
BackendRepo().RemoveBrokenSong(BackendCtx(), song.Path)
|
||||
logging.GetLogger().Warn("Song not found, removed from database",
|
||||
zap.String("song", song.SongName),
|
||||
zap.String("game", gameData.GameName),
|
||||
@@ -270,7 +271,7 @@ func getSongFromList(games []repository.Game) repository.Song {
|
||||
var song repository.Song
|
||||
for !songFound {
|
||||
game := getRandomGame(games)
|
||||
songs, _ := repo.FindSongsFromGame(db.Ctx, game.ID)
|
||||
songs, _ := BackendRepo().FindSongsFromGame(BackendCtx(), game.ID)
|
||||
if len(songs) == 0 {
|
||||
continue
|
||||
}
|
||||
@@ -281,7 +282,7 @@ func getSongFromList(games []repository.Game) repository.Song {
|
||||
openFile, err := os.Open(song.Path)
|
||||
if err != nil || (song.FileName != nil && game.Path+*song.FileName != song.Path) || (song.FileName != nil && strings.HasSuffix(*song.FileName, ".wav")) {
|
||||
//File not found
|
||||
repo.RemoveBrokenSong(db.Ctx, song.Path)
|
||||
BackendRepo().RemoveBrokenSong(BackendCtx(), song.Path)
|
||||
logging.GetLogger().Warn("Song not found, removed from database",
|
||||
zap.String("song", song.SongName),
|
||||
zap.String("game", game.GameName),
|
||||
|
||||
+25
-29
@@ -7,7 +7,6 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"music-server/internal/db"
|
||||
"music-server/internal/db/repository"
|
||||
"music-server/internal/logging"
|
||||
"os"
|
||||
@@ -80,8 +79,8 @@ func (gs GameStatus) String() string {
|
||||
}
|
||||
|
||||
func ResetDB() {
|
||||
repo.ClearSongs(db.Ctx)
|
||||
repo.ClearGames(db.Ctx)
|
||||
repo.ClearSongs(BackendCtx())
|
||||
repo.ClearGames(BackendCtx())
|
||||
}
|
||||
|
||||
func SyncProgress() ProgressResponse {
|
||||
@@ -181,8 +180,6 @@ func SyncGamesNewOnlyChanges() {
|
||||
}
|
||||
|
||||
func syncGamesNew(full bool) {
|
||||
Syncing = true
|
||||
|
||||
musicPath := os.Getenv("MUSIC_PATH")
|
||||
fmt.Printf("dir: %s\n", musicPath)
|
||||
logging.GetLogger().Debug("Folder to sync", zap.String("MUSIC_PATH", musicPath))
|
||||
@@ -194,7 +191,7 @@ func syncGamesNew(full bool) {
|
||||
|
||||
initRepo()
|
||||
start = time.Now()
|
||||
foldersToSkip := []string{".sync", "dist", "old", "characters"}
|
||||
foldersToSkip := []string{".sync", "characters", "dist", "old"}
|
||||
logging.GetLogger().Debug("Folders to skip during sync", zap.Strings("folders", foldersToSkip))
|
||||
|
||||
var err error
|
||||
@@ -206,20 +203,19 @@ func syncGamesNew(full bool) {
|
||||
catchedErrors = nil
|
||||
brokenSongs = nil
|
||||
|
||||
gamesBeforeSync, err = repo.FindAllGames(db.Ctx)
|
||||
gamesBeforeSync, err = repo.FindAllGames(BackendCtx())
|
||||
handleError("FindAllGames Before", err, "")
|
||||
logging.GetLogger().Info("Starting sync", zap.Int("games_before", len(gamesBeforeSync)))
|
||||
|
||||
allGames, err = repo.GetAllGamesIncludingDeleted(db.Ctx)
|
||||
allGames, err = repo.GetAllGamesIncludingDeleted(BackendCtx())
|
||||
handleError("GetAllGamesIncludingDeleted", err, "")
|
||||
err = repo.SetGameDeletionDate(db.Ctx)
|
||||
err = repo.SetGameDeletionDate(BackendCtx())
|
||||
handleError("SetGameDeletionDate", err, "")
|
||||
|
||||
directories, err := os.ReadDir(musicPath)
|
||||
if err != nil {
|
||||
logging.GetLogger().Fatal("Failed to read music directory", zap.String("path", musicPath), zap.String("error", err.Error()))
|
||||
}
|
||||
|
||||
pool, _ = ants.NewPool(10, ants.WithPreAlloc(true))
|
||||
poolSong, _ = ants.NewPool(10, ants.WithPreAlloc(true))
|
||||
defer pool.Release()
|
||||
@@ -237,7 +233,7 @@ func syncGamesNew(full bool) {
|
||||
syncWg.Wait()
|
||||
checkBrokenSongsNew()
|
||||
|
||||
gamesAfterSync, err = repo.FindAllGames(db.Ctx)
|
||||
gamesAfterSync, err = repo.FindAllGames(BackendCtx())
|
||||
handleError("FindAllGames After", err, "")
|
||||
|
||||
finished := time.Now()
|
||||
@@ -249,7 +245,7 @@ func syncGamesNew(full bool) {
|
||||
}
|
||||
|
||||
func checkBrokenSongsNew() {
|
||||
allSongs, err := repo.FetchAllSongs(db.Ctx)
|
||||
allSongs, err := repo.FetchAllSongs(BackendCtx())
|
||||
handleError("FetchAllSongs", err, "")
|
||||
var brokenWg sync.WaitGroup
|
||||
poolBroken, _ := ants.NewPool(200, ants.WithPreAlloc(true))
|
||||
@@ -263,7 +259,7 @@ func checkBrokenSongsNew() {
|
||||
})
|
||||
}
|
||||
brokenWg.Wait()
|
||||
err = repo.RemoveBrokenSongs(db.Ctx, brokenSongs)
|
||||
err = repo.RemoveBrokenSongs(BackendCtx(), brokenSongs)
|
||||
handleError("RemoveBrokenSongs", err, "")
|
||||
}
|
||||
|
||||
@@ -315,7 +311,7 @@ func syncGameNew(file os.DirEntry, foldersToSkip []string, baseDir string, full
|
||||
}
|
||||
}
|
||||
|
||||
if full {
|
||||
if full && status != NewGame {
|
||||
status = TitleChanged
|
||||
}
|
||||
entries, err := os.ReadDir(gameDir)
|
||||
@@ -336,7 +332,7 @@ func syncGameNew(file os.DirEntry, foldersToSkip []string, baseDir string, full
|
||||
break
|
||||
}
|
||||
}
|
||||
err = repo.InsertGameWithExistingId(db.Ctx, repository.InsertGameWithExistingIdParams{ID: id, GameName: file.Name(), Path: gameDir, Hash: dirHash})
|
||||
err = repo.InsertGameWithExistingId(BackendCtx(), repository.InsertGameWithExistingIdParams{ID: id, GameName: file.Name(), Path: gameDir, Hash: dirHash})
|
||||
handleError("InsertGameWithExistingId", err, "")
|
||||
if err != nil {
|
||||
logging.GetLogger().Debug("Game already exists, removing old ID file",
|
||||
@@ -370,7 +366,7 @@ func syncGameNew(file os.DirEntry, foldersToSkip []string, baseDir string, full
|
||||
zap.String("game", file.Name()),
|
||||
zap.String("hash", dirHash),
|
||||
zap.String("status", status.String()))
|
||||
err = repo.UpdateGameHash(db.Ctx, repository.UpdateGameHashParams{Hash: dirHash, ID: id})
|
||||
err = repo.UpdateGameHash(BackendCtx(), repository.UpdateGameHashParams{Hash: dirHash, ID: id})
|
||||
handleError("UpdateGameHash", err, "")
|
||||
gamesChangedContent = append(gamesChangedContent, file.Name())
|
||||
newCheckSongs(entries, gameDir, id)
|
||||
@@ -381,7 +377,7 @@ func syncGameNew(file os.DirEntry, foldersToSkip []string, baseDir string, full
|
||||
zap.String("newName", file.Name()),
|
||||
zap.String("hash", dirHash),
|
||||
zap.String("status", status.String()))
|
||||
err = repo.UpdateGameName(db.Ctx, repository.UpdateGameNameParams{Name: file.Name(), Path: gameDir, ID: id})
|
||||
err = repo.UpdateGameName(BackendCtx(), repository.UpdateGameNameParams{Name: file.Name(), Path: gameDir, ID: id})
|
||||
handleError("UpdateGameName", err, "")
|
||||
newCheckSongs(entries, gameDir, id)
|
||||
if gamesChangedTitle == nil {
|
||||
@@ -416,7 +412,7 @@ func syncGameNew(file os.DirEntry, foldersToSkip []string, baseDir string, full
|
||||
zap.String("game", file.Name()),
|
||||
zap.String("hash", dirHash),
|
||||
zap.String("status", status.String()))
|
||||
err = repo.RemoveDeletionDate(db.Ctx, id)
|
||||
err = repo.RemoveDeletionDate(BackendCtx(), id)
|
||||
handleError("RemoveDeletionDate", err, "")
|
||||
}
|
||||
foldersSynced++
|
||||
@@ -428,13 +424,13 @@ func syncGameNew(file os.DirEntry, foldersToSkip []string, baseDir string, full
|
||||
|
||||
func insertGameNew(name string, path string, hash string) int32 {
|
||||
var duplicateError = errors.New("ERROR: duplicate key value violates unique")
|
||||
id, err := repo.InsertGame(db.Ctx, repository.InsertGameParams{GameName: name, Path: path, Hash: hash})
|
||||
id, err := repo.InsertGame(BackendCtx(), repository.InsertGameParams{GameName: name, Path: path, Hash: hash})
|
||||
handleError("InsertGame", err, "")
|
||||
if err != nil {
|
||||
logging.GetLogger().Warn("ID collision detected, resetting sequence")
|
||||
if strings.HasPrefix(err.Error(), duplicateError.Error()) {
|
||||
logging.GetLogger().Debug("Resetting game ID sequence")
|
||||
_, err = repo.ResetGameIdSeq(db.Ctx)
|
||||
_, err = repo.ResetGameIdSeq(BackendCtx())
|
||||
handleError("ResetGameIdSeq", err, "")
|
||||
id = insertGameNew(name, path, hash)
|
||||
}
|
||||
@@ -478,7 +474,7 @@ func newCheckSong(entry os.DirEntry, gameDir string, id int32) bool {
|
||||
fileName := entry.Name()
|
||||
songName, _ := strings.CutSuffix(fileName, ".mp3")
|
||||
|
||||
song, err := repo.GetSongWithHash(db.Ctx, songHash)
|
||||
song, err := repo.GetSongWithHash(BackendCtx(), songHash)
|
||||
handleError("GetSongWithHash", err, fmt.Sprintf("GameID: %d | Path: %s | SongName: %s | SongHash: %s", id, path, entry.Name(), songHash))
|
||||
if err == nil {
|
||||
if song.SongName == songName && song.Path == path {
|
||||
@@ -491,31 +487,31 @@ func newCheckSong(entry os.DirEntry, gameDir string, id int32) bool {
|
||||
zap.String("song_name", songName),
|
||||
zap.String("song_hash", songHash))
|
||||
|
||||
count, err := repo.CheckSongWithHash(db.Ctx, songHash)
|
||||
count, err := repo.CheckSongWithHash(BackendCtx(), songHash)
|
||||
handleError("CheckSongWithHash", err, fmt.Sprintf("GameID: %d | Path: %s | SongName: %s | SongHash: %s\n", id, path, entry.Name(), songHash))
|
||||
if err != nil {
|
||||
count2, err := repo.CheckSong(db.Ctx, path)
|
||||
count2, err := repo.CheckSong(BackendCtx(), path)
|
||||
handleError("CheckSong", err, fmt.Sprintf("GameID: %d | Path: %s | SongName: %s | SongHash: %s\n", id, path, entry.Name(), songHash))
|
||||
if count2 > 0 {
|
||||
err = repo.AddHashToSong(db.Ctx, repository.AddHashToSongParams{Hash: songHash, Path: path})
|
||||
err = repo.AddHashToSong(BackendCtx(), repository.AddHashToSongParams{Hash: songHash, Path: path})
|
||||
handleError("AddHashToSong", err, fmt.Sprintf("GameID: %d | Path: %s | SongName: %s | SongHash: %s", id, path, entry.Name(), songHash))
|
||||
count, err = repo.CheckSongWithHash(db.Ctx, songHash)
|
||||
count, err = repo.CheckSongWithHash(BackendCtx(), songHash)
|
||||
handleError("CheckSongWithHash 2", err, fmt.Sprintf("GameID: %d | Path: %s | SongName: %s | SongHash: %s", id, path, entry.Name(), songHash))
|
||||
}
|
||||
}
|
||||
|
||||
//count, _ := repo.CheckSong(ctx, path)
|
||||
if count > 0 {
|
||||
err = repo.UpdateSong(db.Ctx, repository.UpdateSongParams{SongName: songName, FileName: &fileName, Path: path, Hash: songHash})
|
||||
err = repo.UpdateSong(BackendCtx(), repository.UpdateSongParams{SongName: songName, FileName: &fileName, Path: path, Hash: songHash})
|
||||
handleError("UpdateSong", err, fmt.Sprintf("GameID: %d | Path: %s | SongName: %s | SongHash: %s", id, path, entry.Name(), songHash))
|
||||
} else {
|
||||
count2, err := repo.CheckSong(db.Ctx, path)
|
||||
count2, err := repo.CheckSong(BackendCtx(), path)
|
||||
handleError("CheckSong", err, fmt.Sprintf("GameID: %d | Path: %s | SongName: %s | SongHash: %s", id, path, entry.Name(), songHash))
|
||||
if count2 > 0 {
|
||||
err = repo.AddHashToSong(db.Ctx, repository.AddHashToSongParams{Hash: songHash, Path: path})
|
||||
err = repo.AddHashToSong(BackendCtx(), repository.AddHashToSongParams{Hash: songHash, Path: path})
|
||||
handleError("AddHashToSong", err, fmt.Sprintf("GameID: %d | Path: %s | SongName: %s | SongHash: %s", id, path, entry.Name(), songHash))
|
||||
} else {
|
||||
err = repo.AddSong(db.Ctx, repository.AddSongParams{GameID: id, SongName: songName, Path: path, FileName: &fileName, Hash: songHash})
|
||||
err = repo.AddSong(BackendCtx(), repository.AddSongParams{GameID: id, SongName: songName, Path: path, FileName: &fileName, Hash: songHash})
|
||||
handleError("AddSong", err, fmt.Sprintf("GameID: %d | Path: %s | SongName: %s | SongHash: %s", id, path, entry.Name(), songHash))
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
|
||||
"music-server/internal/logging"
|
||||
|
||||
"github.com/golang-migrate/migrate/v4"
|
||||
"github.com/golang-migrate/migrate/v4/database/postgres"
|
||||
_ "github.com/golang-migrate/migrate/v4/database/postgres"
|
||||
"github.com/golang-migrate/migrate/v4/source/iofs"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
_ "github.com/lib/pq"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// Database holds the database connection pool and context
|
||||
type Database struct {
|
||||
Pool *pgxpool.Pool
|
||||
Ctx context.Context
|
||||
}
|
||||
|
||||
// NewDatabase creates a new Database instance with connection pool
|
||||
func NewDatabase(host, port, user, password, dbname string) (*Database, error) {
|
||||
ctx := context.Background()
|
||||
psqlInfo := fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s sslmode=disable",
|
||||
host, port, user, password, dbname)
|
||||
|
||||
logging.GetLogger().Debug("Database connection info",
|
||||
zap.String("host", host),
|
||||
zap.String("port", port),
|
||||
zap.String("dbname", dbname))
|
||||
|
||||
pool, err := pgxpool.New(ctx, psqlInfo)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unable to connect to database: %w", err)
|
||||
}
|
||||
|
||||
// Test connection
|
||||
var success string
|
||||
err = pool.QueryRow(ctx, "select 'Successfully connected!'").Scan(&success)
|
||||
if err != nil {
|
||||
pool.Close()
|
||||
return nil, fmt.Errorf("database query failed: %w", err)
|
||||
}
|
||||
|
||||
logging.GetLogger().Info("Database connected", zap.String("status", success))
|
||||
|
||||
return &Database{Pool: pool, Ctx: ctx}, nil
|
||||
}
|
||||
|
||||
// Close closes the database connection pool
|
||||
func (db *Database) Close() {
|
||||
if db.Pool != nil {
|
||||
logging.GetLogger().Info("Closing database connection")
|
||||
db.Pool.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// RunMigrations runs all pending database migrations to the latest version.
|
||||
// Uses the existing pool to extract connection details.
|
||||
func (db *Database) RunMigrations() error {
|
||||
// Extract connection info from pool config
|
||||
connConfig := db.Pool.Config().ConnConfig
|
||||
migrationURL := fmt.Sprintf("postgres://%s:%s@%s:%d/%s?sslmode=disable",
|
||||
connConfig.User,
|
||||
connConfig.Password,
|
||||
connConfig.Host,
|
||||
connConfig.Port,
|
||||
connConfig.Database)
|
||||
|
||||
logging.GetLogger().Debug("Migration info", zap.String("url", migrationURL))
|
||||
|
||||
sqlDb, err := sql.Open("postgres", migrationURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to open database for migration: %w", err)
|
||||
}
|
||||
defer sqlDb.Close()
|
||||
|
||||
driver, err := postgres.WithInstance(sqlDb, &postgres.Config{})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create migration driver: %w", err)
|
||||
}
|
||||
|
||||
files, err := iofs.New(MigrationsFs, "migrations")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create migration files: %w", err)
|
||||
}
|
||||
|
||||
m, err := migrate.NewWithInstance("iofs", files, "postgres", driver)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create migrator: %w", err)
|
||||
}
|
||||
|
||||
// Get current version for logging
|
||||
version, _, err := m.Version()
|
||||
if err != nil {
|
||||
logging.GetLogger().Error("Failed to get migration version", zap.String("error", err.Error()))
|
||||
}
|
||||
|
||||
logging.GetLogger().Info("Migration version before", zap.Uint("version", version))
|
||||
|
||||
// Run all pending migrations to latest version
|
||||
err = m.Up()
|
||||
if err != nil {
|
||||
if err == migrate.ErrNoChange {
|
||||
logging.GetLogger().Info("Database already up to date")
|
||||
} else {
|
||||
return fmt.Errorf("migration failed: %w", err)
|
||||
}
|
||||
} else {
|
||||
// Get new version after migration
|
||||
versionAfter, _, _ := m.Version()
|
||||
logging.GetLogger().Info("Migrated to version", zap.Uint("version", versionAfter))
|
||||
}
|
||||
|
||||
logging.GetLogger().Info("Migration completed")
|
||||
return nil
|
||||
}
|
||||
+14
-10
@@ -121,19 +121,23 @@ func Migrate_db(host string, port string, user string, password string, dbname s
|
||||
// logging.GetLogger().Error("Force migration error", zap.String("error", err.Error()))
|
||||
//}
|
||||
|
||||
err = m.Migrate(2)
|
||||
//err = m.Up() // or m.Steps(2) if you want to explicitly set the number of migrations to run
|
||||
// Use Up() to apply all pending migrations instead of Migrate(2)
|
||||
err = m.Up()
|
||||
if err != nil {
|
||||
logging.GetLogger().Error("Migration error", zap.String("error", err.Error()))
|
||||
if err == migrate.ErrNoChange {
|
||||
logging.GetLogger().Info("Database already up to date")
|
||||
} else {
|
||||
logging.GetLogger().Error("Migration error", zap.String("error", err.Error()))
|
||||
}
|
||||
} else {
|
||||
versionAfter, _, err := m.Version()
|
||||
if err != nil {
|
||||
logging.GetLogger().Error("Failed to get migration version after", zap.String("error", err.Error()))
|
||||
} else {
|
||||
logging.GetLogger().Info("Migrated to version", zap.Uint("version", versionAfter))
|
||||
}
|
||||
}
|
||||
|
||||
versionAfter, _, err := m.Version()
|
||||
if err != nil {
|
||||
logging.GetLogger().Error("Failed to get migration version after", zap.String("error", err.Error()))
|
||||
}
|
||||
|
||||
logging.GetLogger().Info("Migration version after", zap.Uint("version", versionAfter))
|
||||
|
||||
logging.GetLogger().Info("Migration completed")
|
||||
|
||||
db.Close()
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
-- Drop indexes for sessions table
|
||||
DROP INDEX IF EXISTS idx_sessions_expires;
|
||||
DROP INDEX IF EXISTS idx_sessions_token;
|
||||
DROP INDEX IF EXISTS idx_sessions_ip;
|
||||
DROP INDEX IF EXISTS idx_sessions_created;
|
||||
|
||||
-- Drop sessions table
|
||||
DROP TABLE IF EXISTS sessions;
|
||||
|
||||
-- Drop performance indexes for song_list
|
||||
DROP INDEX IF EXISTS idx_song_list_match_date;
|
||||
DROP INDEX IF EXISTS idx_song_list_match_id;
|
||||
|
||||
-- Drop performance indexes for song
|
||||
DROP INDEX IF EXISTS idx_song_hash;
|
||||
DROP INDEX IF EXISTS idx_song_path;
|
||||
DROP INDEX IF EXISTS idx_song_game_id;
|
||||
DROP INDEX IF EXISTS idx_song_game_id_song_name;
|
||||
|
||||
-- Drop performance indexes for game
|
||||
DROP INDEX IF EXISTS idx_game_deleted;
|
||||
DROP INDEX IF EXISTS idx_game_hash;
|
||||
DROP INDEX IF EXISTS idx_game_path;
|
||||
DROP INDEX IF EXISTS idx_game_name;
|
||||
@@ -0,0 +1,39 @@
|
||||
-- ============================================
|
||||
-- PERFORMANCE INDEXES FOR EXISTING TABLES
|
||||
-- ============================================
|
||||
|
||||
-- Game table indexes
|
||||
CREATE INDEX IF NOT EXISTS idx_game_deleted ON game(deleted) WHERE deleted IS NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_game_hash ON game(hash);
|
||||
CREATE INDEX IF NOT EXISTS idx_game_path ON game(path);
|
||||
CREATE INDEX IF NOT EXISTS idx_game_name ON game(game_name);
|
||||
|
||||
-- Song table indexes
|
||||
CREATE INDEX IF NOT EXISTS idx_song_hash ON song(hash);
|
||||
CREATE INDEX IF NOT EXISTS idx_song_path ON song(path);
|
||||
CREATE INDEX IF NOT EXISTS idx_song_game_id ON song(game_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_song_game_id_song_name ON song(game_id, song_name);
|
||||
|
||||
-- Song_list table indexes
|
||||
CREATE INDEX IF NOT EXISTS idx_song_list_match_date ON song_list(match_date);
|
||||
CREATE INDEX IF NOT EXISTS idx_song_list_match_id ON song_list(match_id);
|
||||
|
||||
-- ============================================
|
||||
-- SESSIONS TABLE FOR TOKEN MANAGEMENT
|
||||
-- ============================================
|
||||
|
||||
-- Create sessions table for tracking client tokens
|
||||
CREATE TABLE sessions (
|
||||
token VARCHAR(64) PRIMARY KEY,
|
||||
ip_address VARCHAR(45) NOT NULL,
|
||||
user_agent TEXT NOT NULL,
|
||||
client_type VARCHAR(20) DEFAULT 'web',
|
||||
expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Indexes for fast lookup and cleanup
|
||||
CREATE INDEX idx_sessions_expires ON sessions(expires_at);
|
||||
CREATE INDEX idx_sessions_token ON sessions(token);
|
||||
CREATE INDEX idx_sessions_ip ON sessions(ip_address);
|
||||
CREATE INDEX idx_sessions_created ON sessions(created_at);
|
||||
@@ -0,0 +1,23 @@
|
||||
-- name: CreateSession :one
|
||||
INSERT INTO sessions (token, ip_address, user_agent, client_type, expires_at)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING token, ip_address, user_agent, client_type, expires_at, created_at;
|
||||
|
||||
-- name: GetSession :one
|
||||
SELECT token, ip_address, user_agent, client_type, expires_at, created_at
|
||||
FROM sessions
|
||||
WHERE token = $1
|
||||
LIMIT 1;
|
||||
|
||||
-- name: DeleteSession :exec
|
||||
DELETE FROM sessions
|
||||
WHERE token = $1;
|
||||
|
||||
-- name: DeleteExpiredSessions :exec
|
||||
DELETE FROM sessions
|
||||
WHERE expires_at < NOW();
|
||||
|
||||
-- name: ListSessions :many
|
||||
SELECT token, ip_address, user_agent, client_type, expires_at, created_at
|
||||
FROM sessions
|
||||
ORDER BY created_at DESC;
|
||||
@@ -6,6 +6,8 @@ package repository
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
type Game struct {
|
||||
@@ -21,6 +23,15 @@ type Game struct {
|
||||
Hash string `json:"hash"`
|
||||
}
|
||||
|
||||
type Session struct {
|
||||
Token string `json:"token"`
|
||||
IpAddress string `json:"ip_address"`
|
||||
UserAgent string `json:"user_agent"`
|
||||
ClientType *string `json:"client_type"`
|
||||
ExpiresAt pgtype.Timestamptz `json:"expires_at"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
}
|
||||
|
||||
type Song struct {
|
||||
GameID int32 `json:"game_id"`
|
||||
SongName string `json:"song_name"`
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.31.1
|
||||
// source: session.sql
|
||||
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
const createSession = `-- name: CreateSession :one
|
||||
INSERT INTO sessions (token, ip_address, user_agent, client_type, expires_at)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING token, ip_address, user_agent, client_type, expires_at, created_at
|
||||
`
|
||||
|
||||
type CreateSessionParams struct {
|
||||
Token string `json:"token"`
|
||||
IpAddress string `json:"ip_address"`
|
||||
UserAgent string `json:"user_agent"`
|
||||
ClientType *string `json:"client_type"`
|
||||
ExpiresAt pgtype.Timestamptz `json:"expires_at"`
|
||||
}
|
||||
|
||||
func (q *Queries) CreateSession(ctx context.Context, arg CreateSessionParams) (Session, error) {
|
||||
row := q.db.QueryRow(ctx, createSession,
|
||||
arg.Token,
|
||||
arg.IpAddress,
|
||||
arg.UserAgent,
|
||||
arg.ClientType,
|
||||
arg.ExpiresAt,
|
||||
)
|
||||
var i Session
|
||||
err := row.Scan(
|
||||
&i.Token,
|
||||
&i.IpAddress,
|
||||
&i.UserAgent,
|
||||
&i.ClientType,
|
||||
&i.ExpiresAt,
|
||||
&i.CreatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const deleteExpiredSessions = `-- name: DeleteExpiredSessions :exec
|
||||
DELETE FROM sessions
|
||||
WHERE expires_at < NOW()
|
||||
`
|
||||
|
||||
func (q *Queries) DeleteExpiredSessions(ctx context.Context) error {
|
||||
_, err := q.db.Exec(ctx, deleteExpiredSessions)
|
||||
return err
|
||||
}
|
||||
|
||||
const deleteSession = `-- name: DeleteSession :exec
|
||||
DELETE FROM sessions
|
||||
WHERE token = $1
|
||||
`
|
||||
|
||||
func (q *Queries) DeleteSession(ctx context.Context, token string) error {
|
||||
_, err := q.db.Exec(ctx, deleteSession, token)
|
||||
return err
|
||||
}
|
||||
|
||||
const getSession = `-- name: GetSession :one
|
||||
SELECT token, ip_address, user_agent, client_type, expires_at, created_at
|
||||
FROM sessions
|
||||
WHERE token = $1
|
||||
LIMIT 1
|
||||
`
|
||||
|
||||
func (q *Queries) GetSession(ctx context.Context, token string) (Session, error) {
|
||||
row := q.db.QueryRow(ctx, getSession, token)
|
||||
var i Session
|
||||
err := row.Scan(
|
||||
&i.Token,
|
||||
&i.IpAddress,
|
||||
&i.UserAgent,
|
||||
&i.ClientType,
|
||||
&i.ExpiresAt,
|
||||
&i.CreatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const listSessions = `-- name: ListSessions :many
|
||||
SELECT token, ip_address, user_agent, client_type, expires_at, created_at
|
||||
FROM sessions
|
||||
ORDER BY created_at DESC
|
||||
`
|
||||
|
||||
func (q *Queries) ListSessions(ctx context.Context) ([]Session, error) {
|
||||
rows, err := q.db.Query(ctx, listSessions)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []Session
|
||||
for rows.Next() {
|
||||
var i Session
|
||||
if err := rows.Scan(
|
||||
&i.Token,
|
||||
&i.IpAddress,
|
||||
&i.UserAgent,
|
||||
&i.ClientType,
|
||||
&i.ExpiresAt,
|
||||
&i.CreatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package server
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
"github.com/labstack/echo/v5"
|
||||
"music-server/internal/backend"
|
||||
@@ -38,5 +39,11 @@ func (c *CharacterHandler) GetCharacterList(ctx *echo.Context) error {
|
||||
// @Router /character [get]
|
||||
func (c *CharacterHandler) GetCharacter(ctx *echo.Context) error {
|
||||
character := ctx.QueryParam("name")
|
||||
return ctx.File(backend.GetCharacter(character))
|
||||
characterPath := backend.GetCharacter(character)
|
||||
file, err := os.Open(characterPath)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusNotFound, err.Error())
|
||||
}
|
||||
defer file.Close()
|
||||
return ctx.Stream(http.StatusOK, "image/png", file)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
// Package middleware provides Echo middleware for the MusicServer application.
|
||||
package middleware
|
||||
@@ -0,0 +1,77 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"music-server/internal/db/repository"
|
||||
"music-server/internal/logging"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"github.com/labstack/echo/v5"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// TokenAuthMiddleware returns an Echo middleware that validates session tokens
|
||||
func TokenAuthMiddleware(pool *pgxpool.Pool) echo.MiddlewareFunc {
|
||||
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
return func(c *echo.Context) error {
|
||||
// Extract token from Authorization header
|
||||
authHeader := c.Request().Header.Get("Authorization")
|
||||
if authHeader == "" {
|
||||
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "Authorization header required"})
|
||||
}
|
||||
|
||||
// Bearer token format
|
||||
parts := strings.Split(authHeader, " ")
|
||||
if len(parts) != 2 || parts[0] != "Bearer" {
|
||||
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "Invalid authorization format. Use: Bearer <token>"})
|
||||
}
|
||||
|
||||
token := parts[1]
|
||||
queries := repository.New(pool)
|
||||
session, err := queries.GetSession(c.Request().Context(), token)
|
||||
if err != nil {
|
||||
logging.GetLogger().Warn("Invalid token attempt",
|
||||
zap.String("token", token),
|
||||
zap.String("ip", c.RealIP()),
|
||||
zap.String("error", err.Error()),
|
||||
)
|
||||
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "Invalid or expired token"})
|
||||
}
|
||||
|
||||
// Check if token is expired
|
||||
if time.Now().After(session.ExpiresAt.Time) {
|
||||
// Clean up expired session in background
|
||||
go func() {
|
||||
queries.DeleteSession(c.Request().Context(), token)
|
||||
}()
|
||||
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "Token expired"})
|
||||
}
|
||||
|
||||
// Add session to request context for potential use by handlers
|
||||
c.Set("session", session)
|
||||
|
||||
return next(c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TokenIPCheckMiddleware checks if the request IP matches the session IP
|
||||
func TokenIPCheckMiddleware(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
return func(c *echo.Context) error {
|
||||
sessionVal := c.Get("session")
|
||||
if sessionVal == nil {
|
||||
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "No session in context"})
|
||||
}
|
||||
session := sessionVal.(repository.Session)
|
||||
if session.IpAddress != c.RealIP() {
|
||||
logging.GetLogger().Warn("Token IP mismatch",
|
||||
zap.String("token_ip", session.IpAddress),
|
||||
zap.String("request_ip", c.RealIP()),
|
||||
)
|
||||
}
|
||||
return next(c)
|
||||
}
|
||||
}
|
||||
@@ -2,16 +2,17 @@ package server
|
||||
|
||||
import (
|
||||
"music-server/cmd/web"
|
||||
"music-server/internal/logging"
|
||||
"music-server/internal/server/middleware"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/a-h/templ"
|
||||
"github.com/labstack/echo/v5"
|
||||
"github.com/labstack/echo/v5/middleware"
|
||||
echoMiddleware "github.com/labstack/echo/v5/middleware"
|
||||
echoSwagger "github.com/swaggo/echo-swagger/v2"
|
||||
"go.uber.org/zap"
|
||||
"music-server/internal/logging"
|
||||
)
|
||||
|
||||
// @Title MusicServer API
|
||||
@@ -36,9 +37,9 @@ func (s *Server) RegisterRoutes() http.Handler {
|
||||
http.ServeFile(w, r, "cmd/docs/swagger.json")
|
||||
})))
|
||||
e.Use(logging.RequestLogger())
|
||||
e.Use(middleware.Recover())
|
||||
e.Use(echoMiddleware.Recover())
|
||||
|
||||
e.Use(middleware.CORSWithConfig(middleware.CORSConfig{
|
||||
e.Use(echoMiddleware.CORSWithConfig(echoMiddleware.CORSConfig{
|
||||
AllowOrigins: []string{"https://*", "http://*"},
|
||||
AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"},
|
||||
AllowHeaders: []string{"Accept", "Authorization", "Content-Type", "X-CSRF-Token"},
|
||||
@@ -103,6 +104,33 @@ func (s *Server) RegisterRoutes() http.Handler {
|
||||
musicGroup.GET("/addQue", music.AddLatestToQue)
|
||||
musicGroup.GET("/addPlayed", music.AddLatestPlayed)
|
||||
|
||||
// ============================================
|
||||
// API v1 Routes with Token Authentication
|
||||
// ============================================
|
||||
|
||||
// Create /api/v1 group
|
||||
apiV1 := e.Group("/api/v1")
|
||||
|
||||
// Public endpoints - no token required
|
||||
apiV1.POST("/token", func(c *echo.Context) error {
|
||||
return s.tokenHandler.CreateTokenHandler(c)
|
||||
})
|
||||
apiV1.DELETE("/token", func(c *echo.Context) error {
|
||||
return s.tokenHandler.DeleteTokenHandler(c)
|
||||
})
|
||||
apiV1.POST("/token/cleanup", func(c *echo.Context) error {
|
||||
return s.tokenHandler.CleanupExpiredSessionsHandler(c)
|
||||
})
|
||||
|
||||
// Protected endpoints - require valid token
|
||||
// Create token auth middleware with pool access
|
||||
tokenAuthMiddleware := middleware.TokenAuthMiddleware(s.db.Pool)
|
||||
|
||||
// Protected group with token authentication - will be used by VGMQ and Statistics API
|
||||
_ = apiV1.Group("", tokenAuthMiddleware)
|
||||
|
||||
// Note: Future protected endpoints (VGMQ, Statistics) will be added here
|
||||
|
||||
routes := e.Router().Routes()
|
||||
sort.Slice(routes, func(i, j int) bool {
|
||||
return routes[i].Path < routes[j].Path
|
||||
|
||||
+62
-23
@@ -6,6 +6,7 @@ import (
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"music-server/internal/backend"
|
||||
"music-server/internal/db"
|
||||
"music-server/internal/logging"
|
||||
"net/http"
|
||||
@@ -14,7 +15,10 @@ import (
|
||||
)
|
||||
|
||||
type Server struct {
|
||||
port int
|
||||
port int
|
||||
db *db.Database
|
||||
tokenHandler *TokenHandler
|
||||
httpServer *http.Server
|
||||
}
|
||||
|
||||
var (
|
||||
@@ -29,7 +33,9 @@ var (
|
||||
logJSON = os.Getenv("LOG_JSON") == "true"
|
||||
)
|
||||
|
||||
func NewServer() *http.Server {
|
||||
// NewServerInstance creates a new Server instance with all dependencies initialized.
|
||||
// Use this for dependency injection and proper lifecycle management.
|
||||
func NewServerInstance() *Server {
|
||||
// Initialize logger
|
||||
if logLevel == "" {
|
||||
logLevel = "info"
|
||||
@@ -39,8 +45,43 @@ func NewServer() *http.Server {
|
||||
logger := logging.GetLogger()
|
||||
|
||||
port, _ := strconv.Atoi(os.Getenv("PORT"))
|
||||
NewServer := &Server{
|
||||
port: port,
|
||||
|
||||
// Validate required environment variables
|
||||
if host == "" || dbPort == "" || username == "" || password == "" || dbName == "" || musicPath == "" || charactersPath == "" {
|
||||
logging.GetLogger().Fatal("Invalid settings - missing required environment variables")
|
||||
}
|
||||
|
||||
// Create database instance
|
||||
database, err := db.NewDatabase(host, dbPort, username, password, dbName)
|
||||
if err != nil {
|
||||
logging.GetLogger().Fatal("Failed to initialize database", zap.String("error", err.Error()))
|
||||
}
|
||||
|
||||
// Run migrations using the new method
|
||||
if err := database.RunMigrations(); err != nil {
|
||||
logging.GetLogger().Fatal("Migration failed", zap.String("error", err.Error()))
|
||||
}
|
||||
|
||||
// Initialize backend package with database pool
|
||||
backend.InitBackend(database.Pool)
|
||||
|
||||
// Initialize token handler with database pool
|
||||
tokenHandler := NewTokenHandler(database.Pool)
|
||||
|
||||
// Create the server instance
|
||||
appServer := &Server{
|
||||
port: port,
|
||||
db: database,
|
||||
tokenHandler: tokenHandler,
|
||||
}
|
||||
|
||||
// Create the HTTP server
|
||||
appServer.httpServer = &http.Server{
|
||||
Addr: fmt.Sprintf(":%d", port),
|
||||
Handler: appServer.RegisterRoutes(),
|
||||
IdleTimeout: time.Minute,
|
||||
ReadTimeout: 10 * time.Second,
|
||||
WriteTimeout: 30 * time.Second,
|
||||
}
|
||||
|
||||
logger.Info("Starting server",
|
||||
@@ -55,23 +96,21 @@ func NewServer() *http.Server {
|
||||
zap.String("charactersPath", charactersPath),
|
||||
)
|
||||
|
||||
//conf.SetupDb()
|
||||
if host == "" || dbPort == "" || username == "" || password == "" || dbName == "" || musicPath == "" || charactersPath == "" {
|
||||
logging.GetLogger().Fatal("Invalid settings - missing required environment variables")
|
||||
}
|
||||
|
||||
db.Migrate_db(host, dbPort, username, password, dbName)
|
||||
|
||||
db.InitDB(host, dbPort, username, password, dbName)
|
||||
|
||||
// Declare Server config
|
||||
server := &http.Server{
|
||||
Addr: fmt.Sprintf(":%d", NewServer.port),
|
||||
Handler: NewServer.RegisterRoutes(),
|
||||
IdleTimeout: time.Minute,
|
||||
ReadTimeout: 10 * time.Second,
|
||||
WriteTimeout: 30 * time.Second,
|
||||
}
|
||||
|
||||
return server
|
||||
return appServer
|
||||
}
|
||||
|
||||
// HTTPServer returns the underlying http.Server for serving HTTP requests.
|
||||
func (s *Server) HTTPServer() *http.Server {
|
||||
return s.httpServer
|
||||
}
|
||||
|
||||
// DB returns the database instance for dependency injection.
|
||||
func (s *Server) DB() *db.Database {
|
||||
return s.db
|
||||
}
|
||||
|
||||
// NewServer creates a new HTTP server (deprecated, use NewServerInstance instead).
|
||||
// This function is kept for backward compatibility.
|
||||
func NewServer() *http.Server {
|
||||
return NewServerInstance().HTTPServer()
|
||||
}
|
||||
|
||||
@@ -49,6 +49,7 @@ func (s *SyncHandler) SyncGamesNewOnlyChanges(ctx *echo.Context) error {
|
||||
return ctx.JSON(http.StatusLocked, "Syncing is in progress")
|
||||
}
|
||||
logging.GetLogger().Info("Starting sync with only changes")
|
||||
backend.Syncing = true
|
||||
go backend.SyncGamesNewOnlyChanges()
|
||||
return ctx.JSON(http.StatusOK, "Start syncing games")
|
||||
}
|
||||
@@ -68,6 +69,7 @@ func (s *SyncHandler) SyncGamesNewFull(ctx *echo.Context) error {
|
||||
return ctx.JSON(http.StatusLocked, "Syncing is in progress")
|
||||
}
|
||||
logging.GetLogger().Info("Starting full sync")
|
||||
backend.Syncing = true
|
||||
go backend.SyncGamesNewFull()
|
||||
return ctx.JSON(http.StatusOK, "Start syncing games full")
|
||||
}
|
||||
|
||||
@@ -75,8 +75,8 @@ func TestSyncPopulatesDatabase(t *testing.T) {
|
||||
db.TestClearDatabase(t)
|
||||
|
||||
// Before sync - should have no games
|
||||
repo := repository.New(db.Dbpool)
|
||||
gamesBefore, err := repo.FindAllGames(db.Ctx)
|
||||
repo := repository.New(backend.BackendPool())
|
||||
gamesBefore, err := repo.FindAllGames(backend.BackendCtx())
|
||||
assert.NoError(t, err)
|
||||
beforeCount := len(gamesBefore)
|
||||
t.Logf("Games before sync: %d", beforeCount)
|
||||
@@ -92,7 +92,7 @@ func TestSyncPopulatesDatabase(t *testing.T) {
|
||||
}
|
||||
|
||||
// After sync - should have games
|
||||
gamesAfter, err := repo.FindAllGames(db.Ctx)
|
||||
gamesAfter, err := repo.FindAllGames(backend.BackendCtx())
|
||||
assert.NoError(t, err)
|
||||
afterCount := len(gamesAfter)
|
||||
t.Logf("Games after sync: %d", afterCount)
|
||||
@@ -112,8 +112,8 @@ func TestSyncMakesDifference(t *testing.T) {
|
||||
db.TestClearDatabase(t)
|
||||
|
||||
// Before sync - should have no games
|
||||
repo := repository.New(db.Dbpool)
|
||||
gamesBefore, err := repo.FindAllGames(db.Ctx)
|
||||
repo := repository.New(backend.BackendPool())
|
||||
gamesBefore, err := repo.FindAllGames(backend.BackendCtx())
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 0, len(gamesBefore), "Should have no games before sync")
|
||||
|
||||
@@ -127,7 +127,7 @@ func TestSyncMakesDifference(t *testing.T) {
|
||||
}
|
||||
|
||||
// After sync - should have games
|
||||
gamesAfter, err := repo.FindAllGames(db.Ctx)
|
||||
gamesAfter, err := repo.FindAllGames(backend.BackendCtx())
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, len(gamesAfter) > 0, "Should have games after sync")
|
||||
}
|
||||
@@ -199,8 +199,8 @@ func TestSyncGamesNewOnlyChanges(t *testing.T) {
|
||||
}
|
||||
|
||||
// Get initial count
|
||||
repo := repository.New(db.Dbpool)
|
||||
gamesBefore, _ := repo.FindAllGames(db.Ctx)
|
||||
repo := repository.New(backend.BackendPool())
|
||||
gamesBefore, _ := repo.FindAllGames(backend.BackendCtx())
|
||||
beforeCount := len(gamesBefore)
|
||||
|
||||
// Run incremental sync (should not change count if nothing changed)
|
||||
@@ -211,7 +211,7 @@ func TestSyncGamesNewOnlyChanges(t *testing.T) {
|
||||
time.Sleep(2 * time.Second)
|
||||
|
||||
// Count should be the same
|
||||
gamesAfter, _ := repo.FindAllGames(db.Ctx)
|
||||
gamesAfter, _ := repo.FindAllGames(backend.BackendCtx())
|
||||
afterCount := len(gamesAfter)
|
||||
|
||||
// Note: This might not be exactly equal due to timing, but should be close
|
||||
@@ -227,8 +227,8 @@ func TestResetGames(t *testing.T) {
|
||||
e := StartTestServer(t)
|
||||
|
||||
// First ensure we have data
|
||||
repo := repository.New(db.Dbpool)
|
||||
gamesBefore, _ := repo.FindAllGames(db.Ctx)
|
||||
repo := repository.New(backend.BackendPool())
|
||||
gamesBefore, _ := repo.FindAllGames(backend.BackendCtx())
|
||||
beforeCount := len(gamesBefore)
|
||||
|
||||
if beforeCount == 0 {
|
||||
@@ -238,7 +238,7 @@ func TestResetGames(t *testing.T) {
|
||||
t.Error("Sync did not complete within timeout")
|
||||
return
|
||||
}
|
||||
gamesBefore, _ = repo.FindAllGames(db.Ctx)
|
||||
gamesBefore, _ = repo.FindAllGames(backend.BackendCtx())
|
||||
beforeCount = len(gamesBefore)
|
||||
}
|
||||
|
||||
@@ -253,7 +253,7 @@ func TestResetGames(t *testing.T) {
|
||||
// Note: reset might take a moment to propagate
|
||||
time.Sleep(1 * time.Second)
|
||||
|
||||
gamesAfter, _ := repo.FindAllGames(db.Ctx)
|
||||
gamesAfter, _ := repo.FindAllGames(backend.BackendCtx())
|
||||
afterCount := len(gamesAfter)
|
||||
|
||||
t.Logf("Games after reset: %d", afterCount)
|
||||
@@ -281,8 +281,8 @@ func TestSyncGamesNewFull(t *testing.T) {
|
||||
}
|
||||
|
||||
// Verify database is populated
|
||||
repo := repository.New(db.Dbpool)
|
||||
games, err := repo.FindAllGames(db.Ctx)
|
||||
repo := repository.New(backend.BackendPool())
|
||||
games, err := repo.FindAllGames(backend.BackendCtx())
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, len(games) > 0, "Database should be populated after full sync")
|
||||
t.Logf("Full sync populated %d games", len(games))
|
||||
|
||||
@@ -8,6 +8,9 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"music-server/internal/backend"
|
||||
"music-server/internal/db"
|
||||
|
||||
"github.com/labstack/echo/v5"
|
||||
)
|
||||
|
||||
@@ -45,8 +48,23 @@ func StartTestServer(t *testing.T) *echo.Echo {
|
||||
os.Setenv("LOG_JSON", "false")
|
||||
}
|
||||
|
||||
// Initialize database for tests
|
||||
db.TestSetupDB(t)
|
||||
|
||||
// Initialize backend with the global Dbpool
|
||||
// This ensures BackendRepo() and BackendCtx() are available
|
||||
if db.Dbpool != nil {
|
||||
backend.InitBackend(db.Dbpool)
|
||||
}
|
||||
|
||||
// Create a Server instance and get its routes
|
||||
s := &Server{}
|
||||
s := &Server{
|
||||
db: &db.Database{
|
||||
Pool: db.Dbpool,
|
||||
Ctx: db.Ctx,
|
||||
},
|
||||
tokenHandler: NewTokenHandler(db.Dbpool),
|
||||
}
|
||||
handler := s.RegisterRoutes()
|
||||
|
||||
// Wrap the http.Handler in an echo.Echo
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"music-server/internal/db/repository"
|
||||
"music-server/internal/logging"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
"github.com/labstack/echo/v5"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// TokenRequest represents a request to generate a new token
|
||||
type TokenRequest struct {
|
||||
ClientType string `json:"client_type"` // Optional: "web", "mobile", "api"
|
||||
}
|
||||
|
||||
// TokenResponse represents the response with a new token
|
||||
type TokenResponse struct {
|
||||
Token string `json:"token"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
ClientType string `json:"client_type"`
|
||||
}
|
||||
|
||||
// TokenHandler contains the database pool for token operations
|
||||
type TokenHandler struct {
|
||||
pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
// NewTokenHandler creates a new token handler with database pool
|
||||
func NewTokenHandler(pool *pgxpool.Pool) *TokenHandler {
|
||||
return &TokenHandler{
|
||||
pool: pool,
|
||||
}
|
||||
}
|
||||
|
||||
// generateToken creates a new cryptographically secure token
|
||||
func (h *TokenHandler) generateToken() (string, error) {
|
||||
b := make([]byte, 32)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.URLEncoding.EncodeToString(b), nil
|
||||
}
|
||||
|
||||
// CreateTokenHandler creates a new session token
|
||||
// POST /api/v1/token
|
||||
//
|
||||
// @Summary Create session token
|
||||
// @Description Returns a new session token for API access
|
||||
// @Tags auth
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param request body TokenRequest true "Client type"
|
||||
// @Success 200 {object} TokenResponse
|
||||
// @Failure 400 {object} map[string]string
|
||||
// @Failure 500 {object} map[string]string
|
||||
// @Router /api/v1/token [post]
|
||||
func (h *TokenHandler) CreateTokenHandler(c *echo.Context) error {
|
||||
var req TokenRequest
|
||||
if err := c.Bind(&req); err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid request body"})
|
||||
}
|
||||
|
||||
if req.ClientType == "" {
|
||||
req.ClientType = "web"
|
||||
}
|
||||
|
||||
// Generate token
|
||||
token, err := h.generateToken()
|
||||
if err != nil {
|
||||
logging.GetLogger().Error("Failed to generate token", zap.String("error", err.Error()))
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to generate token"})
|
||||
}
|
||||
|
||||
// Set expiration (24 hours from now)
|
||||
expiresAt := time.Now().Add(24 * time.Hour)
|
||||
clientType := req.ClientType
|
||||
|
||||
// Store in database using sqlc-generated repository
|
||||
queries := repository.New(h.pool)
|
||||
session, err := queries.CreateSession(c.Request().Context(), repository.CreateSessionParams{
|
||||
Token: token,
|
||||
IpAddress: c.RealIP(),
|
||||
UserAgent: c.Request().UserAgent(),
|
||||
ClientType: &clientType,
|
||||
ExpiresAt: pgtype.Timestamptz{Time: expiresAt, Valid: true},
|
||||
})
|
||||
if err != nil {
|
||||
logging.GetLogger().Error("Failed to create session", zap.String("error", err.Error()))
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to create session"})
|
||||
}
|
||||
|
||||
response := TokenResponse{
|
||||
Token: session.Token,
|
||||
ExpiresAt: session.ExpiresAt.Time,
|
||||
ClientType: *session.ClientType,
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, response)
|
||||
}
|
||||
|
||||
// DeleteTokenHandler invalidates a session token
|
||||
// DELETE /api/v1/token
|
||||
//
|
||||
// @Summary Invalidate session token
|
||||
// @Description Deletes the current session token
|
||||
// @Tags auth
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param Authorization header string true "Bearer token"
|
||||
// @Success 200 {object} map[string]string
|
||||
// @Failure 401 {object} map[string]string
|
||||
// @Failure 500 {object} map[string]string
|
||||
// @Router /api/v1/token [delete]
|
||||
func (h *TokenHandler) DeleteTokenHandler(c *echo.Context) error {
|
||||
authHeader := c.Request().Header.Get("Authorization")
|
||||
if authHeader == "" {
|
||||
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "Authorization header required"})
|
||||
}
|
||||
|
||||
parts := strings.Split(authHeader, " ")
|
||||
if len(parts) != 2 || parts[0] != "Bearer" {
|
||||
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "Invalid authorization format"})
|
||||
}
|
||||
|
||||
token := parts[1]
|
||||
|
||||
// Delete session using sqlc-generated repository
|
||||
queries := repository.New(h.pool)
|
||||
err := queries.DeleteSession(c.Request().Context(), token)
|
||||
if err != nil {
|
||||
logging.GetLogger().Error("Failed to delete session", zap.String("error", err.Error()))
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to invalidate token"})
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, map[string]string{"status": "token invalidated"})
|
||||
}
|
||||
|
||||
// CleanupExpiredSessionsHandler removes all expired sessions
|
||||
// POST /api/v1/token/cleanup
|
||||
//
|
||||
// @Summary Cleanup expired sessions
|
||||
// @Description Removes all expired session tokens from the database
|
||||
// @Tags auth
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param Authorization header string true "Bearer token"
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Failure 401 {object} map[string]string
|
||||
// @Failure 500 {object} map[string]string
|
||||
// @Router /api/v1/token/cleanup [post]
|
||||
func (h *TokenHandler) CleanupExpiredSessionsHandler(c *echo.Context) error {
|
||||
// Verify token is valid first (using existing middleware)
|
||||
// The middleware will have already validated the token
|
||||
|
||||
queries := repository.New(h.pool)
|
||||
err := queries.DeleteExpiredSessions(c.Request().Context())
|
||||
if err != nil {
|
||||
logging.GetLogger().Error("Failed to cleanup sessions", zap.String("error", err.Error()))
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to cleanup sessions"})
|
||||
}
|
||||
|
||||
// Get count of deleted rows (DeleteExpiredSessions doesn't return count in the generated code)
|
||||
// So we just return success
|
||||
return c.JSON(http.StatusOK, map[string]interface{}{
|
||||
"status": "cleanup complete",
|
||||
})
|
||||
}
|
||||
@@ -15,8 +15,8 @@ import (
|
||||
|
||||
// ensureSyncRan ensures that sync has been run before testing music endpoints
|
||||
func ensureSyncRan(t *testing.T, e *echo.Echo) {
|
||||
repo := repository.New(db.Dbpool)
|
||||
games, err := repo.FindAllGames(db.Ctx)
|
||||
repo := repository.New(backend.BackendPool())
|
||||
games, err := repo.FindAllGames(backend.BackendCtx())
|
||||
assert.NoError(t, err)
|
||||
|
||||
if len(games) == 0 {
|
||||
|
||||
Reference in New Issue
Block a user