diff --git a/Dockerfile b/Dockerfile index 8b6ca7c..c041846 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/cmd/docs/docs.go b/cmd/docs/docs.go index db1d0a5..848b952 100644 --- a/cmd/docs/docs.go +++ b/cmd/docs/docs.go @@ -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" + } + } } } }` diff --git a/cmd/docs/swagger.json b/cmd/docs/swagger.json index ce18ae6..cde977f 100644 --- a/cmd/docs/swagger.json +++ b/cmd/docs/swagger.json @@ -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" + } + } } } } \ No newline at end of file diff --git a/cmd/docs/swagger.yaml b/cmd/docs/swagger.yaml index e537b4f..242592d 100644 --- a/cmd/docs/swagger.yaml +++ b/cmd/docs/swagger.yaml @@ -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" diff --git a/internal/backend/sync.go b/internal/backend/sync.go index b4522d8..189668a 100644 --- a/internal/backend/sync.go +++ b/internal/backend/sync.go @@ -186,8 +186,6 @@ func SyncSoundtracksNewOnlyChanges() { } 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)) @@ -199,7 +197,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 @@ -224,7 +222,6 @@ func syncGamesNew(full bool) { 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() @@ -322,7 +319,7 @@ func syncGameNew(file os.DirEntry, foldersToSkip []string, baseDir string, full } } - if full { + if full && status != NewGame { status = TitleChanged } entries, err := os.ReadDir(gameDir) diff --git a/internal/db/repository/models.go b/internal/db/repository/models.go index 82728e5..cd231c7 100644 --- a/internal/db/repository/models.go +++ b/internal/db/repository/models.go @@ -27,6 +27,15 @@ type Session struct { CreatedAt pgtype.Timestamptz `json:"created_at"` } +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 { SoundtrackID int32 `json:"soundtrack_id"` SongName string `json:"song_name"` diff --git a/internal/server/characterHandler.go b/internal/server/characterHandler.go index 941ffc3..3f3822d 100644 --- a/internal/server/characterHandler.go +++ b/internal/server/characterHandler.go @@ -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) } diff --git a/internal/server/routes.go b/internal/server/routes.go index 9c18adc..4309342 100644 --- a/internal/server/routes.go +++ b/internal/server/routes.go @@ -164,6 +164,33 @@ func (s *Server) RegisterRoutes() http.Handler { // Future: VGMQ endpoints will be added to protectedV1 group _ = protectedV1 // Use the variable to avoid unused variable error + // ============================================ + // 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 diff --git a/internal/server/syncHandler.go b/internal/server/syncHandler.go index 88f1d3f..4ee7ce4 100644 --- a/internal/server/syncHandler.go +++ b/internal/server/syncHandler.go @@ -49,6 +49,7 @@ func (s *SyncHandler) SyncSoundtracksNewOnlyChanges(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.SyncSoundtracksNewOnlyChanges() return ctx.JSON(http.StatusOK, "Start syncing soundtracks") } @@ -68,6 +69,7 @@ func (s *SyncHandler) SyncSoundtracksNewFull(ctx *echo.Context) error { return ctx.JSON(http.StatusLocked, "Syncing is in progress") } logging.GetLogger().Info("Starting full sync") + backend.Syncing = true go backend.SyncSoundtracksNewFull() return ctx.JSON(http.StatusOK, "Start syncing soundtracks full") }