auth-uploads #5
11 changed files with 596 additions and 606 deletions
32
.env.example
32
.env.example
|
@ -1,16 +1,16 @@
|
|||
IMAGE_PATH=/tmp
|
||||
REDIRECT_URI=http://localhost:8081/api/auth/callback
|
||||
CLIENT_ID=
|
||||
CLIENT_SECRET=
|
||||
|
||||
# can be either postgres or sqlite
|
||||
DATABASE_TYPE=
|
||||
|
||||
# database file, stereo.db by default.
|
||||
SQLITE_FILE=
|
||||
|
||||
# postgres DSN, look at https://gorm.io/docs/connecting_to_the_database.html#PostgreSQL
|
||||
POSTGRES_DSN=
|
||||
|
||||
# Random secret. Recommended length is 64 characters at minimum.
|
||||
JWT_SECRET=
|
||||
IMAGE_PATH=/tmp
|
||||
REDIRECT_URI=http://localhost:8081/api/auth/callback
|
||||
CLIENT_ID=
|
||||
CLIENT_SECRET=
|
||||
|
||||
# can be either postgres or sqlite
|
||||
DATABASE_TYPE=
|
||||
|
||||
# database file, stereo.db by default.
|
||||
SQLITE_FILE=
|
||||
|
||||
# postgres DSN, look at https://gorm.io/docs/connecting_to_the_database.html#PostgreSQL
|
||||
POSTGRES_DSN=
|
||||
|
||||
# Random secret. Recommended length is 64 characters at minimum.
|
||||
JWT_SECRET=
|
||||
|
|
6
.gitignore
vendored
6
.gitignore
vendored
|
@ -1,4 +1,4 @@
|
|||
.env
|
||||
tmp
|
||||
*.db
|
||||
.env
|
||||
tmp
|
||||
*.db
|
||||
imgs
|
16
README.md
16
README.md
|
@ -1,8 +1,8 @@
|
|||
# stereo.cat backend
|
||||
|
||||
written in Go, uses Gin.
|
||||
|
||||
## database shit
|
||||
|
||||
Instead of using Discord oAuth as a database, we instead use it as a login source, only using it to source a username/id, avatar data and a secure login/registration flow.
|
||||
We store these attributes alongside stereo.cat specific attributes in our own database. There is a trade-off however: this means that avatar & username data is not updated in real-time, only when the oauth flow is executed.
|
||||
# stereo.cat backend
|
||||
|
||||
written in Go, uses Gin.
|
||||
|
||||
## database shit
|
||||
|
||||
Instead of using Discord oAuth as a database, we instead use it as a login source, only using it to source a username/id, avatar data and a secure login/registration flow.
|
||||
We store these attributes alongside stereo.cat specific attributes in our own database. There is a trade-off however: this means that avatar & username data is not updated in real-time, only when the oauth flow is executed.
|
||||
|
|
|
@ -1,12 +1,12 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"stereo.cat/backend/internal/api/routes"
|
||||
"stereo.cat/backend/internal/types"
|
||||
)
|
||||
|
||||
func Register(cfg *types.StereoConfig) {
|
||||
api := cfg.Router.Group("/api")
|
||||
routes.RegisterFileRoutes(cfg, api)
|
||||
routes.RegisterAuthRoutes(cfg, api)
|
||||
}
|
||||
package api
|
||||
|
||||
import (
|
||||
"stereo.cat/backend/internal/api/routes"
|
||||
"stereo.cat/backend/internal/types"
|
||||
)
|
||||
|
||||
func Register(cfg *types.StereoConfig) {
|
||||
api := cfg.Router.Group("/api")
|
||||
routes.RegisterFileRoutes(cfg, api)
|
||||
routes.RegisterAuthRoutes(cfg, api)
|
||||
}
|
||||
|
|
|
@ -1,51 +1,51 @@
|
|||
package routes
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"stereo.cat/backend/internal/auth"
|
||||
"stereo.cat/backend/internal/types"
|
||||
)
|
||||
|
||||
func RegisterAuthRoutes(cfg *types.StereoConfig, api *gin.RouterGroup) {
|
||||
api.GET("/auth/callback", func(c *gin.Context) {
|
||||
code := c.Query("code")
|
||||
|
||||
t, err := cfg.Client.ExchangeCode(code)
|
||||
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
user, err := cfg.Client.GetUser(t)
|
||||
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
jwt, err := auth.GenerateJWT(cfg.JWTSecret, user, uint64(time.Now().Add(time.Second*time.Duration(t.ExpiresIn)).Unix()))
|
||||
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
res := cfg.Database.FirstOrCreate(&user)
|
||||
|
||||
if res.Error != nil {
|
||||
panic(res.Error)
|
||||
}
|
||||
|
||||
// TODO: redirect to dashboard
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"jwt": jwt,
|
||||
"known": res.RowsAffected == 0,
|
||||
})
|
||||
})
|
||||
|
||||
api.GET("/auth/me", auth.JwtMiddleware(cfg.JWTSecret), func(c *gin.Context) {
|
||||
claims, _ := c.Get("claims")
|
||||
c.JSON(http.StatusOK, claims)
|
||||
})
|
||||
}
|
||||
package routes
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"stereo.cat/backend/internal/auth"
|
||||
"stereo.cat/backend/internal/types"
|
||||
)
|
||||
|
||||
func RegisterAuthRoutes(cfg *types.StereoConfig, api *gin.RouterGroup) {
|
||||
api.GET("/auth/callback", func(c *gin.Context) {
|
||||
code := c.Query("code")
|
||||
|
||||
t, err := cfg.Client.ExchangeCode(code)
|
||||
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
user, err := cfg.Client.GetUser(t)
|
||||
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
jwt, err := auth.GenerateJWT(cfg.JWTSecret, user, uint64(time.Now().Add(time.Second*time.Duration(t.ExpiresIn)).Unix()))
|
||||
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
res := cfg.Database.FirstOrCreate(&user)
|
||||
|
||||
if res.Error != nil {
|
||||
panic(res.Error)
|
||||
}
|
||||
|
||||
// TODO: redirect to dashboard
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"jwt": jwt,
|
||||
"known": res.RowsAffected == 0,
|
||||
})
|
||||
})
|
||||
|
||||
api.GET("/auth/me", auth.JwtMiddleware(cfg.JWTSecret), func(c *gin.Context) {
|
||||
claims, _ := c.Get("claims")
|
||||
c.JSON(http.StatusOK, claims)
|
||||
})
|
||||
}
|
||||
|
|
|
@ -1,154 +1,144 @@
|
|||
package routes
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"stereo.cat/backend/internal/auth"
|
||||
"stereo.cat/backend/internal/types"
|
||||
)
|
||||
|
||||
func RegisterFileRoutes(cfg *types.StereoConfig, api *gin.RouterGroup) {
|
||||
api.POST("/upload", auth.JwtMiddleware(cfg.JWTSecret), func(c *gin.Context) {
|
||||
claims := c.MustGet("claims").(jwt.MapClaims)
|
||||
user := claims["user"].(auth.User)
|
||||
|
||||
uid := user.ID
|
||||
if uid == "" {
|
||||
c.JSON(401, gin.H{"error": "unauthorized"})
|
||||
return
|
||||
}
|
||||
|
||||
file, err := c.FormFile("file")
|
||||
if err != nil {
|
||||
c.JSON(400, gin.H{"error": "file is required"})
|
||||
return
|
||||
}
|
||||
|
||||
filePath := filepath.Join(cfg.ImagePath, uid, file.Filename)
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(filePath), os.ModePerm); err != nil {
|
||||
c.JSON(500, gin.H{"error": "failed to create directory"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := c.SaveUploadedFile(file, filePath); err != nil {
|
||||
c.JSON(500, gin.H{"error": "failed to save file"})
|
||||
return
|
||||
}
|
||||
|
||||
b64, err := convertToBase64(filePath)
|
||||
if err != nil {
|
||||
}
|
||||
|
||||
fileMeta := types.File{
|
||||
ID: uid + "_" + file.Filename,
|
||||
Path: filePath,
|
||||
Owner: uid,
|
||||
CreatedAt: time.Now(),
|
||||
Base64: b64,
|
||||
}
|
||||
|
||||
if err := cfg.Database.Create(&fileMeta).Error; err != nil {
|
||||
c.JSON(500, gin.H{"error": "failed to save file metadata"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(200, gin.H{"message": "file uploaded successfully", "file_id": fileMeta.ID})
|
||||
})
|
||||
|
||||
api.DELETE("/delete", auth.JwtMiddleware(cfg.JWTSecret), func(c *gin.Context) {
|
||||
claims := c.MustGet("claims").(jwt.MapClaims)
|
||||
user := claims["user"].(auth.User)
|
||||
|
||||
uid := user.ID
|
||||
if uid == "" {
|
||||
c.JSON(401, gin.H{"error": "unauthorized"})
|
||||
return
|
||||
}
|
||||
|
||||
var response struct {
|
||||
FileID string `json:"file_id" binding:"required"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&response); err != nil {
|
||||
c.JSON(400, gin.H{"error": "file_id is required"})
|
||||
return
|
||||
}
|
||||
|
||||
resfID := response.FileID
|
||||
if resfID == "" {
|
||||
c.JSON(400, gin.H{"error": "file_id cannot be empty"})
|
||||
return
|
||||
}
|
||||
|
||||
parts := strings.SplitN(resfID, "_", 2)
|
||||
if len(parts) != 2 {
|
||||
c.JSON(400, gin.H{"error": "invalid file_id format"})
|
||||
return
|
||||
}
|
||||
|
||||
fileID, filename := parts[0], parts[1]
|
||||
if fileID != uid {
|
||||
c.JSON(403, gin.H{"error": "you can only delete your own files"})
|
||||
return
|
||||
}
|
||||
|
||||
filePath := filepath.Join(cfg.ImagePath, uid, filename)
|
||||
if err := os.Remove(filePath); err != nil {
|
||||
c.JSON(500, gin.H{"error": "failed to delete file"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := cfg.Database.Where("id = ?", resfID).Delete(&types.File{}).Error; err != nil {
|
||||
c.JSON(500, gin.H{"error": "failed to delete file metadata"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(200, gin.H{"message": "file deleted successfully"})
|
||||
})
|
||||
|
||||
api.GET("/:name", func(c *gin.Context) {
|
||||
name := c.Param("name")
|
||||
parts := strings.SplitN(name, "_", 2)
|
||||
if len(parts) != 2 {
|
||||
c.JSON(400, gin.H{"error": "invalid file name"})
|
||||
return
|
||||
}
|
||||
uid, filename := parts[0], parts[1]
|
||||
path := filepath.Join(cfg.ImagePath, uid, filename)
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
c.JSON(404, gin.H{"error": "file not found"})
|
||||
return
|
||||
}
|
||||
c.File(path)
|
||||
})
|
||||
|
||||
api.GET("/list", auth.JwtMiddleware(cfg.JWTSecret), func(c *gin.Context) {
|
||||
claims := c.MustGet("claims").(jwt.MapClaims)
|
||||
user := claims["user"].(auth.User)
|
||||
|
||||
var files []types.File
|
||||
if err := cfg.Database.Where("owner = ?", user.ID).Find(&files).Error; err != nil {
|
||||
c.JSON(500, gin.H{"error": "failed to retrieve files"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(200, files)
|
||||
})
|
||||
}
|
||||
|
||||
func convertToBase64(filePath string) (string, error) {
|
||||
file, err := os.ReadFile(filePath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
b64 := base64.StdEncoding.EncodeToString(file)
|
||||
return b64, nil
|
||||
}
|
||||
package routes
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"stereo.cat/backend/internal/auth"
|
||||
"stereo.cat/backend/internal/types"
|
||||
)
|
||||
|
||||
func RegisterFileRoutes(cfg *types.StereoConfig, api *gin.RouterGroup) {
|
||||
api.POST("/upload", auth.JwtMiddleware(cfg.JWTSecret), func(c *gin.Context) {
|
||||
claims := c.MustGet("claims").(jwt.MapClaims)
|
||||
user := claims["user"].(auth.User)
|
||||
|
||||
uid := user.ID
|
||||
if uid == "" {
|
||||
c.JSON(401, gin.H{"error": "unauthorized"})
|
||||
return
|
||||
}
|
||||
|
||||
file, err := c.FormFile("file")
|
||||
if err != nil {
|
||||
c.JSON(400, gin.H{"error": "file is required"})
|
||||
return
|
||||
}
|
||||
|
||||
filePath := filepath.Join(cfg.ImagePath, uid, file.Filename)
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(filePath), os.ModePerm); err != nil {
|
||||
c.JSON(500, gin.H{"error": "failed to create directory"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := c.SaveUploadedFile(file, filePath); err != nil {
|
||||
c.JSON(500, gin.H{"error": "failed to save file"})
|
||||
return
|
||||
}
|
||||
|
||||
if file.Size <= 0 {
|
||||
c.JSON(400, gin.H{"error": "file size must be greater than zero"})
|
||||
return
|
||||
}
|
||||
|
||||
fileMeta := types.File{
|
||||
ID: uid + "_" + file.Filename,
|
||||
Path: filePath,
|
||||
Owner: uid,
|
||||
CreatedAt: time.Now(),
|
||||
Size: file.Size,
|
||||
}
|
||||
|
||||
if err := cfg.Database.Create(&fileMeta).Error; err != nil {
|
||||
c.JSON(500, gin.H{"error": "failed to save file metadata"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(200, gin.H{"message": "file uploaded successfully", "file_id": fileMeta.ID})
|
||||
})
|
||||
|
||||
api.DELETE("/delete", auth.JwtMiddleware(cfg.JWTSecret), func(c *gin.Context) {
|
||||
claims := c.MustGet("claims").(jwt.MapClaims)
|
||||
user := claims["user"].(auth.User)
|
||||
|
||||
uid := user.ID
|
||||
if uid == "" {
|
||||
c.JSON(401, gin.H{"error": "unauthorized"})
|
||||
return
|
||||
}
|
||||
|
||||
var response struct {
|
||||
FileID string `json:"file_id" binding:"required"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&response); err != nil {
|
||||
c.JSON(400, gin.H{"error": "file_id is required"})
|
||||
return
|
||||
}
|
||||
|
||||
resfID := response.FileID
|
||||
if resfID == "" {
|
||||
c.JSON(400, gin.H{"error": "file_id cannot be empty"})
|
||||
return
|
||||
}
|
||||
|
||||
parts := strings.SplitN(resfID, "_", 2)
|
||||
if len(parts) != 2 {
|
||||
c.JSON(400, gin.H{"error": "invalid file_id format"})
|
||||
return
|
||||
}
|
||||
|
||||
fileID, filename := parts[0], parts[1]
|
||||
if fileID != uid {
|
||||
c.JSON(403, gin.H{"error": "you can only delete your own files"})
|
||||
return
|
||||
}
|
||||
|
||||
filePath := filepath.Join(cfg.ImagePath, uid, filename)
|
||||
if err := os.Remove(filePath); err != nil {
|
||||
c.JSON(500, gin.H{"error": "failed to delete file"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := cfg.Database.Where("id = ?", resfID).Delete(&types.File{}).Error; err != nil {
|
||||
c.JSON(500, gin.H{"error": "failed to delete file metadata"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(200, gin.H{"message": "file deleted successfully"})
|
||||
})
|
||||
|
||||
api.GET("/:name", func(c *gin.Context) {
|
||||
name := c.Param("name")
|
||||
parts := strings.SplitN(name, "_", 2)
|
||||
if len(parts) != 2 {
|
||||
c.JSON(400, gin.H{"error": "invalid file name"})
|
||||
return
|
||||
}
|
||||
uid, filename := parts[0], parts[1]
|
||||
path := filepath.Join(cfg.ImagePath, uid, filename)
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
c.JSON(404, gin.H{"error": "file not found"})
|
||||
return
|
||||
}
|
||||
c.File(path)
|
||||
})
|
||||
|
||||
api.GET("/list", auth.JwtMiddleware(cfg.JWTSecret), func(c *gin.Context) {
|
||||
claims := c.MustGet("claims").(jwt.MapClaims)
|
||||
user := claims["user"].(auth.User)
|
||||
|
||||
var files []types.File
|
||||
if err := cfg.Database.Where("owner = ?", user.ID).Find(&files).Error; err != nil {
|
||||
c.JSON(500, gin.H{"error": "failed to retrieve files"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(200, files)
|
||||
})
|
||||
}
|
||||
|
|
|
@ -1,117 +1,117 @@
|
|||
package client
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"stereo.cat/backend/internal/auth"
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
RedirectUri string
|
||||
ClientSecret string
|
||||
ClientId string
|
||||
}
|
||||
|
||||
const api = "https://discord.com/api/v10"
|
||||
|
||||
func New(redirectUri, clientId, clientSecret string) Client {
|
||||
return Client{
|
||||
RedirectUri: redirectUri,
|
||||
ClientId: clientId,
|
||||
ClientSecret: clientSecret,
|
||||
}
|
||||
}
|
||||
|
||||
func (c Client) GetUser(t auth.TokenResponse) (auth.User, error) {
|
||||
user := auth.User{
|
||||
Blacklisted: false,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("%s/%s", api, "users/@me"), nil)
|
||||
|
||||
if err != nil {
|
||||
return user, nil
|
||||
}
|
||||
|
||||
req.Header.Add("Content-Type", "application/json")
|
||||
req.Header.Add("Authorization", "Bearer "+t.AccessToken)
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
bodyBytes, err := io.ReadAll(resp.Body)
|
||||
|
||||
if err != nil {
|
||||
return user, err
|
||||
}
|
||||
|
||||
return user, errors.New(string(bodyBytes))
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return user, err
|
||||
}
|
||||
|
||||
defer resp.Body.Close()
|
||||
|
||||
err = json.NewDecoder(resp.Body).Decode(&user)
|
||||
|
||||
if err != nil {
|
||||
return user, err
|
||||
}
|
||||
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func (c Client) ExchangeCode(code string) (auth.TokenResponse, error) {
|
||||
var tokenResponse auth.TokenResponse
|
||||
|
||||
requestBody := url.Values{
|
||||
"grant_type": {"authorization_code"},
|
||||
"code": {code},
|
||||
"redirect_uri": {c.RedirectUri},
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(http.MethodPost, fmt.Sprintf("%s/%s", api, "/oauth2/token"), strings.NewReader(requestBody.Encode()))
|
||||
|
||||
if err != nil {
|
||||
return tokenResponse, err
|
||||
}
|
||||
|
||||
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.SetBasicAuth(c.ClientId, c.ClientSecret)
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
bodyBytes, err := io.ReadAll(resp.Body)
|
||||
|
||||
if err != nil {
|
||||
return tokenResponse, err
|
||||
}
|
||||
|
||||
return tokenResponse, errors.New(string(bodyBytes))
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return tokenResponse, err
|
||||
}
|
||||
|
||||
defer resp.Body.Close()
|
||||
|
||||
err = json.NewDecoder(resp.Body).Decode(&tokenResponse)
|
||||
|
||||
if err != nil {
|
||||
return tokenResponse, err
|
||||
}
|
||||
|
||||
return tokenResponse, nil
|
||||
}
|
||||
package client
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"stereo.cat/backend/internal/auth"
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
RedirectUri string
|
||||
ClientSecret string
|
||||
ClientId string
|
||||
}
|
||||
|
||||
const api = "https://discord.com/api/v10"
|
||||
|
||||
func New(redirectUri, clientId, clientSecret string) Client {
|
||||
return Client{
|
||||
RedirectUri: redirectUri,
|
||||
ClientId: clientId,
|
||||
ClientSecret: clientSecret,
|
||||
}
|
||||
}
|
||||
|
||||
func (c Client) GetUser(t auth.TokenResponse) (auth.User, error) {
|
||||
user := auth.User{
|
||||
Blacklisted: false,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("%s/%s", api, "users/@me"), nil)
|
||||
|
||||
if err != nil {
|
||||
return user, nil
|
||||
}
|
||||
|
||||
req.Header.Add("Content-Type", "application/json")
|
||||
req.Header.Add("Authorization", "Bearer "+t.AccessToken)
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
bodyBytes, err := io.ReadAll(resp.Body)
|
||||
|
||||
if err != nil {
|
||||
return user, err
|
||||
}
|
||||
|
||||
return user, errors.New(string(bodyBytes))
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return user, err
|
||||
}
|
||||
|
||||
defer resp.Body.Close()
|
||||
|
||||
err = json.NewDecoder(resp.Body).Decode(&user)
|
||||
|
||||
if err != nil {
|
||||
return user, err
|
||||
}
|
||||
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func (c Client) ExchangeCode(code string) (auth.TokenResponse, error) {
|
||||
var tokenResponse auth.TokenResponse
|
||||
|
||||
requestBody := url.Values{
|
||||
"grant_type": {"authorization_code"},
|
||||
"code": {code},
|
||||
"redirect_uri": {c.RedirectUri},
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(http.MethodPost, fmt.Sprintf("%s/%s", api, "/oauth2/token"), strings.NewReader(requestBody.Encode()))
|
||||
|
||||
if err != nil {
|
||||
return tokenResponse, err
|
||||
}
|
||||
|
||||
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.SetBasicAuth(c.ClientId, c.ClientSecret)
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
bodyBytes, err := io.ReadAll(resp.Body)
|
||||
|
||||
if err != nil {
|
||||
return tokenResponse, err
|
||||
}
|
||||
|
||||
return tokenResponse, errors.New(string(bodyBytes))
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return tokenResponse, err
|
||||
}
|
||||
|
||||
defer resp.Body.Close()
|
||||
|
||||
err = json.NewDecoder(resp.Body).Decode(&tokenResponse)
|
||||
|
||||
if err != nil {
|
||||
return tokenResponse, err
|
||||
}
|
||||
|
||||
return tokenResponse, nil
|
||||
}
|
||||
|
|
|
@ -1,83 +1,83 @@
|
|||
package auth
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
func GenerateJWT(key string, user User, expiryTimestamp uint64) (string, error) {
|
||||
claims := Claims{
|
||||
User: user,
|
||||
Exp: expiryTimestamp,
|
||||
}
|
||||
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
return token.SignedString([]byte(key))
|
||||
}
|
||||
|
||||
func invalidAuth(c *gin.Context) {
|
||||
c.String(http.StatusUnauthorized, "Unauthorized.")
|
||||
c.Abort()
|
||||
}
|
||||
|
||||
func JwtMiddleware(secret string) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
jwtSplit := strings.Split(c.GetHeader("Authorization"), " ")
|
||||
|
||||
if len(jwtSplit) < 2 || jwtSplit[0] != "Bearer" {
|
||||
invalidAuth(c)
|
||||
return
|
||||
}
|
||||
|
||||
claims, err := ValidateJWT(jwtSplit[1], secret)
|
||||
if err != nil {
|
||||
invalidAuth(c)
|
||||
return
|
||||
}
|
||||
|
||||
if userClaims, ok := claims["user"].(map[string]interface{}); ok {
|
||||
userJSON, err := json.Marshal(userClaims) // Convert map to JSON
|
||||
if err != nil {
|
||||
invalidAuth(c)
|
||||
return
|
||||
}
|
||||
|
||||
var user User
|
||||
err = json.Unmarshal(userJSON, &user)
|
||||
if err != nil {
|
||||
invalidAuth(c)
|
||||
return
|
||||
}
|
||||
|
||||
claims["user"] = user
|
||||
}
|
||||
|
||||
c.Set("claims", claims)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func ValidateJWT(jwtString, key string) (jwt.MapClaims, error) {
|
||||
token, err := jwt.Parse(jwtString, func(token *jwt.Token) (any, error) {
|
||||
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||
return nil, fmt.Errorf("Invalid signing method!")
|
||||
}
|
||||
|
||||
return []byte(key), nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if claims, ok := token.Claims.(jwt.MapClaims); ok && token.Valid {
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("Invalid token!")
|
||||
}
|
||||
package auth
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
func GenerateJWT(key string, user User, expiryTimestamp uint64) (string, error) {
|
||||
claims := Claims{
|
||||
User: user,
|
||||
Exp: expiryTimestamp,
|
||||
}
|
||||
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
return token.SignedString([]byte(key))
|
||||
}
|
||||
|
||||
func invalidAuth(c *gin.Context) {
|
||||
c.String(http.StatusUnauthorized, "Unauthorized.")
|
||||
c.Abort()
|
||||
}
|
||||
|
||||
func JwtMiddleware(secret string) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
jwtSplit := strings.Split(c.GetHeader("Authorization"), " ")
|
||||
|
||||
if len(jwtSplit) < 2 || jwtSplit[0] != "Bearer" {
|
||||
invalidAuth(c)
|
||||
return
|
||||
}
|
||||
|
||||
claims, err := ValidateJWT(jwtSplit[1], secret)
|
||||
if err != nil {
|
||||
invalidAuth(c)
|
||||
return
|
||||
}
|
||||
|
||||
if userClaims, ok := claims["user"].(map[string]interface{}); ok {
|
||||
userJSON, err := json.Marshal(userClaims) // Convert map to JSON
|
||||
if err != nil {
|
||||
invalidAuth(c)
|
||||
return
|
||||
}
|
||||
|
||||
var user User
|
||||
err = json.Unmarshal(userJSON, &user)
|
||||
if err != nil {
|
||||
invalidAuth(c)
|
||||
return
|
||||
}
|
||||
|
||||
claims["user"] = user
|
||||
}
|
||||
|
||||
c.Set("claims", claims)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func ValidateJWT(jwtString, key string) (jwt.MapClaims, error) {
|
||||
token, err := jwt.Parse(jwtString, func(token *jwt.Token) (any, error) {
|
||||
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||
return nil, fmt.Errorf("Invalid signing method!")
|
||||
}
|
||||
|
||||
return []byte(key), nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if claims, ok := token.Claims.(jwt.MapClaims); ok && token.Valid {
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("Invalid token!")
|
||||
}
|
||||
|
|
|
@ -1,40 +1,40 @@
|
|||
package auth
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
type TokenResponse struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
TokenType string `json:"token_type"`
|
||||
ExpiresIn int64 `json:"expires_in"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
Scope string `json:"scope"`
|
||||
}
|
||||
|
||||
type User struct {
|
||||
ID string `json:"id" gorm:"primaryKey"`
|
||||
Username string `json:"username"`
|
||||
Blacklisted bool `json:"blacklisted"`
|
||||
Email string `json:"email"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
type AvatarDecorationData struct {
|
||||
Asset string
|
||||
SkuID string
|
||||
}
|
||||
|
||||
type ExchangeCodeRequest struct {
|
||||
GrantType string `json:"grant_type"`
|
||||
Code string `json:"code"`
|
||||
RedirectUri string `json:"redirect_uri"`
|
||||
}
|
||||
|
||||
type Claims struct {
|
||||
User User `json:"user"`
|
||||
Exp uint64 `json:"exp"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
package auth
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
type TokenResponse struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
TokenType string `json:"token_type"`
|
||||
ExpiresIn int64 `json:"expires_in"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
Scope string `json:"scope"`
|
||||
}
|
||||
|
||||
type User struct {
|
||||
ID string `json:"id" gorm:"primaryKey"`
|
||||
Username string `json:"username"`
|
||||
Blacklisted bool `json:"blacklisted"`
|
||||
Email string `json:"email"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
type AvatarDecorationData struct {
|
||||
Asset string
|
||||
SkuID string
|
||||
}
|
||||
|
||||
type ExchangeCodeRequest struct {
|
||||
GrantType string `json:"grant_type"`
|
||||
Code string `json:"code"`
|
||||
RedirectUri string `json:"redirect_uri"`
|
||||
}
|
||||
|
||||
type Claims struct {
|
||||
User User `json:"user"`
|
||||
Exp uint64 `json:"exp"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
|
|
@ -1,31 +1,31 @@
|
|||
package types
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
"stereo.cat/backend/internal/auth/client"
|
||||
)
|
||||
|
||||
type Route struct {
|
||||
Path string
|
||||
Method string
|
||||
Exec func(cfg *StereoConfig) gin.HandlerFunc
|
||||
}
|
||||
|
||||
type StereoConfig struct {
|
||||
ImagePath string
|
||||
Router *gin.Engine
|
||||
Client client.Client
|
||||
Database *gorm.DB
|
||||
JWTSecret string
|
||||
}
|
||||
|
||||
type File struct {
|
||||
ID string `gorm:"primaryKey"`
|
||||
Path string `gorm:"not null;index"`
|
||||
Owner string `gorm:"not null;index"`
|
||||
CreatedAt time.Time `gorm:"autoCreateTime"`
|
||||
Base64 string `gorm:"type:text"`
|
||||
}
|
||||
package types
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
"stereo.cat/backend/internal/auth/client"
|
||||
)
|
||||
|
||||
type Route struct {
|
||||
Path string
|
||||
Method string
|
||||
Exec func(cfg *StereoConfig) gin.HandlerFunc
|
||||
}
|
||||
|
||||
type StereoConfig struct {
|
||||
ImagePath string
|
||||
Router *gin.Engine
|
||||
Client client.Client
|
||||
Database *gorm.DB
|
||||
JWTSecret string
|
||||
}
|
||||
|
||||
type File struct {
|
||||
ID string `gorm:"primaryKey"`
|
||||
Path string `gorm:"not null;index"`
|
||||
Owner string `gorm:"not null;index"`
|
||||
Size int64 `gorm:"not null;type:bigint"`
|
||||
CreatedAt time.Time `gorm:"autoCreateTime"`
|
||||
}
|
||||
|
|
182
main.go
182
main.go
|
@ -1,91 +1,91 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/joho/godotenv"
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
"stereo.cat/backend/internal/api"
|
||||
"stereo.cat/backend/internal/auth"
|
||||
"stereo.cat/backend/internal/auth/client"
|
||||
"stereo.cat/backend/internal/types"
|
||||
)
|
||||
|
||||
func getEnv(key, fallback string) string {
|
||||
if value, ok := os.LookupEnv(key); ok {
|
||||
return value
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func requireEnv(key string) string {
|
||||
if value, ok := os.LookupEnv(key); ok {
|
||||
return value
|
||||
}
|
||||
panic(errors.New(fmt.Sprintf("Environment variable %s is required but not specified. Exiting...", key)))
|
||||
}
|
||||
|
||||
func main() {
|
||||
_ = godotenv.Load()
|
||||
|
||||
databaseType := getEnv("DATABASE_TYPE", "sqlite")
|
||||
sqliteFile := getEnv("SQLITE_FILE", "stereo.db")
|
||||
imagePath := getEnv("IMAGE_PATH", os.TempDir())
|
||||
|
||||
if _, err := os.Stat(imagePath); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
if err := os.MkdirAll(imagePath, os.ModePerm); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
c := types.StereoConfig{
|
||||
Router: gin.Default(),
|
||||
ImagePath: imagePath,
|
||||
Client: client.New(
|
||||
requireEnv("REDIRECT_URI"),
|
||||
requireEnv("CLIENT_ID"),
|
||||
requireEnv("CLIENT_SECRET"),
|
||||
),
|
||||
JWTSecret: requireEnv("JWT_SECRET"),
|
||||
}
|
||||
|
||||
switch databaseType {
|
||||
case "sqlite":
|
||||
db, err := gorm.Open(sqlite.Open(sqliteFile), &gorm.Config{})
|
||||
|
||||
c.Database = db
|
||||
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
break
|
||||
|
||||
case "postgres":
|
||||
db, err := gorm.Open(postgres.Open(requireEnv("POSTGRES_DSN")), &gorm.Config{})
|
||||
|
||||
c.Database = db
|
||||
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
break
|
||||
default:
|
||||
panic(errors.New("Invalid database type was specified."))
|
||||
}
|
||||
|
||||
c.Database.AutoMigrate(&auth.User{}, &types.File{})
|
||||
|
||||
api.Register(&c)
|
||||
fmt.Printf("Running on port %s\n", getEnv("PORT", "8080"))
|
||||
c.Router.Run()
|
||||
}
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/joho/godotenv"
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
"stereo.cat/backend/internal/api"
|
||||
"stereo.cat/backend/internal/auth"
|
||||
"stereo.cat/backend/internal/auth/client"
|
||||
"stereo.cat/backend/internal/types"
|
||||
)
|
||||
|
||||
func getEnv(key, fallback string) string {
|
||||
if value, ok := os.LookupEnv(key); ok {
|
||||
return value
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func requireEnv(key string) string {
|
||||
if value, ok := os.LookupEnv(key); ok {
|
||||
return value
|
||||
}
|
||||
panic(errors.New(fmt.Sprintf("Environment variable %s is required but not specified. Exiting...", key)))
|
||||
}
|
||||
|
||||
func main() {
|
||||
_ = godotenv.Load()
|
||||
|
||||
databaseType := getEnv("DATABASE_TYPE", "sqlite")
|
||||
sqliteFile := getEnv("SQLITE_FILE", "stereo.db")
|
||||
imagePath := getEnv("IMAGE_PATH", os.TempDir())
|
||||
|
||||
if _, err := os.Stat(imagePath); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
if err := os.MkdirAll(imagePath, os.ModePerm); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
c := types.StereoConfig{
|
||||
Router: gin.Default(),
|
||||
ImagePath: imagePath,
|
||||
Client: client.New(
|
||||
requireEnv("REDIRECT_URI"),
|
||||
requireEnv("CLIENT_ID"),
|
||||
requireEnv("CLIENT_SECRET"),
|
||||
),
|
||||
JWTSecret: requireEnv("JWT_SECRET"),
|
||||
}
|
||||
|
||||
switch databaseType {
|
||||
case "sqlite":
|
||||
db, err := gorm.Open(sqlite.Open(sqliteFile), &gorm.Config{})
|
||||
|
||||
c.Database = db
|
||||
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
break
|
||||
|
||||
case "postgres":
|
||||
db, err := gorm.Open(postgres.Open(requireEnv("POSTGRES_DSN")), &gorm.Config{})
|
||||
|
||||
c.Database = db
|
||||
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
break
|
||||
default:
|
||||
panic(errors.New("Invalid database type was specified."))
|
||||
}
|
||||
|
||||
c.Database.AutoMigrate(&auth.User{}, &types.File{})
|
||||
|
||||
api.Register(&c)
|
||||
fmt.Printf("Running on port %s\n", getEnv("PORT", "8080"))
|
||||
c.Router.Run()
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue